mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(nextcloud-talk): prevent shared proxy webhook lockouts (#126251)
* fix(nextcloud-talk): isolate proxy webhook rate limits * fix(nextcloud-talk): preserve proxy fallback buckets
This commit is contained in:
committed by
GitHub
parent
bc4ed8dcaf
commit
2020fc2274
@@ -25,6 +25,10 @@ describe("Nextcloud Talk monitor abort", () => {
|
||||
}));
|
||||
const monitor = await monitorNextcloudTalkProvider({
|
||||
config: {
|
||||
gateway: {
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
allowRealIpFallback: true,
|
||||
},
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
@@ -42,6 +46,12 @@ describe("Nextcloud Talk monitor abort", () => {
|
||||
expect(createSpool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ abortSignal: abortController.signal }),
|
||||
);
|
||||
expect(createServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
allowRealIpFallback: true,
|
||||
}),
|
||||
);
|
||||
expect(statusSink).toHaveBeenCalledExactlyOnceWith({
|
||||
running: true,
|
||||
connected: true,
|
||||
|
||||
@@ -109,6 +109,8 @@ export async function monitorNextcloudTalkProvider(
|
||||
onError: (error) => {
|
||||
logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
|
||||
},
|
||||
trustedProxies: cfg.gateway?.trustedProxies,
|
||||
allowRealIpFallback: cfg.gateway?.allowRealIpFallback,
|
||||
abortSignal: opts.abortSignal,
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,41 @@ function createNextcloudTalkWebhookServer(options: TestWebhookServerOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
async function invokeWebhookRequestListener(params: {
|
||||
listener: (req: IncomingMessage, res: ServerResponse) => void;
|
||||
path: string;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
remoteAddress: string;
|
||||
}) {
|
||||
const req = Object.assign(createMockIncomingRequest([params.body]), {
|
||||
method: "POST",
|
||||
url: params.path,
|
||||
headers: params.headers,
|
||||
socket: { remoteAddress: params.remoteAddress },
|
||||
}) as unknown as IncomingMessage;
|
||||
|
||||
return await new Promise<{ body: string; status: number }>((resolve) => {
|
||||
let status = 0;
|
||||
const res = {
|
||||
headersSent: false,
|
||||
writeHead(code: number) {
|
||||
status = code;
|
||||
this.headersSent = true;
|
||||
return this;
|
||||
},
|
||||
setHeader() {
|
||||
return this;
|
||||
},
|
||||
end(body?: string) {
|
||||
resolve({ body: body ?? "", status });
|
||||
return this;
|
||||
},
|
||||
};
|
||||
params.listener(req, res as unknown as ServerResponse);
|
||||
});
|
||||
}
|
||||
|
||||
async function invokeWebhookServerRequest(params: {
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
@@ -43,28 +78,12 @@ async function invokeWebhookServerRequest(params: {
|
||||
if (!listener) {
|
||||
throw new Error("expected Nextcloud Talk request listener");
|
||||
}
|
||||
const req = Object.assign(createMockIncomingRequest([params.body]), {
|
||||
method: "POST",
|
||||
url: "/nextcloud-body-limit",
|
||||
return await invokeWebhookRequestListener({
|
||||
listener,
|
||||
path: "/nextcloud-body-limit",
|
||||
body: params.body,
|
||||
headers: params.headers,
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
}) as unknown as IncomingMessage;
|
||||
|
||||
return await new Promise<{ body: string; status: number }>((resolve) => {
|
||||
let status = 0;
|
||||
const res = {
|
||||
headersSent: false,
|
||||
writeHead(code: number) {
|
||||
status = code;
|
||||
this.headersSent = true;
|
||||
return this;
|
||||
},
|
||||
end(body?: string) {
|
||||
resolve({ body: body ?? "", status });
|
||||
return this;
|
||||
},
|
||||
};
|
||||
listener(req, res as unknown as ServerResponse);
|
||||
remoteAddress: "127.0.0.1",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,6 +349,81 @@ describe("createNextcloudTalkWebhookServer auth rate limiting", () => {
|
||||
expect(await lastResponse?.text()).toBe("Too Many Requests");
|
||||
});
|
||||
|
||||
it("isolates failed-auth limits by forwarded client behind a trusted proxy", async () => {
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-auth-rate-limit-trusted-proxy",
|
||||
authRateLimit: { maxRequests: 1 },
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
onMessage: vi.fn(),
|
||||
});
|
||||
const { body, headers } = createSignedCreateMessageRequest();
|
||||
const attackerHeaders = {
|
||||
...headers,
|
||||
"x-forwarded-for": "198.51.100.10",
|
||||
"x-nextcloud-talk-signature": "invalid-signature",
|
||||
};
|
||||
|
||||
const firstAttack = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: attackerHeaders,
|
||||
body,
|
||||
});
|
||||
const blockedAttack = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: attackerHeaders,
|
||||
body,
|
||||
});
|
||||
const legitimateDelivery = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "x-forwarded-for": "198.51.100.11" },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(firstAttack.status).toBe(401);
|
||||
expect(blockedAttack.status).toBe(429);
|
||||
expect(legitimateDelivery.status).toBe(200);
|
||||
});
|
||||
|
||||
it("keeps unattributed trusted proxies in separate socket buckets", async () => {
|
||||
const path = "/nextcloud-auth-rate-limit-proxy-fallback";
|
||||
const { server } = createNextcloudTalkWebhookServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
path,
|
||||
secret: "nextcloud-secret", // pragma: allowlist secret
|
||||
authRateLimit: { maxRequests: 1 },
|
||||
trustedProxies: ["127.0.0.0/8"],
|
||||
onMessage: vi.fn(),
|
||||
});
|
||||
const listener = server.listeners("request")[0] as
|
||||
| ((req: IncomingMessage, res: ServerResponse) => void)
|
||||
| undefined;
|
||||
if (!listener) {
|
||||
throw new Error("expected Nextcloud Talk request listener");
|
||||
}
|
||||
const { body, headers } = createSignedCreateMessageRequest();
|
||||
const invalidHeaders = {
|
||||
...headers,
|
||||
"x-nextcloud-talk-signature": "invalid-signature",
|
||||
};
|
||||
const invoke = (remoteAddress: string, requestHeaders: Record<string, string>) =>
|
||||
invokeWebhookRequestListener({
|
||||
listener,
|
||||
path,
|
||||
body,
|
||||
headers: requestHeaders,
|
||||
remoteAddress,
|
||||
});
|
||||
|
||||
const firstAttack = await invoke("127.0.0.2", invalidHeaders);
|
||||
const blockedAttack = await invoke("127.0.0.2", invalidHeaders);
|
||||
const legitimateDelivery = await invoke("127.0.0.3", headers);
|
||||
|
||||
expect(firstAttack.status).toBe(401);
|
||||
expect(blockedAttack.status).toBe(429);
|
||||
expect(legitimateDelivery.status).toBe(200);
|
||||
});
|
||||
|
||||
it("does not rate limit valid signed webhook bursts from the same source", async () => {
|
||||
const maxRequests = 1;
|
||||
const harness = await startWebhookServer({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createAuthRateLimiter,
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
resolveRequestClientIp,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
|
||||
@@ -144,7 +145,10 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = req.socket.remoteAddress ?? "unknown";
|
||||
const clientIp =
|
||||
resolveRequestClientIp(req, opts.trustedProxies, opts.allowRealIpFallback) ??
|
||||
req.socket.remoteAddress ??
|
||||
"unknown";
|
||||
if (!webhookAuthRateLimiter.check(clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE).allowed) {
|
||||
res.writeHead(429);
|
||||
res.end("Too Many Requests");
|
||||
|
||||
@@ -3,7 +3,13 @@ import type {
|
||||
ChannelDeliveryStreamingConfig,
|
||||
MessageReceipt,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { DmConfig, DmPolicy, GroupPolicy, SecretInput } from "../runtime-api.js";
|
||||
import type {
|
||||
DmConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
OpenClawConfig,
|
||||
SecretInput,
|
||||
} from "../runtime-api.js";
|
||||
|
||||
export type NextcloudTalkRoomConfig = {
|
||||
requireMention?: boolean;
|
||||
@@ -88,6 +94,7 @@ export type CoreConfig = {
|
||||
channels?: {
|
||||
"nextcloud-talk"?: NextcloudTalkConfig;
|
||||
};
|
||||
gateway?: OpenClawConfig["gateway"];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -179,6 +186,8 @@ export type NextcloudTalkWebhookServerOptions = {
|
||||
};
|
||||
readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise<string>;
|
||||
isBackendAllowed?: (backend: string) => boolean;
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
onWebhook: (rawBody: string) => Promise<"accepted" | "ignored">;
|
||||
onError?: (error: Error) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
|
||||
Reference in New Issue
Block a user