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:
Pavan Kumar Gondhi
2026-08-19 15:59:21 +05:30
committed by GitHub
parent bc4ed8dcaf
commit 2020fc2274
5 changed files with 142 additions and 23 deletions
@@ -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({
+5 -1
View File
@@ -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");
+10 -1
View File
@@ -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;