diff --git a/extensions/nextcloud-talk/src/monitor.limiter-lifecycle.test.ts b/extensions/nextcloud-talk/src/monitor.limiter-lifecycle.test.ts new file mode 100644 index 000000000000..e527446cc40a --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.limiter-lifecycle.test.ts @@ -0,0 +1,44 @@ +// Nextcloud Talk plugin module implements monitor limiter lifecycle behavior. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createNextcloudTalkWebhookServer } from "./monitor.js"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("Nextcloud Talk webhook auth rate limiter lifecycle", () => { + it("releases the limiter prune timer on stop", async () => { + vi.useFakeTimers(); + const baselineTimerCount = vi.getTimerCount(); + const handle = createNextcloudTalkWebhookServer({ + port: 0, + host: "127.0.0.1", + path: "/w", + secret: "s", + onWebhook: async () => "ignored", + }); + expect(vi.getTimerCount()).toBe(baselineTimerCount + 1); + + await handle.stop(); + + expect(vi.getTimerCount()).toBe(baselineTimerCount); + }); + + it("keeps stop idempotent for the limiter timer", async () => { + vi.useFakeTimers(); + const baselineTimerCount = vi.getTimerCount(); + const handle = createNextcloudTalkWebhookServer({ + port: 0, + host: "127.0.0.1", + path: "/w", + secret: "s", + onWebhook: async () => "ignored", + }); + expect(vi.getTimerCount()).toBe(baselineTimerCount + 1); + + await handle.stop(); + await handle.stop(); + + expect(vi.getTimerCount()).toBe(baselineTimerCount); + }); +}); diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts index 8e2be277f6f1..3b906f178e48 100644 --- a/extensions/nextcloud-talk/src/monitor.replay.test.ts +++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts @@ -64,7 +64,7 @@ async function invokeWebhookServerRequest(params: { headers: Record; maxBodyBytes: number; }) { - const { server } = createNextcloudTalkWebhookServer({ + const { server, stop } = createNextcloudTalkWebhookServer({ host: "127.0.0.1", port: 0, path: "/nextcloud-body-limit", @@ -72,19 +72,23 @@ async function invokeWebhookServerRequest(params: { maxBodyBytes: params.maxBodyBytes, 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"); + try { + const listener = server.listeners("request")[0] as + | ((req: IncomingMessage, res: ServerResponse) => void) + | undefined; + if (!listener) { + throw new Error("expected Nextcloud Talk request listener"); + } + return await invokeWebhookRequestListener({ + listener, + path: "/nextcloud-body-limit", + body: params.body, + headers: params.headers, + remoteAddress: "127.0.0.1", + }); + } finally { + await stop(); } - return await invokeWebhookRequestListener({ - listener, - path: "/nextcloud-body-limit", - body: params.body, - headers: params.headers, - remoteAddress: "127.0.0.1", - }); } describe("createNextcloudTalkWebhookServer auth order", () => { @@ -386,7 +390,7 @@ describe("createNextcloudTalkWebhookServer auth rate limiting", () => { it("keeps unattributed trusted proxies in separate socket buckets", async () => { const path = "/nextcloud-auth-rate-limit-proxy-fallback"; - const { server } = createNextcloudTalkWebhookServer({ + const { server, stop } = createNextcloudTalkWebhookServer({ host: "127.0.0.1", port: 0, path, @@ -395,33 +399,37 @@ describe("createNextcloudTalkWebhookServer auth rate limiting", () => { 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"); + try { + 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); + } finally { + await stop(); } - 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 () => { diff --git a/extensions/nextcloud-talk/src/monitor.test-harness.ts b/extensions/nextcloud-talk/src/monitor.test-harness.ts index 48747eeada52..ce1879f290a3 100644 --- a/extensions/nextcloud-talk/src/monitor.test-harness.ts +++ b/extensions/nextcloud-talk/src/monitor.test-harness.ts @@ -61,7 +61,7 @@ export async function startWebhookServer( const host = params.host ?? "127.0.0.1"; const port = params.port ?? 0; const secret = params.secret ?? "nextcloud-secret"; - const { server, start } = createNextcloudTalkWebhookServer({ + const { server, start, stop } = createNextcloudTalkWebhookServer({ ...params, port, host, @@ -75,10 +75,7 @@ export async function startWebhookServer( const harness: WebhookHarness = { webhookUrl: `http://${host}:${address.port}${params.path}`, - stop: () => - new Promise((resolve) => { - server.close(() => resolve()); - }), + stop, }; cleanupFns.push(harness.stop); return harness; diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index a0f72b139cd2..6036b82bc4bd 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -230,6 +230,7 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe const stop = async () => { stopRequested = true; await closeIfListening(); + webhookAuthRateLimiter.dispose(); }; const start = (): Promise => {