fix(auth): bound OIDC initiation and transport

This commit is contained in:
2026-08-17 07:03:19 +02:00
parent 8573500121
commit bdabecbb63
15 changed files with 747 additions and 111 deletions
+87 -32
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import type { AuthenticationConfigProvider, LoadedAuthConfig, OidcAuthenticationConfig, Role } from "./types.js";
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
import type { AuthSessionStore } from "./session-store.js";
import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js";
import { rolesToPermissions } from "./config.js";
import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
import { requirePermission, isPrincipalContext } from "./authorization.js";
@@ -15,6 +15,7 @@ const REMEMBER_COOKIE_SECONDS = 2_592_000;
const MAX_USERNAME_LENGTH = 64;
const MAX_PASSWORD_LENGTH = 1024;
const MAX_LIMIT_ENTRIES = 10_000;
const MAX_OIDC_INITIATIONS_PER_ADDRESS = 20;
const MAX_OIDC_CALLBACK_QUERY_LENGTH = 4096;
const OIDC_CALLBACK_PATH = "/api/auth/oidc/callback";
const OIDC_TRANSACTION_COOKIE = "__Host-thothii_oidc_tx";
@@ -38,6 +39,44 @@ interface LoginPayload {
remember: boolean;
}
function countActiveAttempts(
bucket: ReadonlyMap<string, readonly number[]>,
key: string,
now: number,
): number {
const attempts = bucket.get(key);
if (!attempts) return 0;
const earliest = now - TEN_MINUTES_MS;
let count = 0;
for (const timestamp of attempts) {
if (timestamp > earliest) count += 1;
}
return count;
}
function pruneExpiredAttempts(bucket: Map<string, number[]>, now: number): void {
const earliest = now - TEN_MINUTES_MS;
for (const [key, attempts] of bucket) {
const active = attempts.filter((timestamp) => timestamp > earliest);
if (active.length === 0) bucket.delete(key);
else if (active.length !== attempts.length) bucket.set(key, active);
}
}
function canRecordAttempt(
bucket: ReadonlyMap<string, readonly number[]>,
key: string,
limit: number,
maximumEntries: number,
): boolean {
return (bucket.get(key)?.length ?? 0) < limit
&& (bucket.has(key) || bucket.size < maximumEntries);
}
function appendAttempt(bucket: Map<string, number[]>, key: string, now: number): void {
bucket.set(key, [...(bucket.get(key) ?? []), now]);
}
export class LoginFailureLimiter {
private readonly usernames = new Map<string, number[]>();
private readonly addresses = new Map<string, number[]>();
@@ -48,42 +87,34 @@ export class LoginFailureLimiter {
}
isLimited(username: string, address: string, now = Date.now()): boolean {
return this.countActive(this.usernames, username, now) >= 10
|| this.countActive(this.addresses, address, now) >= 20;
return countActiveAttempts(this.usernames, username, now) >= 10
|| countActiveAttempts(this.addresses, address, now) >= 20;
}
recordFailure(username: string, address: string, now = Date.now()): boolean {
this.pruneExpired(this.usernames, now);
this.pruneExpired(this.addresses, now);
const usernameAttempts = this.usernames.get(username) ?? [];
const addressAttempts = this.addresses.get(address) ?? [];
if (usernameAttempts.length >= 10 || addressAttempts.length >= 20) return false;
if ((!this.usernames.has(username) && this.usernames.size >= this.maximumEntries)
|| (!this.addresses.has(address) && this.addresses.size >= this.maximumEntries)) return false;
this.usernames.set(username, [...usernameAttempts, now]);
this.addresses.set(address, [...addressAttempts, now]);
pruneExpiredAttempts(this.usernames, now);
pruneExpiredAttempts(this.addresses, now);
if (!canRecordAttempt(this.usernames, username, 10, this.maximumEntries)
|| !canRecordAttempt(this.addresses, address, 20, this.maximumEntries)) return false;
appendAttempt(this.usernames, username, now);
appendAttempt(this.addresses, address, now);
return true;
}
}
private countActive(bucket: ReadonlyMap<string, readonly number[]>, key: string, now: number): number {
const attempts = bucket.get(key);
if (!attempts) return 0;
const earliest = now - TEN_MINUTES_MS;
let count = 0;
for (const timestamp of attempts) {
if (timestamp > earliest) count += 1;
}
return count;
}
class OidcInitiationLimiter {
private readonly addresses = new Map<string, number[]>();
private pruneExpired(bucket: Map<string, number[]>, now: number): void {
const earliest = now - TEN_MINUTES_MS;
for (const [key, attempts] of bucket) {
const active = attempts.filter((timestamp) => timestamp > earliest);
if (active.length === 0) bucket.delete(key);
else if (active.length !== attempts.length) bucket.set(key, active);
}
consume(address: string, now = Date.now()): boolean {
pruneExpiredAttempts(this.addresses, now);
if (!canRecordAttempt(
this.addresses,
address,
MAX_OIDC_INITIATIONS_PER_ADDRESS,
MAX_LIMIT_ENTRIES,
)) return false;
appendAttempt(this.addresses, address, now);
return true;
}
}
@@ -120,6 +151,7 @@ async function unavailableAfterDummy(
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependencies): void {
const limiter = new LoginFailureLimiter();
const oidcInitiationLimiter = new OidcInitiationLimiter();
const verificationGate = new VerificationGate();
app.get("/auth/config", async (request, reply) => {
@@ -189,6 +221,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
});
app.get("/auth/oidc/login", async (request, reply) => {
if (!oidcInitiationLimiter.consume(boundedAddress(request.ip))) return loginLimited(reply);
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
const configured = currentOidcConfig(loaded, deps);
if (!configured || !deps.sessionStore) {
@@ -216,8 +249,9 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
clearOidcTransactionCookie(reply);
return unavailable(reply);
}
} catch {
} catch (error) {
clearOidcTransactionCookie(reply);
if (error instanceof OidcStateCapacityError) return loginLimited(reply);
return unavailable(reply);
}
});
@@ -421,7 +455,9 @@ function oidcCallbackUrl(
request: FastifyRequest,
publicUrl: string | undefined,
): { currentUrl?: URL; state?: string } {
if (request.url.length > MAX_OIDC_CALLBACK_QUERY_LENGTH) return {};
if (request.url.length > MAX_OIDC_CALLBACK_QUERY_LENGTH) {
return { state: oversizedOidcCallbackState(request.url) };
}
let supplied: URL;
try {
supplied = new URL(request.url, "http://callback.invalid");
@@ -452,6 +488,25 @@ function oidcCallbackUrl(
return { currentUrl: target, state };
}
function oversizedOidcCallbackState(rawUrl: string): string | undefined {
const prefix = "/auth/oidc/callback?";
if (!rawUrl.startsWith(prefix)) return undefined;
const boundedQuery = rawUrl.slice(prefix.length, MAX_OIDC_CALLBACK_QUERY_LENGTH);
let offset = 0;
while (offset < boundedQuery.length) {
const separator = boundedQuery.indexOf("&", offset);
const end = separator === -1 ? boundedQuery.length : separator;
const parameter = boundedQuery.slice(offset, end);
if (parameter.startsWith("state=")) {
const value = parameter.slice("state=".length);
if (OIDC_VALUE_PATTERN.test(value)) return value;
}
if (separator === -1) break;
offset = separator + 1;
}
return undefined;
}
function oidcCallbackFailed(reply: FastifyReply) {
return reply.code(401).send({ code: "oidc_callback_failed", error: "OIDC sign-in could not be completed" });
}