fix(security): bound board-key auth failure throttling

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-06 16:50:24 +00:00 committed by cryppadotta
parent 64c08ce961
commit f0ba1c96d0
3 changed files with 205 additions and 18 deletions

View File

@ -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;

View File

@ -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<string, { count: number; resetAt: number }>();
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;

View File

@ -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<string, FailureEntry>();
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,
};
},
};
}