fix(security): bound board-key auth failure throttling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
64c08ce961
commit
f0ba1c96d0
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue