diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts index 68cf8af308f..125a306a359 100644 --- a/extensions/nextcloud-talk/src/monitor.replay.test.ts +++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js"; +import { WEBHOOK_RATE_LIMIT_DEFAULTS } from "../runtime-api.js"; import { readNextcloudTalkWebhookBody } from "./monitor.js"; import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js"; import { startWebhookServer } from "./monitor.test-harness.js"; @@ -145,3 +146,57 @@ describe("createNextcloudTalkWebhookServer payload validation", () => { expect(await response.json()).toEqual({ error: "Invalid payload format" }); }); }); + +describe("createNextcloudTalkWebhookServer auth rate limiting", () => { + it("rate limits repeated invalid signature attempts from the same source", async () => { + const harness = await startWebhookServer({ + path: "/nextcloud-auth-rate-limit", + onMessage: vi.fn(), + }); + const { body, headers } = createSignedCreateMessageRequest(); + const invalidHeaders = { + ...headers, + "x-nextcloud-talk-signature": "invalid-signature", + }; + + let firstResponse: Response | undefined; + let lastResponse: Response | undefined; + for (let attempt = 0; attempt <= WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests; attempt += 1) { + const response = await fetch(harness.webhookUrl, { + method: "POST", + headers: invalidHeaders, + body, + }); + if (attempt === 0) { + firstResponse = response; + } + lastResponse = response; + } + + expect(firstResponse).toBeDefined(); + expect(firstResponse?.status).toBe(401); + expect(lastResponse).toBeDefined(); + expect(lastResponse?.status).toBe(429); + expect(await lastResponse?.text()).toBe("Too Many Requests"); + }); + + it("does not rate limit valid signed webhook bursts from the same source", async () => { + const harness = await startWebhookServer({ + path: "/nextcloud-auth-rate-limit-valid", + onMessage: vi.fn(), + }); + const { body, headers } = createSignedCreateMessageRequest(); + + let lastResponse: Response | undefined; + for (let attempt = 0; attempt <= WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests; attempt += 1) { + lastResponse = await fetch(harness.webhookUrl, { + method: "POST", + headers, + body, + }); + } + + expect(lastResponse).toBeDefined(); + expect(lastResponse?.status).toBe(200); + }); +}); diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index 025eb2173bd..ecf3b039193 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -6,6 +6,8 @@ import { } from "openclaw/plugin-sdk/extension-shared"; import { z } from "zod"; import { + WEBHOOK_RATE_LIMIT_DEFAULTS, + createAuthRateLimiter, type RuntimeEnv, isRequestBodyLimitError, readRequestBodyWithLimit, @@ -32,6 +34,7 @@ const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000; const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 64 * 1024; const PREAUTH_WEBHOOK_BODY_TIMEOUT_MS = 5_000; const HEALTH_PATH = "/healthz"; +const WEBHOOK_AUTH_RATE_LIMIT_SCOPE = "nextcloud-talk-webhook-auth"; const NextcloudTalkWebhookPayloadSchema: z.ZodType = z.object({ type: z.enum(["Create", "Update", "Delete"]), actor: z.object({ @@ -125,6 +128,8 @@ function verifyWebhookSignature(params: { body: string; secret: string; res: ServerResponse; + clientIp: string; + authRateLimiter: ReturnType; }): boolean { const isValid = verifyNextcloudTalkSignature({ signature: params.headers.signature, @@ -133,9 +138,11 @@ function verifyWebhookSignature(params: { secret: params.secret, }); if (!isValid) { + params.authRateLimiter.recordFailure(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE); writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature); return false; } + params.authRateLimiter.reset(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE); return true; } @@ -203,6 +210,13 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe const readBody = opts.readBody ?? readNextcloudTalkWebhookBody; const isBackendAllowed = opts.isBackendAllowed; const shouldProcessMessage = opts.shouldProcessMessage; + const webhookAuthRateLimiter = createAuthRateLimiter({ + maxAttempts: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests, + windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, + lockoutMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, + exemptLoopback: false, + pruneIntervalMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, + }); const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.url === HEALTH_PATH) { @@ -217,6 +231,13 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe return; } + const clientIp = req.socket.remoteAddress ?? "unknown"; + if (!webhookAuthRateLimiter.check(clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE).allowed) { + res.writeHead(429); + res.end("Too Many Requests"); + return; + } + try { const headers = validateWebhookHeaders({ req, @@ -234,6 +255,8 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe body, secret, res, + clientIp, + authRateLimiter: webhookAuthRateLimiter, }); if (!hasValidSignature) { return; diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 934ead7568b..b189411aa7d 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -2,6 +2,7 @@ // Keep this list additive and scoped to symbols used under extensions/nextcloud-talk. export { logInboundDrop } from "../channels/logging.js"; +export { createAuthRateLimiter } from "../gateway/auth-rate-limit.js"; export { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; export { @@ -70,10 +71,11 @@ export { requireOpenAllowFrom, } from "../config/zod-schema.core.js"; export { + WEBHOOK_RATE_LIMIT_DEFAULTS, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, -} from "../infra/http-body.js"; +} from "./webhook-ingress.js"; export { waitForAbortSignal } from "../infra/abort-signal.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js";