From 0d3785c718ea2fe516cdf96e91ee4b6bcc7aff07 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:19:52 -0700 Subject: [PATCH] fix(security): timing-safe auth + rate limit SPA endpoints (#3231) - isHttpAuthed(): remove length pre-check that leaks TRIGGER_SECRET length via timing side-channel (CWE-208); wrap timingSafeEqual in try/catch instead since it throws on length mismatch (fixes #3201) - startHttpServer(): add token-bucket rate limiter (10 req/min per endpoint) on /health, /candidate, /reply; returns HTTP 429 when exceeded (fixes #3204) Agent: security-auditor Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- .claude/skills/setup-spa/main.ts | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/.claude/skills/setup-spa/main.ts b/.claude/skills/setup-spa/main.ts index c0a6f53d..9f386ceb 100644 --- a/.claude/skills/setup-spa/main.ts +++ b/.claude/skills/setup-spa/main.ts @@ -1317,8 +1317,13 @@ function isHttpAuthed(req: Request): boolean { if (!TRIGGER_SECRET) return false; const given = req.headers.get("Authorization") ?? ""; const expected = `Bearer ${TRIGGER_SECRET}`; - if (given.length !== expected.length) return false; - return timingSafeEqual(Buffer.from(given), Buffer.from(expected)); + // Use try/catch instead of length pre-check: timingSafeEqual throws on length + // mismatch, and the pre-check leaks the expected secret length via timing (CWE-208). + try { + return timingSafeEqual(Buffer.from(given), Buffer.from(expected)); + } catch { + return false; + } } /** Post a Block Kit candidate card to Slack and store in DB. */ @@ -1597,6 +1602,20 @@ async function postRedditReply(postId: string, replyText: string): Promise(); +function checkRateLimit(endpoint: string): boolean { + const now = Date.now(); + const bucket = rateLimitBuckets.get(endpoint) ?? { count: 0, resetAt: now + 60_000 }; + if (now > bucket.resetAt) { + bucket.count = 0; + bucket.resetAt = now + 60_000; + } + bucket.count = bucket.count + 1; + rateLimitBuckets.set(endpoint, bucket); + return bucket.count <= 10; +} + /** Start the HTTP server for growth candidate ingestion. */ function startHttpServer(client: SlackClient): void { if (!TRIGGER_SECRET) { @@ -1610,6 +1629,9 @@ function startHttpServer(client: SlackClient): void { const url = new URL(req.url); if (req.method === "GET" && url.pathname === "/health") { + if (!checkRateLimit("/health")) { + return Response.json({ error: "rate limit exceeded" }, { status: 429 }); + } return Response.json({ status: "ok", }); @@ -1626,6 +1648,9 @@ function startHttpServer(client: SlackClient): void { }, ); } + if (!checkRateLimit("/candidate")) { + return Response.json({ error: "rate limit exceeded" }, { status: 429 }); + } let body: unknown; try { @@ -1668,6 +1693,9 @@ function startHttpServer(client: SlackClient): void { }, ); } + if (!checkRateLimit("/reply")) { + return Response.json({ error: "rate limit exceeded" }, { status: 429 }); + } const replySchema = v.object({ postId: v.string(),