diff --git a/extensions/nextcloud-talk/src/monitor-runtime.abort.test.ts b/extensions/nextcloud-talk/src/monitor-runtime.abort.test.ts index 2ba37d7a26ab..8c74401a7822 100644 --- a/extensions/nextcloud-talk/src/monitor-runtime.abort.test.ts +++ b/extensions/nextcloud-talk/src/monitor-runtime.abort.test.ts @@ -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, diff --git a/extensions/nextcloud-talk/src/monitor-runtime.ts b/extensions/nextcloud-talk/src/monitor-runtime.ts index d0cfa5f6efb6..6b9c146b0002 100644 --- a/extensions/nextcloud-talk/src/monitor-runtime.ts +++ b/extensions/nextcloud-talk/src/monitor-runtime.ts @@ -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, }); diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts index e32ad55e4138..8e2be277f6f1 100644 --- a/extensions/nextcloud-talk/src/monitor.replay.test.ts +++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts @@ -24,6 +24,41 @@ function createNextcloudTalkWebhookServer(options: TestWebhookServerOptions) { }); } +async function invokeWebhookRequestListener(params: { + listener: (req: IncomingMessage, res: ServerResponse) => void; + path: string; + body: string; + headers: Record; + 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; @@ -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) => + 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({ diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index 0e7abc7193e5..a0f72b139cd2 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -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"); diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index 9925a5c967e7..e30b42e26406 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -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; isBackendAllowed?: (backend: string) => boolean; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; onWebhook: (rawBody: string) => Promise<"accepted" | "ignored">; onError?: (error: Error) => void; abortSignal?: AbortSignal;