fix(auth): bound OIDC initiation and transport
This commit is contained in:
+87
-32
@@ -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" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user