diff --git a/server/src/__tests__/board-key-auth-middleware.test.ts b/server/src/__tests__/board-key-auth-middleware.test.ts index 7413bd02ad..46af6019dd 100644 --- a/server/src/__tests__/board-key-auth-middleware.test.ts +++ b/server/src/__tests__/board-key-auth-middleware.test.ts @@ -15,6 +15,10 @@ import { resetBoardKeyAuthFailureRateLimitForTests, } from "../middleware/auth.js"; import { errorHandler } from "../middleware/error-handler.js"; +import { + BOARD_KEY_AUTH_FAILURE_RATE_LIMIT_DEFAULTS, + createBoardKeyAuthFailureRateLimiter, +} from "../security/board-key-auth-failure-rate-limit.js"; const TOKEN = "pcp_board_middleware_valid_token"; @@ -168,6 +172,69 @@ describe("board-key authentication middleware", () => { expect(JSON.stringify(audits)).not.toContain(`${TOKEN}bad`); }); + it("rate-limits rotating invalid credentials from one source before another database lookup", async () => { + const { db, state } = createDbState(); + state.keyExists = false; + const { app } = createApp(db); + const sourceLimit = BOARD_KEY_AUTH_FAILURE_RATE_LIMIT_DEFAULTS.sourceLimit; + + for (let index = 0; index < sourceLimit; index += 1) { + const response = await request(app) + .get("/actor") + .set("Authorization", `Bearer pcp_board_rotating_${index}`); + expect(response.status).toBe(401); + } + const lookupsBeforeThrottle = db.select.mock.calls.length; + const limited = await request(app) + .get("/actor") + .set("Authorization", "Bearer pcp_board_rotating_blocked"); + + expect(limited.status).toBe(429); + expect(db.select.mock.calls.length).toBe(lookupsBeforeThrottle); + }); + + it("strictly bounds credential and source failure storage during identity floods", () => { + const limiter = createBoardKeyAuthFailureRateLimiter({ + windowMs: 60_000, + credentialLimit: 10_000, + sourceLimit: 10_000, + globalLimit: 10_000, + credentialMaxEntries: 7, + sourceMaxEntries: 5, + }); + + for (let index = 0; index < 100; index += 1) { + limiter.recordFailure({ + credentialId: `credential-${index}`, + sourceId: `source-${index}`, + now: 1, + }); + } + + expect(limiter.snapshot()).toEqual({ + credentialEntries: 7, + sourceEntries: 5, + globalEntries: 1, + }); + }); + + it("opens the global circuit breaker across rotating sources and credentials", () => { + const limiter = createBoardKeyAuthFailureRateLimiter({ + windowMs: 60_000, + credentialLimit: 100, + sourceLimit: 100, + globalLimit: 3, + credentialMaxEntries: 10, + sourceMaxEntries: 10, + }); + + for (let index = 0; index < 3; index += 1) { + limiter.recordFailure({ credentialId: `credential-${index}`, sourceId: `source-${index}`, now: 1 }); + } + + expect(limiter.isLimited({ credentialId: "novel-credential", sourceId: "novel-source", now: 1 })).toBe(true); + }); + it("does not resurrect last-used state when revocation wins the authentication race", async () => { const { db, state } = createDbState(); state.revokeOnTouch = true; diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index ee02ef7407..17a1688295 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -57,6 +57,7 @@ function pruneCloudTenantWriteDebounce( import { instanceSettingsService } from "../services/instance-settings.js"; import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js"; import { forbidden, tooManyRequests, unauthorized, unprocessable } from "../errors.js"; +import { createBoardKeyAuthFailureRateLimiter } from "../security/board-key-auth-failure-rate-limit.js"; export { isCloudManagedInstance } from "../services/cloud-instance.js"; @@ -64,27 +65,14 @@ function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } -const BOARD_KEY_AUTH_FAILURE_WINDOW_MS = 60_000; -const BOARD_KEY_AUTH_FAILURE_LIMIT = 10; -const boardKeyAuthFailures = new Map(); +const boardKeyAuthFailureRateLimiter = createBoardKeyAuthFailureRateLimiter(); -function recordBoardKeyAuthFailure(token: string, now = Date.now()) { - const identity = hashToken(token); - const existing = boardKeyAuthFailures.get(identity); - const entry = !existing || existing.resetAt <= now - ? { count: 1, resetAt: now + BOARD_KEY_AUTH_FAILURE_WINDOW_MS } - : { count: existing.count + 1, resetAt: existing.resetAt }; - boardKeyAuthFailures.set(identity, entry); - if (boardKeyAuthFailures.size > 10_000) { - for (const [key, value] of boardKeyAuthFailures) { - if (value.resetAt <= now) boardKeyAuthFailures.delete(key); - } - } - return entry.count > BOARD_KEY_AUTH_FAILURE_LIMIT; +function boardKeyAuthFailureSource(req: Request) { + return req.socket.remoteAddress || req.ip || "unknown"; } export function resetBoardKeyAuthFailureRateLimitForTests() { - boardKeyAuthFailures.clear(); + boardKeyAuthFailureRateLimiter.reset(); } async function auditBoardKeyAuthenticationFailure( @@ -356,10 +344,22 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa } if (token.startsWith("pcp_board_")) { + const failureIdentity = { + credentialId: hashToken(token), + sourceId: boardKeyAuthFailureSource(req), + }; + if (boardKeyAuthFailureRateLimiter.isLimited(failureIdentity)) { + next(tooManyRequests("Too many authentication failures")); + return; + } const authentication = await boardAuth.authenticateBoardApiKey(token); if (!authentication.ok) { await auditBoardKeyAuthenticationFailure(db, req, authentication); - next(recordBoardKeyAuthFailure(token) ? tooManyRequests("Too many authentication failures") : unauthorized()); + next( + boardKeyAuthFailureRateLimiter.recordFailure(failureIdentity) + ? tooManyRequests("Too many authentication failures") + : unauthorized(), + ); return; } const { key: boardKey, access, scopeConfig } = authentication; diff --git a/server/src/security/board-key-auth-failure-rate-limit.ts b/server/src/security/board-key-auth-failure-rate-limit.ts new file mode 100644 index 0000000000..fa73ae8b4d --- /dev/null +++ b/server/src/security/board-key-auth-failure-rate-limit.ts @@ -0,0 +1,120 @@ +export type BoardKeyAuthFailureRateLimitConfig = { + windowMs: number; + credentialLimit: number; + sourceLimit: number; + globalLimit: number; + credentialMaxEntries: number; + sourceMaxEntries: number; +}; + +export const BOARD_KEY_AUTH_FAILURE_RATE_LIMIT_DEFAULTS = { + windowMs: 60_000, + credentialLimit: 10, + sourceLimit: 50, + globalLimit: 1_000, + credentialMaxEntries: 4_096, + sourceMaxEntries: 1_024, +} satisfies BoardKeyAuthFailureRateLimitConfig; + +type FailureEntry = { count: number; resetAt: number }; + +class BoundedFixedWindowFailures { + private readonly entries = new Map(); + + constructor( + private readonly limit: number, + private readonly windowMs: number, + private readonly maxEntries: number, + ) {} + + isLimited(identity: string, now: number) { + const entry = this.entries.get(identity); + if (!entry) return false; + if (entry.resetAt <= now) { + this.entries.delete(identity); + return false; + } + return entry.count >= this.limit; + } + + record(identity: string, now: number) { + const existing = this.entries.get(identity); + const entry = !existing || existing.resetAt <= now + ? { count: 1, resetAt: now + this.windowMs } + : { count: existing.count + 1, resetAt: existing.resetAt }; + + // Refresh insertion order so capacity eviction removes the least recently + // used identity. The store remains bounded even during unique-token floods. + if (existing) this.entries.delete(identity); + this.ensureCapacity(now); + this.entries.set(identity, entry); + return entry.count > this.limit; + } + + clear() { + this.entries.clear(); + } + + get size() { + return this.entries.size; + } + + private ensureCapacity(now: number) { + if (this.entries.size < this.maxEntries) return; + for (const [identity, entry] of this.entries) { + if (entry.resetAt <= now) this.entries.delete(identity); + } + while (this.entries.size >= this.maxEntries) { + const oldestIdentity = this.entries.keys().next().value; + if (oldestIdentity === undefined) break; + this.entries.delete(oldestIdentity); + } + } +} + +export function createBoardKeyAuthFailureRateLimiter( + config: BoardKeyAuthFailureRateLimitConfig = BOARD_KEY_AUTH_FAILURE_RATE_LIMIT_DEFAULTS, +) { + const credentials = new BoundedFixedWindowFailures( + config.credentialLimit, + config.windowMs, + config.credentialMaxEntries, + ); + const sources = new BoundedFixedWindowFailures( + config.sourceLimit, + config.windowMs, + config.sourceMaxEntries, + ); + const global = new BoundedFixedWindowFailures(config.globalLimit, config.windowMs, 1); + + return { + isLimited(input: { credentialId: string; sourceId: string; now?: number }) { + const now = input.now ?? Date.now(); + return global.isLimited("global", now) + || sources.isLimited(input.sourceId, now) + || credentials.isLimited(input.credentialId, now); + }, + + recordFailure(input: { credentialId: string; sourceId: string; now?: number }) { + const now = input.now ?? Date.now(); + const globalLimited = global.record("global", now); + const sourceLimited = sources.record(input.sourceId, now); + const credentialLimited = credentials.record(input.credentialId, now); + return globalLimited || sourceLimited || credentialLimited; + }, + + reset() { + credentials.clear(); + sources.clear(); + global.clear(); + }, + + snapshot() { + return { + credentialEntries: credentials.size, + sourceEntries: sources.size, + globalEntries: global.size, + }; + }, + }; +}