mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-20 01:11:18 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
a9b429e0fd
commit
0d3785c718
1 changed files with 30 additions and 2 deletions
|
|
@ -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<Respo
|
|||
});
|
||||
}
|
||||
|
||||
/** Simple token-bucket rate limiter: max 10 requests per minute per endpoint. */
|
||||
const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();
|
||||
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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue