diff --git a/apps/web/src/http-security.test.ts b/apps/web/src/http-security.test.ts index 7f23828..0aa3d80 100644 --- a/apps/web/src/http-security.test.ts +++ b/apps/web/src/http-security.test.ts @@ -58,6 +58,23 @@ test("InMemoryRateLimiter blocks after the configured request budget is exhauste assert.equal(blocked.retryAfterSeconds, 58); }); +test("InMemoryRateLimiter drops expired buckets during periodic sweeps", () => { + const limiter = new InMemoryRateLimiter(); + const policy = { + id: "auth", + windowMs: 1_000, + maxRequests: 1, + }; + + assert.deepEqual(limiter.consume(policy, "stale", 1_000), { allowed: true }); + + for (let index = 0; index < 99; index += 1) { + limiter.consume(policy, `fresh-${index}`, 3_000); + } + + assert.deepEqual(limiter.consume(policy, "stale", 3_000), { allowed: true }); +}); + test("assertTrustedOrigin accepts configured origins and requires browser origin metadata", () => { const request = createMockRequest({ headers: { diff --git a/apps/web/src/http-security.ts b/apps/web/src/http-security.ts index 7489deb..b944488 100644 --- a/apps/web/src/http-security.ts +++ b/apps/web/src/http-security.ts @@ -66,8 +66,15 @@ export async function readJsonBody( export class InMemoryRateLimiter { private readonly entries = new Map(); + private sweepCounter = 0; consume(policy: RateLimitPolicy, key: string, now = Date.now()): RateLimitDecision { + this.sweepCounter += 1; + + if (this.sweepCounter % 100 === 0) { + this.deleteExpiredEntries(now); + } + const bucketKey = `${policy.id}:${key}`; const existing = this.entries.get(bucketKey); @@ -90,6 +97,14 @@ export class InMemoryRateLimiter { this.entries.set(bucketKey, existing); return { allowed: true }; } + + private deleteExpiredEntries(now: number): void { + for (const [bucketKey, entry] of this.entries.entries()) { + if (entry.resetAt <= now) { + this.entries.delete(bucketKey); + } + } + } } export function getRateLimitClientIp(request: IncomingMessage): string { diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index f4ad34a..ab22908 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -70,6 +70,7 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/register") { + assertTrustedOrigin(request, mutationAllowedOrigins); enforceRateLimit(request, authRateLimitPolicy); const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readAuthPayload(body); @@ -89,6 +90,7 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/login") { + assertTrustedOrigin(request, mutationAllowedOrigins); enforceRateLimit(request, authRateLimitPolicy); const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readAuthPayload(body); @@ -108,6 +110,7 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/password-reset/request") { + assertTrustedOrigin(request, mutationAllowedOrigins); enforceRateLimit(request, authRateLimitPolicy); const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readEmailOnlyPayload(body); @@ -137,6 +140,7 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/password-reset/confirm") { + assertTrustedOrigin(request, mutationAllowedOrigins); enforceRateLimit(request, authRateLimitPolicy); const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readPasswordResetConfirmPayload(body);