diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index a26a161de2d..7c78fbdb5e3 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -147,6 +147,125 @@ describe("createWebhookHandler", () => { expect(res._status).toBe(401); }); + it("rate limits repeated invalid token guesses before the correct token can succeed", async () => { + const weakToken = "00000129"; + const deliver = vi.fn().mockResolvedValue(null); + const handler = createWebhookHandler({ + account: makeAccount({ + accountId: "weak-token-bruteforce-" + Date.now(), + token: weakToken, + rateLimitPerMinute: 5, + }), + deliver, + log, + }); + + let guessedToken: string | null = null; + let saw429 = false; + + for (let i = 0; i < 130; i += 1) { + const candidate = String(i).padStart(8, "0"); + const req = makeReq( + "POST", + makeFormBody({ + token: candidate, + user_id: "123", + username: "testuser", + text: "Hello bot", + }), + ); + (req.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10"; + const res = makeRes(); + await handler(req, res); + + if (res._status === 429) { + saw429 = true; + break; + } + + if (res._status === 204) { + guessedToken = candidate; + break; + } + + expect(res._status).toBe(401); + } + + expect(saw429).toBe(true); + expect(guessedToken).toBeNull(); + const lockedReq = makeReq( + "POST", + makeFormBody({ + token: weakToken, + user_id: "123", + username: "testuser", + text: "Hello bot", + }), + ); + (lockedReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10"; + const lockedRes = makeRes(); + await handler(lockedReq, lockedRes); + + expect(lockedRes._status).toBe(429); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("keeps pre-auth throttling scoped to the remote IP", async () => { + const deliver = vi.fn().mockResolvedValue(null); + const handler = createWebhookHandler({ + account: makeAccount({ + accountId: "preauth-ip-scope-" + Date.now(), + rateLimitPerMinute: 1, + }), + deliver, + log, + }); + + const invalidReq = makeReq( + "POST", + makeFormBody({ + token: "wrong-token", + user_id: "123", + username: "testuser", + text: "Hello", + }), + ); + (invalidReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10"; + const invalidRes = makeRes(); + await handler(invalidReq, invalidRes); + expect(invalidRes._status).toBe(401); + + const validReq = makeReq("POST", validBody); + (validReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.11"; + const validRes = makeRes(); + await handler(validReq, validRes); + + expect(validRes._status).toBe(204); + expect(deliver).toHaveBeenCalledTimes(1); + }); + + it("does not spend invalid-token budget on successful requests", async () => { + const deliver = vi.fn().mockResolvedValue(null); + const handler = createWebhookHandler({ + account: makeAccount({ + accountId: "invalid-token-budget-" + Date.now(), + rateLimitPerMinute: 30, + }), + deliver, + log, + }); + + for (let i = 0; i < 11; i += 1) { + const req = makeReq("POST", validBody); + (req.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.20"; + const res = makeRes(); + await handler(req, res); + expect(res._status).toBe(204); + } + + expect(deliver).toHaveBeenCalledTimes(11); + }); + it("accepts application/json with alias fields", async () => { const deliver = vi.fn().mockResolvedValue(null); const handler = createWebhookHandler({ diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index d2c6427d993..5409c02b214 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -16,8 +16,77 @@ import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./type // One rate limiter per account, created lazily const rateLimiters = new Map(); +const invalidTokenRateLimiters = new Map(); const PREAUTH_MAX_BODY_BYTES = 64 * 1024; const PREAUTH_BODY_TIMEOUT_MS = 5_000; +const PREAUTH_MAX_REQUESTS_PER_MINUTE = 10; +const INVALID_TOKEN_WINDOW_MS = 60_000; +const INVALID_TOKEN_MAX_TRACKED_KEYS = 5_000; + +type InvalidTokenRateLimitState = { + count: number; + windowStartMs: number; +}; + +class InvalidTokenRateLimiter { + private readonly limit: number; + private readonly state = new Map(); + + constructor(limit: number) { + this.limit = limit; + } + + private normalizeState(key: string, nowMs: number): InvalidTokenRateLimitState | undefined { + const existing = this.state.get(key); + if (!existing) { + return undefined; + } + if (nowMs - existing.windowStartMs >= INVALID_TOKEN_WINDOW_MS) { + this.state.delete(key); + return undefined; + } + return existing; + } + + private touch(key: string, value: InvalidTokenRateLimitState): void { + this.state.delete(key); + this.state.set(key, value); + while (this.state.size > INVALID_TOKEN_MAX_TRACKED_KEYS) { + const oldestKey = this.state.keys().next().value; + if (!oldestKey) { + break; + } + this.state.delete(oldestKey); + } + } + + isLocked(key: string, nowMs = Date.now()): boolean { + if (!key) { + return false; + } + const existing = this.normalizeState(key, nowMs); + return (existing?.count ?? 0) > this.limit; + } + + recordFailure(key: string, nowMs = Date.now()): boolean { + if (!key) { + return false; + } + const existing = this.normalizeState(key, nowMs); + const nextCount = (existing?.count ?? 0) + 1; + const windowStartMs = existing?.windowStartMs ?? nowMs; + this.touch(key, { count: nextCount, windowStartMs }); + return nextCount > this.limit; + } + + clear(): void { + this.state.clear(); + } + + maxRequests(): number { + return this.limit; + } +} function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter { let rl = rateLimiters.get(account.accountId); @@ -29,15 +98,34 @@ function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter { return rl; } +function getInvalidTokenRateLimiter(account: ResolvedSynologyChatAccount): InvalidTokenRateLimiter { + const limit = Math.min(account.rateLimitPerMinute, PREAUTH_MAX_REQUESTS_PER_MINUTE); + let rl = invalidTokenRateLimiters.get(account.accountId); + if (!rl || rl.maxRequests() !== limit) { + rl?.clear(); + rl = new InvalidTokenRateLimiter(limit); + invalidTokenRateLimiters.set(account.accountId, rl); + } + return rl; +} + export function clearSynologyWebhookRateLimiterStateForTest(): void { for (const limiter of rateLimiters.values()) { limiter.clear(); } rateLimiters.clear(); + for (const limiter of invalidTokenRateLimiters.values()) { + limiter.clear(); + } + invalidTokenRateLimiters.clear(); } export function getSynologyWebhookRateLimiterCountForTest(): number { - return rateLimiters.size; + return rateLimiters.size + invalidTokenRateLimiters.size; +} + +function getSynologyWebhookInvalidTokenRateLimitKey(req: IncomingMessage): string { + return req.socket?.remoteAddress ?? "unknown"; } /** Read the full request body as a string. */ @@ -281,10 +369,22 @@ function authorizeSynologyWebhook(params: { req: IncomingMessage; account: ResolvedSynologyChatAccount; payload: SynologyWebhookPayload; + invalidTokenRateLimiter: InvalidTokenRateLimiter; rateLimiter: RateLimiter; log?: WebhookHandlerDeps["log"]; }): SynologyWebhookAuthorization { + const invalidTokenRateLimitKey = getSynologyWebhookInvalidTokenRateLimitKey(params.req); + // Once a source has exhausted its invalid-token budget, reject all requests in the window. + if (params.invalidTokenRateLimiter.isLocked(invalidTokenRateLimitKey)) { + params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`); + return { ok: false, statusCode: 429, error: "Rate limit exceeded" }; + } + if (!validateToken(params.payload.token, params.account.token)) { + if (params.invalidTokenRateLimiter.recordFailure(invalidTokenRateLimitKey)) { + params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`); + return { ok: false, statusCode: 429, error: "Rate limit exceeded" }; + } params.log?.warn(`Invalid token from ${params.req.socket?.remoteAddress}`); return { ok: false, statusCode: 401, error: "Invalid token" }; } @@ -313,6 +413,7 @@ function authorizeSynologyWebhook(params: { } if (!params.rateLimiter.check(params.payload.user_id)) { + // Keep a separate post-auth budget so authenticated users are still throttled per sender. params.log?.warn(`Rate limit exceeded for user: ${params.payload.user_id}`); return { ok: false, statusCode: 429, error: "Rate limit exceeded" }; } @@ -332,6 +433,7 @@ async function parseAndAuthorizeSynologyWebhook(params: { req: IncomingMessage; res: ServerResponse; account: ResolvedSynologyChatAccount; + invalidTokenRateLimiter: InvalidTokenRateLimiter; rateLimiter: RateLimiter; log?: WebhookHandlerDeps["log"]; }): Promise<{ ok: false } | { ok: true; message: AuthorizedSynologyWebhook }> { @@ -344,6 +446,7 @@ async function parseAndAuthorizeSynologyWebhook(params: { req: params.req, account: params.account, payload: parsed.payload, + invalidTokenRateLimiter: params.invalidTokenRateLimiter, rateLimiter: params.rateLimiter, log: params.log, }); @@ -453,6 +556,7 @@ async function processAuthorizedSynologyWebhook(params: { export function createWebhookHandler(deps: WebhookHandlerDeps) { const { account, deliver, log } = deps; const rateLimiter = getRateLimiter(account); + const invalidTokenRateLimiter = getInvalidTokenRateLimiter(account); return async (req: IncomingMessage, res: ServerResponse) => { // Only accept POST @@ -464,6 +568,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { req, res, account, + invalidTokenRateLimiter, rateLimiter, log, });