fix(auth): paginate session maintenance safely

This commit is contained in:
2026-08-17 09:05:20 +02:00
parent 3e7bb11313
commit c86f01e886
12 changed files with 1021 additions and 79 deletions
+118 -18
View File
@@ -1,6 +1,13 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import type { AuthenticationConfigProvider, LoadedAuthConfig, OidcAuthenticationConfig, Role } from "./types.js"; import type {
AuthenticationConfigProvider,
LoadedAuthConfig,
OidcAuthenticationConfig,
OidcStateRecord,
OidcTransactionTransport,
Role,
} from "./types.js";
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js"; import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js"; import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js";
import { rolesToPermissions } from "./config.js"; import { rolesToPermissions } from "./config.js";
@@ -9,6 +16,7 @@ import { requirePermission, isPrincipalContext } from "./authorization.js";
import { deriveCsrfToken } from "./csrf.js"; import { deriveCsrfToken } from "./csrf.js";
import { verifyWithDummy } from "./password.js"; import { verifyWithDummy } from "./password.js";
import type { OidcProtocol } from "./oidc-client.js"; import type { OidcProtocol } from "./oidc-client.js";
import { parseConfiguredTransportUrl } from "./url-policy.js";
const TEN_MINUTES_MS = 10 * 60 * 1000; const TEN_MINUTES_MS = 10 * 60 * 1000;
const REMEMBER_COOKIE_SECONDS = 2_592_000; const REMEMBER_COOKIE_SECONDS = 2_592_000;
@@ -18,10 +26,20 @@ const MAX_LIMIT_ENTRIES = 10_000;
const MAX_OIDC_INITIATIONS_PER_ADDRESS = 20; const MAX_OIDC_INITIATIONS_PER_ADDRESS = 20;
const MAX_OIDC_CALLBACK_QUERY_LENGTH = 4096; const MAX_OIDC_CALLBACK_QUERY_LENGTH = 4096;
const OIDC_CALLBACK_PATH = "/api/auth/oidc/callback"; const OIDC_CALLBACK_PATH = "/api/auth/oidc/callback";
const OIDC_TRANSACTION_COOKIE = "__Host-thothii_oidc_tx";
const OIDC_TRANSACTION_COOKIE_SECONDS = TEN_MINUTES_MS / 1000; const OIDC_TRANSACTION_COOKIE_SECONDS = TEN_MINUTES_MS / 1000;
const OIDC_VALUE_PATTERN = /^[A-Za-z0-9_-]{43}$/; const OIDC_VALUE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
interface OidcTransactionCookieProfile {
transport: OidcTransactionTransport;
name: string;
secure: boolean;
}
const OIDC_TRANSACTION_COOKIE_PROFILES: Readonly<Record<OidcTransactionTransport, OidcTransactionCookieProfile>> = {
https: { transport: "https", name: "__Host-thothii_oidc_tx", secure: true },
loopback_http: { transport: "loopback_http", name: "thothii_oidc_tx", secure: false },
};
export interface AuthRouteDependencies { export interface AuthRouteDependencies {
authMode: "local" | "oidc" | "upstream" | "none" | "mock"; authMode: "local" | "oidc" | "upstream" | "none" | "mock";
authentication?: AuthenticationConfigProvider; authentication?: AuthenticationConfigProvider;
@@ -224,8 +242,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
if (!oidcInitiationLimiter.consume(boundedAddress(request.ip))) return loginLimited(reply); if (!oidcInitiationLimiter.consume(boundedAddress(request.ip))) return loginLimited(reply);
const loaded = captureAuthConfigSnapshot(request, deps.authentication); const loaded = captureAuthConfigSnapshot(request, deps.authentication);
const configured = currentOidcConfig(loaded, deps); const configured = currentOidcConfig(loaded, deps);
if (!configured || !deps.sessionStore) { const transactionProfile = configured === undefined
clearOidcTransactionCookie(reply); ? undefined
: oidcTransactionCookieProfile(configured.loaded);
if (!configured || !transactionProfile || !deps.sessionStore) {
clearOidcTransactionCookies(reply);
return unavailable(reply); return unavailable(reply);
} }
const nonce = randomOidcValue(); const nonce = randomOidcValue();
@@ -239,18 +260,20 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
authConfigRevision: configured.loaded.revision, authConfigRevision: configured.loaded.revision,
issuer: configured.config.oidc.issuer, issuer: configured.config.oidc.issuer,
browserTransactionDigest: oidcTransactionDigest(browserTransaction).toString("hex"), browserTransactionDigest: oidcTransactionDigest(browserTransaction).toString("hex"),
browserTransactionTransport: transactionProfile.transport,
}); });
try { try {
const location = await configured.protocol.authorizationUrl({ state: created.state, nonce, codeVerifier }); const location = await configured.protocol.authorizationUrl({ state: created.state, nonce, codeVerifier });
reply.setCookie(OIDC_TRANSACTION_COOKIE, browserTransaction, oidcTransactionCookieOptions()); clearOidcTransactionCookie(reply, otherOidcTransactionCookieProfile(transactionProfile));
reply.setCookie(transactionProfile.name, browserTransaction, oidcTransactionCookieOptions(transactionProfile));
return reply.redirect(location.href); return reply.redirect(location.href);
} catch { } catch {
await deps.sessionStore.consumeOidcState(created.state).catch(() => undefined); await deps.sessionStore.consumeOidcState(created.state).catch(() => undefined);
clearOidcTransactionCookie(reply); clearOidcTransactionCookies(reply, transactionProfile);
return unavailable(reply); return unavailable(reply);
} }
} catch (error) { } catch (error) {
clearOidcTransactionCookie(reply); clearOidcTransactionCookies(reply, transactionProfile);
if (error instanceof OidcStateCapacityError) return loginLimited(reply); if (error instanceof OidcStateCapacityError) return loginLimited(reply);
return unavailable(reply); return unavailable(reply);
} }
@@ -258,20 +281,31 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
app.get("/auth/oidc/callback", async (request, reply) => { app.get("/auth/oidc/callback", async (request, reply) => {
const loaded = captureAuthConfigSnapshot(request, deps.authentication); const loaded = captureAuthConfigSnapshot(request, deps.authentication);
clearOidcTransactionCookie(reply);
const callback = oidcCallbackUrl(request, loaded?.value.publicUrl); const callback = oidcCallbackUrl(request, loaded?.value.publicUrl);
if (!deps.sessionStore || !callback.state) return oidcCallbackFailed(reply); if (!deps.sessionStore || !callback.state) {
let state; clearOidcTransactionCookies(reply);
return oidcCallbackFailed(reply);
}
let state: OidcStateRecord | undefined;
try { try {
state = await deps.sessionStore.consumeOidcState(callback.state); state = await deps.sessionStore.consumeOidcState(callback.state);
} catch { } catch {
clearOidcTransactionCookies(reply);
return oidcCallbackFailed(reply); return oidcCallbackFailed(reply);
} }
const stateTransactionProfile = oidcTransactionCookieProfileForTransport(state?.browserTransactionTransport);
clearOidcTransactionCookies(reply, stateTransactionProfile);
const configured = currentOidcConfig(loaded, deps); const configured = currentOidcConfig(loaded, deps);
if (!callback.currentUrl || !configured || !state || state.returnTo !== "/" const configuredTransactionProfile = configured === undefined
|| !oidcTransactionMatches(request.cookies[OIDC_TRANSACTION_COOKIE], state.browserTransactionDigest) ? undefined
: oidcTransactionCookieProfile(configured.loaded);
const transaction = readOidcTransactionCookie(request, stateTransactionProfile);
const transactionMatches = oidcTransactionMatches(transaction, state?.browserTransactionDigest ?? "");
if (!callback.currentUrl || !configured || !configuredTransactionProfile || !state || !stateTransactionProfile
|| state.returnTo !== "/" || !transactionMatches
|| state.authConfigRevision !== configured.loaded.revision || state.authConfigRevision !== configured.loaded.revision
|| state.issuer !== configured.config.oidc.issuer) { || state.issuer !== configured.config.oidc.issuer
|| stateTransactionProfile.transport !== configuredTransactionProfile.transport) {
return oidcCallbackFailed(reply); return oidcCallbackFailed(reply);
} }
try { try {
@@ -432,25 +466,91 @@ function oidcTransactionMatches(value: string | undefined, expectedDigest: strin
return canonical && expectedCanonical && matches; return canonical && expectedCanonical && matches;
} }
function oidcTransactionCookieOptions() { function oidcTransactionCookieProfile(loaded: LoadedAuthConfig): OidcTransactionCookieProfile | undefined {
try {
if (loaded.value.mode !== "oidc") return undefined;
const publicUrl = parseConfiguredTransportUrl(loaded.value.publicUrl, {
allowLoopbackHttp: true,
originOnly: true,
});
if (!publicUrl) return undefined;
if (publicUrl.protocol === "https:") return OIDC_TRANSACTION_COOKIE_PROFILES.https;
if (publicUrl.protocol === "http:") return OIDC_TRANSACTION_COOKIE_PROFILES.loopback_http;
return undefined;
} catch {
return undefined;
}
}
function oidcTransactionCookieProfileForTransport(
transport: OidcTransactionTransport | undefined,
): OidcTransactionCookieProfile | undefined {
return transport === "https" || transport === "loopback_http"
? OIDC_TRANSACTION_COOKIE_PROFILES[transport]
: undefined;
}
function otherOidcTransactionCookieProfile(
profile: OidcTransactionCookieProfile,
): OidcTransactionCookieProfile {
return profile.transport === "https"
? OIDC_TRANSACTION_COOKIE_PROFILES.loopback_http
: OIDC_TRANSACTION_COOKIE_PROFILES.https;
}
function oidcTransactionCookieOptions(profile: OidcTransactionCookieProfile) {
return { return {
httpOnly: true, httpOnly: true,
sameSite: "lax" as const, sameSite: "lax" as const,
path: "/", path: "/",
secure: true, secure: profile.secure,
maxAge: OIDC_TRANSACTION_COOKIE_SECONDS, maxAge: OIDC_TRANSACTION_COOKIE_SECONDS,
}; };
} }
function clearOidcTransactionCookie(reply: FastifyReply): void { function clearOidcTransactionCookie(reply: FastifyReply, profile: OidcTransactionCookieProfile): void {
reply.clearCookie(OIDC_TRANSACTION_COOKIE, { reply.clearCookie(profile.name, {
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
secure: true, secure: profile.secure,
}); });
} }
function clearOidcTransactionCookies(reply: FastifyReply, preferred?: OidcTransactionCookieProfile): void {
if (preferred) {
clearOidcTransactionCookie(reply, preferred);
clearOidcTransactionCookie(reply, otherOidcTransactionCookieProfile(preferred));
return;
}
clearOidcTransactionCookie(reply, OIDC_TRANSACTION_COOKIE_PROFILES.https);
clearOidcTransactionCookie(reply, OIDC_TRANSACTION_COOKIE_PROFILES.loopback_http);
}
/**
* Browser parsers choose one duplicate cookie value differently. Parse just our two fixed names
* from the raw header and reject duplicates or a cross-transport sibling before hashing.
*/
function readOidcTransactionCookie(
request: FastifyRequest,
expected: OidcTransactionCookieProfile | undefined,
): string | undefined {
const raw = request.headers.cookie;
if (!expected || typeof raw !== "string" || raw.length > 4096 || Array.isArray(raw)) return undefined;
let transactionCookies = 0;
let expectedCookies = 0;
let expectedValue: string | undefined;
for (const part of raw.split(";")) {
const match = /^\s*(__Host-thothii_oidc_tx|thothii_oidc_tx)(?:=([^;]*))?\s*$/.exec(part);
if (!match) continue;
transactionCookies += 1;
if (match[1] !== expected.name) continue;
expectedCookies += 1;
expectedValue = match[2];
}
return transactionCookies === 1 && expectedCookies === 1 ? expectedValue : undefined;
}
function oidcCallbackUrl( function oidcCallbackUrl(
request: FastifyRequest, request: FastifyRequest,
publicUrl: string | undefined, publicUrl: string | undefined,
+161 -27
View File
@@ -21,7 +21,13 @@ import type { Stats } from "node:fs";
import { dirname, isAbsolute, join, normalize } from "node:path"; import { dirname, isAbsolute, join, normalize } from "node:path";
import { z } from "zod"; import { z } from "zod";
import type { PrincipalContext } from "./principal.js"; import type { PrincipalContext } from "./principal.js";
import type { AuthSessionRecord, OidcStateRecord, Permission, Role } from "./types.js"; import type {
AuthSessionRecord,
OidcStateRecord,
OidcTransactionTransport,
Permission,
Role,
} from "./types.js";
import { import {
createWindowsAuthStorageBridge, createWindowsAuthStorageBridge,
type WindowsAuthStorageBridge, type WindowsAuthStorageBridge,
@@ -42,6 +48,9 @@ const OIDC_STATE_TTL_MS = 10 * 60 * 1000;
const OIDC_STATE_CAPACITY = 64; const OIDC_STATE_CAPACITY = 64;
const MAX_OIDC_STORAGE_ENTRIES = OIDC_STATE_CAPACITY * 3; const MAX_OIDC_STORAGE_ENTRIES = OIDC_STATE_CAPACITY * 3;
const MAX_SESSION_PRUNE_ENTRIES = 512; const MAX_SESSION_PRUNE_ENTRIES = 512;
// A separate scan ceiling bounds directory-walk CPU and the duplicate-detection set without
// reviving the former 512-record availability ceiling.
const MAX_SESSION_DIRECTORY_PAGE_SCAN_ENTRIES = 16_384;
const TOUCH_INTERVAL_MS = 5 * 60 * 1000; const TOUCH_INTERVAL_MS = 5 * 60 * 1000;
const CSRF_CONTEXT = Buffer.from("thothii-csrf-v1", "utf8"); const CSRF_CONTEXT = Buffer.from("thothii-csrf-v1", "utf8");
const EMPTY_HKDF_SALT = Buffer.alloc(0); const EMPTY_HKDF_SALT = Buffer.alloc(0);
@@ -76,6 +85,7 @@ export interface OidcStateCreateInput {
authConfigRevision: string; authConfigRevision: string;
issuer: string; issuer: string;
browserTransactionDigest: string; browserTransactionDigest: string;
browserTransactionTransport: OidcTransactionTransport;
} }
export interface CreatedOidcState { export interface CreatedOidcState {
@@ -149,6 +159,11 @@ interface DirectoryIdentity {
mode?: number; mode?: number;
} }
interface DirectoryScanIdentity extends DirectoryIdentity {
mtimeMs: number;
ctimeMs: number;
}
interface TrustedFile<T> { interface TrustedFile<T> {
value: T; value: T;
identity: FileIdentity; identity: FileIdentity;
@@ -160,6 +175,11 @@ interface StorageDirectories {
oidc: string; oidc: string;
} }
interface SessionDirectoryPage {
entries: string[];
more: boolean;
}
const text = z.string().min(1).max(512).refine((value) => !/[\u0000-\u001f\u007f]/.test(value)); const text = z.string().min(1).max(512).refine((value) => !/[\u0000-\u001f\u007f]/.test(value));
const timestamp = z.string().length(24).refine((value) => { const timestamp = z.string().length(24).refine((value) => {
const parsed = Date.parse(value); const parsed = Date.parse(value);
@@ -207,6 +227,9 @@ const oidcStateRecordSchema = z.strictObject({
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/), authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)), issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/), browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
// Existing ten-minute records from before this field was introduced can be consumed and
// rejected by the route. New records always receive the required input field below.
browserTransactionTransport: z.enum(["https", "loopback_http"]).optional(),
capacitySlot: z.number().int().min(0).max(OIDC_STATE_CAPACITY - 1).optional(), capacitySlot: z.number().int().min(0).max(OIDC_STATE_CAPACITY - 1).optional(),
createdAt: timestamp, createdAt: timestamp,
expiresAt: timestamp, expiresAt: timestamp,
@@ -254,6 +277,7 @@ const oidcStateInputSchema = z.strictObject({
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/), authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)), issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/), browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
browserTransactionTransport: z.enum(["https", "loopback_http"]),
}); });
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
@@ -265,6 +289,10 @@ function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode; return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode;
} }
function sameDirectoryScanIdentity(left: DirectoryScanIdentity, right: DirectoryScanIdentity): boolean {
return sameDirectoryIdentity(left, right) && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
}
function isNotFound(error: unknown): boolean { function isNotFound(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
} }
@@ -340,6 +368,15 @@ function directoryIdentity(path: string): DirectoryIdentity {
}; };
} }
function directoryScanIdentity(path: string): DirectoryScanIdentity {
const identity = directoryIdentity(path);
const info = lstatSync(path) as Stats;
if (!info.isDirectory() || info.isSymbolicLink() || info.dev !== identity.dev || info.ino !== identity.ino
|| info.uid !== identity.uid || (info.mode & 0o7777) !== identity.mode
|| !Number.isFinite(info.mtimeMs) || !Number.isFinite(info.ctimeMs)) throw invalid();
return { ...identity, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs };
}
function privateDirectory(path: string): void { function privateDirectory(path: string): void {
let created = false; let created = false;
try { try {
@@ -403,6 +440,71 @@ function boundedDirectoryNames(directory: string, maximumEntries: number): strin
} }
} }
/**
* Return the next lexical page without retaining a complete directory listing. Every direct
* child is structurally validated during the bounded scan, so an unsafe entry cannot hide after
* a full page of ordinary sessions.
*/
function boundedSessionDirectoryPage(
directory: string,
after: string | undefined,
maximumEntries: number,
): SessionDirectoryPage {
if (!Number.isInteger(maximumEntries) || maximumEntries < 1
|| (after !== undefined && !DIGEST_FILENAME_PATTERN.test(after))) throw invalid();
let handle: ReturnType<typeof opendirSync> | undefined;
try {
const before = directoryScanIdentity(directory);
handle = opendirSync(directory);
const seen = new Set<string>();
const selected: string[] = [];
let scanned = 0;
while (true) {
const entry = handle.readSync();
if (entry === null) {
handle.closeSync();
handle = undefined;
if (!sameDirectoryScanIdentity(before, directoryScanIdentity(directory))) throw invalid();
selected.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
const more = selected.length > maximumEntries;
return { entries: more ? selected.slice(0, maximumEntries) : selected, more };
}
scanned += 1;
if (scanned > MAX_SESSION_DIRECTORY_PAGE_SCAN_ENTRIES) throw invalid();
const filename = entry.name;
if (!DIGEST_FILENAME_PATTERN.test(filename) || seen.has(filename)) throw invalid();
seen.add(filename);
let info: Stats;
try {
info = lstatSync(filePath(directory, filename)) as Stats;
} catch (error) {
throw invalid();
}
fileIdentity(info);
if (after !== undefined && filename <= after) continue;
appendBoundedSessionFilename(selected, filename, maximumEntries + 1);
}
} catch {
throw invalid();
} finally {
if (handle !== undefined) {
try { handle.closeSync(); } catch { /* the operation is already fail-closed */ }
}
}
}
function appendBoundedSessionFilename(names: string[], filename: string, maximumEntries: number): void {
if (names.length < maximumEntries) {
names.push(filename);
return;
}
let greatest = 0;
for (let index = 1; index < names.length; index += 1) {
if (names[index] > names[greatest]) greatest = index;
}
if (filename < names[greatest]) names[greatest] = filename;
}
function openDirectory(directory: string): number | undefined { function openDirectory(directory: string): number | undefined {
if (process.platform === "win32") return undefined; if (process.platform === "win32") return undefined;
return openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) return openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)
@@ -835,12 +937,65 @@ export function createFileAuthSessionStore(
const windowsStorage = process.platform === "win32" const windowsStorage = process.platform === "win32"
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge() ? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
: undefined; : undefined;
let sessionPruneCursor: string | undefined;
function requiredWindowsStorage(): WindowsAuthStorageBridge { function requiredWindowsStorage(): WindowsAuthStorageBridge {
if (windowsStorage === undefined) throw invalid(); if (windowsStorage === undefined) throw invalid();
return windowsStorage; return windowsStorage;
} }
async function ordinarySessionPage(after: string | undefined): Promise<SessionDirectoryPage> {
if (process.platform !== "win32") {
return boundedSessionDirectoryPage(storageDirectories(root).sessions, after, MAX_SESSION_PRUNE_ENTRIES);
}
const page = await requiredWindowsStorage().listPage(root, "sessions", after, MAX_SESSION_PRUNE_ENTRIES);
if (!page || !Array.isArray(page.entries) || typeof page.more !== "boolean") throw invalid();
return { entries: page.entries.map((entry) => entry.name), more: page.more };
}
function nextSessionPruneCursor(page: SessionDirectoryPage, after: string | undefined): string | undefined {
if (!Array.isArray(page.entries) || typeof page.more !== "boolean"
|| page.entries.length > MAX_SESSION_PRUNE_ENTRIES) throw invalid();
const seen = new Set<string>();
let previous = after;
for (const filename of page.entries) {
if (!DIGEST_FILENAME_PATTERN.test(filename) || seen.has(filename)
|| (previous !== undefined && filename <= previous)) throw invalid();
seen.add(filename);
previous = filename;
}
if (!page.more) return undefined;
if (page.entries.length !== MAX_SESSION_PRUNE_ENTRIES || previous === undefined || previous === after) throw invalid();
return previous;
}
async function pruneOrdinarySessions(nowMs: number): Promise<number> {
const after = sessionPruneCursor;
const page = await ordinarySessionPage(after);
const next = nextSessionPruneCursor(page, after);
let removed = 0;
if (process.platform === "win32") {
const bridge = requiredWindowsStorage();
for (const filename of page.entries) {
const contents = await bridge.read(root, "sessions", filename);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs) && await bridge.remove(root, "sessions", filename)) removed += 1;
}
} else {
const directory = storageDirectories(root).sessions;
for (const filename of page.entries) {
await withLock(lockKey(root, "sessions", filename), async () => {
const trusted = readTrusted(directory, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (trusted && sessionExpired(trusted.value, nowMs)
&& removeTrusted(directory, filename, trusted.identity)) removed += 1;
});
}
}
sessionPruneCursor = next;
return removed;
}
async function oidcStorageEntries(): Promise<string[]> { async function oidcStorageEntries(): Promise<string[]> {
const entries = process.platform === "win32" const entries = process.platform === "win32"
? (await requiredWindowsStorage().list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES)).map((entry) => entry.name) ? (await requiredWindowsStorage().list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES)).map((entry) => entry.name)
@@ -1239,6 +1394,7 @@ export function createFileAuthSessionStore(
authConfigRevision: validated.authConfigRevision, authConfigRevision: validated.authConfigRevision,
issuer: validated.issuer, issuer: validated.issuer,
browserTransactionDigest: validated.browserTransactionDigest, browserTransactionDigest: validated.browserTransactionDigest,
browserTransactionTransport: validated.browserTransactionTransport,
capacitySlot, capacitySlot,
createdAt: isoAt(nowMs), createdAt: isoAt(nowMs),
expiresAt: isoAt(expiresMs), expiresAt: isoAt(expiresMs),
@@ -1284,32 +1440,10 @@ export function createFileAuthSessionStore(
async function prune(now = new Date()): Promise<number> { async function prune(now = new Date()): Promise<number> {
const nowMs = dateMilliseconds(now); const nowMs = dateMilliseconds(now);
if (process.platform === "win32") { // Cursor advancement is process-local, so concurrent timer/manual invocations must not
const bridge = requiredWindowsStorage(); // observe the same page and strand a later page forever.
const sessionEntries = await bridge.list(root, "sessions", MAX_SESSION_PRUNE_ENTRIES); return await withLock(lockKey(root, "sessions", "maintenance"), async () =>
if (sessionEntries.length > MAX_SESSION_PRUNE_ENTRIES) throw invalid(); (await pruneOrdinarySessions(nowMs)) + (await pruneOidcStates(nowMs)));
let removed = 0;
for (const entry of sessionEntries) {
if (!DIGEST_FILENAME_PATTERN.test(entry.name)) throw invalid();
const contents = await bridge.read(root, "sessions", entry.name);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs) && await bridge.remove(root, "sessions", entry.name)) removed += 1;
}
return removed + await pruneOidcStates(nowMs);
}
const directories = storageDirectories(root);
let removed = 0;
const sessionEntries = boundedDirectoryNames(directories.sessions, MAX_SESSION_PRUNE_ENTRIES);
for (const filename of sessionEntries) {
if (!DIGEST_FILENAME_PATTERN.test(filename)) throw invalid();
await withLock(lockKey(root, "sessions", filename), async () => {
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (trusted && sessionExpired(trusted.value, nowMs)
&& removeTrusted(directories.sessions, filename, trusted.identity)) removed += 1;
});
}
return removed + await pruneOidcStates(nowMs);
} }
return { return {
+5
View File
@@ -55,6 +55,9 @@ export interface AuthenticationConfigProvider {
current(): LoadedAuthConfig; current(): LoadedAuthConfig;
} }
/** The only browser transport modes accepted for an OIDC transaction cookie. */
export type OidcTransactionTransport = "https" | "loopback_http";
/** Durable, server-side representation of an opaque browser session. */ /** Durable, server-side representation of an opaque browser session. */
export interface AuthSessionRecord { export interface AuthSessionRecord {
version: 1; version: 1;
@@ -82,6 +85,8 @@ export interface OidcStateRecord {
authConfigRevision: string; authConfigRevision: string;
issuer: string; issuer: string;
browserTransactionDigest: string; browserTransactionDigest: string;
/** Optional only to safely consume and reject a short-lived pre-transport legacy state. */
browserTransactionTransport?: OidcTransactionTransport;
capacitySlot?: number; capacitySlot?: number;
createdAt: string; createdAt: string;
expiresAt: string; expiresAt: string;
+63 -7
View File
@@ -24,6 +24,11 @@ export interface WindowsAuthStorageEntry {
modifiedUnixMs: number; modifiedUnixMs: number;
} }
export interface WindowsAuthStoragePage {
entries: WindowsAuthStorageEntry[];
more: boolean;
}
/** Internal adapter boundary for the file-session store's native Windows path. */ /** Internal adapter boundary for the file-session store's native Windows path. */
export interface WindowsAuthStorageBridge { export interface WindowsAuthStorageBridge {
create(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise<boolean>; create(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise<boolean>;
@@ -35,6 +40,12 @@ export interface WindowsAuthStorageBridge {
directory: WindowsAuthStorageDirectory, directory: WindowsAuthStorageDirectory,
maximumEntries?: number, maximumEntries?: number,
): Promise<WindowsAuthStorageEntry[]>; ): Promise<WindowsAuthStorageEntry[]>;
listPage(
root: string,
directory: "sessions",
afterName: string | undefined,
maximumEntries: number,
): Promise<WindowsAuthStoragePage>;
claimConsume(root: string, filename: string): Promise<Buffer | undefined>; claimConsume(root: string, filename: string): Promise<Buffer | undefined>;
readClaim(root: string, filename: string): Promise<Buffer | undefined>; readClaim(root: string, filename: string): Promise<Buffer | undefined>;
removeClaim(root: string, filename: string): Promise<boolean>; removeClaim(root: string, filename: string): Promise<boolean>;
@@ -92,6 +103,7 @@ const responseSchema = z.strictObject({
name: z.string().max(128), name: z.string().max(128),
modifiedUnixMs: z.number().int().safe().nonnegative(), modifiedUnixMs: z.number().int().safe().nonnegative(),
})).max(MAX_ENTRIES).optional(), })).max(MAX_ENTRIES).optional(),
more: z.boolean().optional(),
}); });
type BridgeResponse = z.infer<typeof responseSchema>; type BridgeResponse = z.infer<typeof responseSchema>;
@@ -104,6 +116,8 @@ interface BridgeRequest {
filename?: string; filename?: string;
contentBase64?: string; contentBase64?: string;
maximumEntries?: number; maximumEntries?: number;
afterName?: string;
continuation?: true;
} }
function directoryMaximum(directory: WindowsAuthStorageDirectory): number { function directoryMaximum(directory: WindowsAuthStorageDirectory): number {
@@ -159,8 +173,15 @@ function encodedRequest(request: BridgeRequest): Buffer {
if (request.filename !== undefined || request.contentBase64 !== undefined) throw invalid(); if (request.filename !== undefined || request.contentBase64 !== undefined) throw invalid();
if (request.maximumEntries !== undefined && (!Number.isInteger(request.maximumEntries) if (request.maximumEntries !== undefined && (!Number.isInteger(request.maximumEntries)
|| request.maximumEntries < 1 || request.maximumEntries > MAX_ENTRIES)) throw invalid(); || request.maximumEntries < 1 || request.maximumEntries > MAX_ENTRIES)) throw invalid();
if (request.continuation === true) {
if (request.directory !== "sessions" || (request.afterName !== undefined && !DIGEST_FILENAME.test(request.afterName))) {
throw invalid();
}
} else if (request.afterName !== undefined || request.continuation !== undefined) {
throw invalid();
}
} else { } else {
if (request.maximumEntries !== undefined) throw invalid(); if (request.maximumEntries !== undefined || request.afterName !== undefined || request.continuation !== undefined) throw invalid();
if (request.filename === undefined) throw invalid(); if (request.filename === undefined) throw invalid();
const allowClaim = request.operation === "remove" && request.directory === "oidc"; const allowClaim = request.operation === "remove" && request.directory === "oidc";
const allowOidcSlot = request.directory === "oidc" const allowOidcSlot = request.directory === "oidc"
@@ -311,6 +332,22 @@ function contentFrom(response: BridgeResponse, maximum: number): Buffer | undefi
return canonicalBase64(response.contentBase64, maximum); return canonicalBase64(response.contentBase64, maximum);
} }
function listedEntries(
response: BridgeResponse,
directory: WindowsAuthStorageDirectory,
maximumEntries: number,
): WindowsAuthStorageEntry[] {
if (response.entries === undefined || response.entries.length > maximumEntries) throw invalid();
const names = new Set<string>();
for (const entry of response.entries) {
if (!DIGEST_FILENAME.test(entry.name) && !(directory === "oidc"
&& (CLAIM_FILENAME.test(entry.name) || OIDC_SLOT_FILENAME.test(entry.name)))) throw invalid();
if (names.has(entry.name)) throw invalid();
names.add(entry.name);
}
return response.entries.map((entry) => ({ name: entry.name, modifiedUnixMs: entry.modifiedUnixMs }));
}
export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge { export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
const executable = safeThtExecutable(options.thtExecutable); const executable = safeThtExecutable(options.thtExecutable);
const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht( const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(
@@ -368,13 +405,32 @@ export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridge
directory, directory,
maximumEntries, maximumEntries,
}); });
if (response.entries === undefined) throw invalid(); if (response.more !== undefined) throw invalid();
if (response.entries.length > maximumEntries) throw invalid(); return listedEntries(response, directory, maximumEntries);
for (const entry of response.entries) { },
if (!DIGEST_FILENAME.test(entry.name) && !(directory === "oidc" async listPage(root, directory, afterName, maximumEntries) {
&& (CLAIM_FILENAME.test(entry.name) || OIDC_SLOT_FILENAME.test(entry.name)))) throw invalid(); if (directory !== "sessions" || !Number.isInteger(maximumEntries)
|| maximumEntries < 1 || maximumEntries > MAX_ENTRIES
|| (afterName !== undefined && !DIGEST_FILENAME.test(afterName))) throw invalid();
const response = await request({
version: PROTOCOL_VERSION,
operation: "list",
root,
directory,
maximumEntries,
continuation: true,
...(afterName === undefined ? {} : { afterName }),
});
if (response.more === undefined) throw invalid();
const entries = listedEntries(response, directory, maximumEntries);
let previous = afterName;
for (const entry of entries) {
if (previous !== undefined && entry.name <= previous) throw invalid();
previous = entry.name;
} }
return response.entries.map((entry) => ({ name: entry.name, modifiedUnixMs: entry.modifiedUnixMs })); if (response.more && entries.length !== maximumEntries) throw invalid();
if (response.more && (previous === undefined || previous === afterName)) throw invalid();
return { entries, more: response.more };
}, },
async claimConsume(root, filename) { async claimConsume(root, filename) {
return contentFrom(await request(recordRequest("claim-consume", root, "oidc", filename)), MAX_OIDC_BYTES); return contentFrom(await request(recordRequest("claim-consume", root, "oidc", filename)), MAX_OIDC_BYTES);
+156 -16
View File
@@ -12,6 +12,7 @@ const state = "s".repeat(43);
const nonce = "n".repeat(43); const nonce = "n".repeat(43);
const verifier = "v".repeat(43); const verifier = "v".repeat(43);
const transactionCookieName = "__Host-thothii_oidc_tx"; const transactionCookieName = "__Host-thothii_oidc_tx";
const loopbackTransactionCookieName = "thothii_oidc_tx";
const createdApps: Array<ReturnType<typeof Fastify>> = []; const createdApps: Array<ReturnType<typeof Fastify>> = [];
function setCookieHeaders(response: { headers: Record<string, string | string[] | undefined> }): string[] { function setCookieHeaders(response: { headers: Record<string, string | string[] | undefined> }): string[] {
@@ -19,15 +20,42 @@ function setCookieHeaders(response: { headers: Record<string, string | string[]
return header === undefined ? [] : Array.isArray(header) ? header : [header]; return header === undefined ? [] : Array.isArray(header) ? header : [header];
} }
function transactionCookie(response: { headers: Record<string, string | string[] | undefined> }): string | undefined { function transactionCookie(
response: { headers: Record<string, string | string[] | undefined> },
name = transactionCookieName,
): string | undefined {
return setCookieHeaders(response) return setCookieHeaders(response)
.find((header) => header.startsWith(`${transactionCookieName}=`)) .find((header) => header.startsWith(`${name}=`) && !header.includes("Max-Age=0"))
?.split(";", 1)[0]; ?.split(";", 1)[0];
} }
function expectTransactionCleared(response: { headers: Record<string, string | string[] | undefined> }): void { function expectTransactionCleared(
expect(setCookieHeaders(response).some((header) => response: { headers: Record<string, string | string[] | undefined> },
header.startsWith(`${transactionCookieName}=`) && header.includes("Max-Age=0"))).toBe(true); name = transactionCookieName,
secure = true,
): void {
const header = setCookieHeaders(response).find((candidate) =>
candidate.startsWith(`${name}=`) && candidate.includes("Max-Age=0"));
expect(header).toBeDefined();
expect(header).toContain("HttpOnly");
expect(header).toContain("SameSite=Lax");
expect(header).toContain("Path=/");
expect(header).not.toContain("Domain=");
if (secure) expect(header).toContain("Secure");
else expect(header).not.toContain("Secure");
}
function expectBothTransactionVariantsCleared(
response: { headers: Record<string, string | string[] | undefined> },
activeName: string,
activeSecure: boolean,
): void {
expectTransactionCleared(response, activeName, activeSecure);
expectTransactionCleared(
response,
activeName === transactionCookieName ? loopbackTransactionCookieName : transactionCookieName,
activeName !== transactionCookieName,
);
} }
function config(overrides: Partial<LoadedAuthConfig["value"]> = {}): LoadedAuthConfig { function config(overrides: Partial<LoadedAuthConfig["value"]> = {}): LoadedAuthConfig {
@@ -58,6 +86,7 @@ function stateRecord(extra: Partial<OidcStateRecord> = {}): OidcStateRecord {
version: 1, nonce, codeVerifier: verifier, returnTo: "/", version: 1, nonce, codeVerifier: verifier, returnTo: "/",
authConfigRevision: revision, issuer, authConfigRevision: revision, issuer,
browserTransactionDigest: "b".repeat(64), browserTransactionDigest: "b".repeat(64),
browserTransactionTransport: "https",
createdAt: "2030-01-01T00:00:00.000Z", expiresAt: "2030-01-01T00:10:00.000Z", createdAt: "2030-01-01T00:00:00.000Z", expiresAt: "2030-01-01T00:10:00.000Z",
...extra, ...extra,
} as OidcStateRecord; } as OidcStateRecord;
@@ -106,6 +135,8 @@ function fixture(options: {
}); });
(record as OidcStateRecord & { browserTransactionDigest: string }).browserTransactionDigest = (record as OidcStateRecord & { browserTransactionDigest: string }).browserTransactionDigest =
input.browserTransactionDigest as string; input.browserTransactionDigest as string;
(record as OidcStateRecord & { browserTransactionTransport?: string }).browserTransactionTransport =
input.browserTransactionTransport as string | undefined ?? "https";
if (options.stateReturnTo) (record as { returnTo: string }).returnTo = options.stateReturnTo; if (options.stateReturnTo) (record as { returnTo: string }).returnTo = options.stateReturnTo;
storedState = record; storedState = record;
return { state, record: storedState }; return { state, record: storedState };
@@ -149,7 +180,10 @@ afterEach(async () => {
async function beginOidcLogin(subject: ReturnType<typeof fixture>) { async function beginOidcLogin(subject: ReturnType<typeof fixture>) {
const response = await subject.app.inject({ method: "GET", url: "/auth/oidc/login" }); const response = await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
return { response, cookie: transactionCookie(response) }; return {
response,
cookie: transactionCookie(response) ?? transactionCookie(response, loopbackTransactionCookieName),
};
} }
async function finishOidcLogin( async function finishOidcLogin(
@@ -208,12 +242,16 @@ test("creates digest-only bound state, maps exact groups, creates a cookie sessi
expect(subject.stateInputs[0]).toMatchObject({ expect(subject.stateInputs[0]).toMatchObject({
returnTo: "/", authConfigRevision: revision, issuer, returnTo: "/", authConfigRevision: revision, issuer,
browserTransactionDigest: expect.stringMatching(/^[a-f0-9]{64}$/), browserTransactionDigest: expect.stringMatching(/^[a-f0-9]{64}$/),
browserTransactionTransport: "https",
}); });
expect(cookie).toMatch(new RegExp(`^${transactionCookieName}=[A-Za-z0-9_-]{43}$`)); expect(cookie).toMatch(new RegExp(`^${transactionCookieName}=[A-Za-z0-9_-]{43}$`));
expect(setCookieHeaders(start).join("\n")).toContain("HttpOnly"); const issued = setCookieHeaders(start)
expect(setCookieHeaders(start).join("\n")).toContain("SameSite=Lax"); .find((header) => header.startsWith(`${transactionCookieName}=`) && !header.includes("Max-Age=0"));
expect(setCookieHeaders(start).join("\n")).toContain("Secure"); expect(issued).toContain("HttpOnly");
expect(setCookieHeaders(start).join("\n")).toContain("Path=/"); expect(issued).toContain("SameSite=Lax");
expect(issued).toContain("Secure");
expect(issued).toContain("Path=/");
expect(issued).not.toContain("Domain=");
expect(JSON.stringify(subject.stateInputs)).not.toContain(cookie?.split("=", 2)[1] ?? "missing-cookie"); expect(JSON.stringify(subject.stateInputs)).not.toContain(cookie?.split("=", 2)[1] ?? "missing-cookie");
const callback = await subject.app.inject({ const callback = await subject.app.inject({
@@ -226,7 +264,7 @@ test("creates digest-only bound state, maps exact groups, creates a cookie sessi
expect(setCookieHeaders(callback).join("\n")).toContain("HttpOnly"); expect(setCookieHeaders(callback).join("\n")).toContain("HttpOnly");
expect(setCookieHeaders(callback).join("\n")).toContain("SameSite=Lax"); expect(setCookieHeaders(callback).join("\n")).toContain("SameSite=Lax");
expect(setCookieHeaders(callback).join("\n")).toContain("Secure"); expect(setCookieHeaders(callback).join("\n")).toContain("Secure");
expectTransactionCleared(callback); expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
expect(subject.callbacks[0]?.href).toBe(`https://thothii.example.test/api/auth/oidc/callback?code=good&state=${state}`); expect(subject.callbacks[0]?.href).toBe(`https://thothii.example.test/api/auth/oidc/callback?code=good&state=${state}`);
expect(subject.creates).toHaveLength(1); expect(subject.creates).toHaveLength(1);
expect(subject.creates[0]).toMatchObject({ expect(subject.creates[0]).toMatchObject({
@@ -242,12 +280,103 @@ test("creates digest-only bound state, maps exact groups, creates a cookie sessi
expect(JSON.stringify(subject.creates)).not.toContain("refresh-token-must-not-leak"); expect(JSON.stringify(subject.creates)).not.toContain("refresh-token-must-not-leak");
}); });
test.each([
"http://127.42.0.1:8787",
"http://[::1]:8787",
])("uses the non-prefixed, non-Secure transaction cookie for literal loopback OIDC %s", async (publicUrl) => {
const subject = fixture({ loaded: config({ publicUrl }) });
const { response: start, cookie } = await beginOidcLogin(subject);
expect(start.statusCode).toBe(302);
expect(subject.stateInputs[0]).toMatchObject({ browserTransactionTransport: "loopback_http" });
expect(cookie).toMatch(new RegExp(`^${loopbackTransactionCookieName}=[A-Za-z0-9_-]{43}$`));
const issued = setCookieHeaders(start).find((header) => header.startsWith(`${loopbackTransactionCookieName}=`));
expect(issued).toContain("HttpOnly");
expect(issued).toContain("SameSite=Lax");
expect(issued).toContain("Path=/");
expect(issued).not.toContain("Secure");
expect(issued).not.toContain("Domain=");
const callback = await finishOidcLogin(subject, cookie);
expect(callback.statusCode).toBe(302);
expect(subject.callbacks[0]?.href).toBe(`${publicUrl}/api/auth/oidc/callback?code=good&state=${state}`);
expectBothTransactionVariantsCleared(callback, loopbackTransactionCookieName, false);
});
test("uses the consumed state transport to reject cross-mode and shadowed transaction cookies", async () => {
const subject = fixture({ loaded: config({ publicUrl: "http://127.0.0.1:8787" }) });
const { cookie } = await beginOidcLogin(subject);
expect(cookie).toBeDefined();
const failed = await finishOidcLogin(subject, [
cookie,
`${transactionCookieName}=${"x".repeat(43)}`,
].join("; "));
expect(failed.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
});
test("rejects a transaction presented only under the wrong transport cookie name", async () => {
const subject = fixture();
const { cookie } = await beginOidcLogin(subject);
const wrongTransportCookie = cookie?.replace(transactionCookieName, loopbackTransactionCookieName);
const failed = await finishOidcLogin(subject, wrongTransportCookie);
expect(failed.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
});
test("rejects duplicate same-mode transaction cookies instead of trusting a parser-selected value", async () => {
const subject = fixture();
const { cookie } = await beginOidcLogin(subject);
expect(cookie).toBeDefined();
const failed = await finishOidcLogin(subject, [
cookie,
`${transactionCookieName}=${"x".repeat(43)}`,
].join("; "));
expect(failed.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
expect(subject.stateWasConsumed()).toBe(true);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
});
test("pins the callback transaction transport to its captured configuration snapshot", async () => {
const subject = fixture({ loaded: config({ publicUrl: "http://127.42.0.1:8787" }) });
const { cookie } = await beginOidcLogin(subject);
subject.setConfig(config({ publicUrl: "https://thothii.example.test" }));
const failed = await finishOidcLogin(subject, cookie);
expect(failed.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]);
expect(subject.stateWasConsumed()).toBe(true);
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
});
test("clears the state-pinned loopback transaction variant after a terminal callback failure", async () => {
const subject = fixture({
loaded: config({ publicUrl: "http://127.0.0.1:8787" }),
callbackFailure: true,
});
const { cookie } = await beginOidcLogin(subject);
const failed = await finishOidcLogin(subject, cookie);
expect(failed.statusCode).toBe(401);
expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false);
});
test("consumes state on callback failure and refuses replay", async () => { test("consumes state on callback failure and refuses replay", async () => {
const subject = fixture({ callbackFailure: true }); const subject = fixture({ callbackFailure: true });
const { cookie } = await beginOidcLogin(subject); const { cookie } = await beginOidcLogin(subject);
const failed = await finishOidcLogin(subject, cookie); const failed = await finishOidcLogin(subject, cookie);
expect(failed.statusCode).toBe(401); expect(failed.statusCode).toBe(401);
expectTransactionCleared(failed); expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
const replay = await finishOidcLogin(subject, cookie); const replay = await finishOidcLogin(subject, cookie);
expect(replay.statusCode).toBe(401); expect(replay.statusCode).toBe(401);
expect(subject.callbacks).toHaveLength(1); expect(subject.callbacks).toHaveLength(1);
@@ -260,6 +389,7 @@ test("consumes state when the protocol becomes unavailable before callback", asy
const failed = await finishOidcLogin(subject, cookie); const failed = await finishOidcLogin(subject, cookie);
expect(failed.statusCode).toBe(401); expect(failed.statusCode).toBe(401);
expect(subject.stateWasConsumed()).toBe(true); expect(subject.stateWasConsumed()).toBe(true);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
}); });
test("rejects a consumed state with a non-root return target", async () => { test("rejects a consumed state with a non-root return target", async () => {
@@ -269,6 +399,7 @@ test("rejects a consumed state with a non-root return target", async () => {
expect(callback.statusCode).toBe(401); expect(callback.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]); expect(subject.callbacks).toEqual([]);
expect(subject.creates).toEqual([]); expect(subject.creates).toEqual([]);
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
}); });
test("rejects an OIDC state when its configuration revision changes before callback", async () => { test("rejects an OIDC state when its configuration revision changes before callback", async () => {
@@ -278,6 +409,7 @@ test("rejects an OIDC state when its configuration revision changes before callb
const callback = await finishOidcLogin(subject, cookie); const callback = await finishOidcLogin(subject, cookie);
expect(callback.statusCode).toBe(401); expect(callback.statusCode).toBe(401);
expect(subject.callbacks).toEqual([]); expect(subject.callbacks).toEqual([]);
expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
}); });
test("rejects an OIDC state when its issuer changes before callback", async () => { test("rejects an OIDC state when its issuer changes before callback", async () => {
@@ -310,7 +442,7 @@ test("rejects a callback from a different browser and clears the transaction coo
const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`); const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`);
expect(failed.statusCode).toBe(401); expect(failed.statusCode).toBe(401);
expectTransactionCleared(failed); expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
expect(subject.stateWasConsumed()).toBe(true); expect(subject.stateWasConsumed()).toBe(true);
expect(subject.creates).toEqual([]); expect(subject.creates).toEqual([]);
@@ -328,7 +460,7 @@ test.each([
const { cookie } = await beginOidcLogin(subject); const { cookie } = await beginOidcLogin(subject);
const failed = await finishOidcLogin(subject, cookie, query); const failed = await finishOidcLogin(subject, cookie, query);
expect(failed.statusCode).toBe(401); expect(failed.statusCode).toBe(401);
expectTransactionCleared(failed); expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
expect(subject.stateWasConsumed()).toBe(true); expect(subject.stateWasConsumed()).toBe(true);
expect(subject.callbacks).toEqual([]); expect(subject.callbacks).toEqual([]);
@@ -345,7 +477,7 @@ test("boundedly burns a canonical state from an oversized callback URL", async (
const failed = await finishOidcLogin(subject, cookie, oversized); const failed = await finishOidcLogin(subject, cookie, oversized);
expect(failed.statusCode).toBe(401); expect(failed.statusCode).toBe(401);
expectTransactionCleared(failed); expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
expect(subject.stateWasConsumed()).toBe(true); expect(subject.stateWasConsumed()).toBe(true);
expect(subject.callbacks).toEqual([]); expect(subject.callbacks).toEqual([]);
expect((await finishOidcLogin(subject, cookie)).statusCode).toBe(401); expect((await finishOidcLogin(subject, cookie)).statusCode).toBe(401);
@@ -358,11 +490,19 @@ test("rejects an empty direct groups claim without creating a session cookie", a
const { cookie } = await beginOidcLogin(subject); const { cookie } = await beginOidcLogin(subject);
const callback = await finishOidcLogin(subject, cookie); const callback = await finishOidcLogin(subject, cookie);
expect(callback.statusCode).toBe(401); expect(callback.statusCode).toBe(401);
expectTransactionCleared(callback); expectBothTransactionVariantsCleared(callback, transactionCookieName, true);
expect(subject.creates).toEqual([]); expect(subject.creates).toEqual([]);
expect(setCookieHeaders(callback).join("\n")).not.toContain("opaque-session-token"); expect(setCookieHeaders(callback).join("\n")).not.toContain("opaque-session-token");
}); });
test("clears both transaction variants when the callback state cannot be consumed", async () => {
const subject = fixture();
const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`);
expect(failed.statusCode).toBe(401);
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
});
test("uses one captured instant for token TTL derivation and session creation", async () => { test("uses one captured instant for token TTL derivation and session creation", async () => {
const tokenExpiresAt = new Date(Date.now() + 120_000); const tokenExpiresAt = new Date(Date.now() + 120_000);
const subject = fixture({ identity: { const subject = fixture({ identity: {
+220 -6
View File
@@ -13,6 +13,7 @@ import {
renameSync, renameSync,
rmSync, rmSync,
symlinkSync, symlinkSync,
utimesSync,
unlinkSync, unlinkSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
@@ -84,9 +85,14 @@ function oidcInput(nonce: string, codeVerifier: string) {
authConfigRevision: revision, authConfigRevision: revision,
issuer: "https://issuer.example.test", issuer: "https://issuer.example.test",
browserTransactionDigest: "b".repeat(64), browserTransactionDigest: "b".repeat(64),
browserTransactionTransport: "https" as const,
}; };
} }
function sessionFilename(index: number): string {
return `${index.toString(16).padStart(64, "0")}.json`;
}
afterEach(() => { afterEach(() => {
fsHooks.afterRead = undefined; fsHooks.afterRead = undefined;
fsHooks.afterWrite = undefined; fsHooks.afterWrite = undefined;
@@ -334,6 +340,104 @@ describe("file-backed auth session store", () => {
expect(existsSync(digestPath(storageRoot, "oidc", oidc.state))).toBe(false); expect(existsSync(digestPath(storageRoot, "oidc", oidc.state))).toBe(false);
}); });
test.skipIf(process.platform === "win32")("allows startup maintenance past 512 live records and keeps OIDC available", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const liveSeed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
const liveContents = readFileSync(digestPath(storageRoot, "sessions", liveSeed.token));
unlinkSync(digestPath(storageRoot, "sessions", liveSeed.token));
const expiredSeed = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
const expiredContents = readFileSync(digestPath(storageRoot, "sessions", expiredSeed.token));
unlinkSync(digestPath(storageRoot, "sessions", expiredSeed.token));
for (let index = 0; index < 512; index += 1) {
writeFileSync(join(storageRoot, "sessions", sessionFilename(index)), liveContents, { mode: 0o600 });
}
const expiredName = sessionFilename(512);
writeFileSync(join(storageRoot, "sessions", expiredName), expiredContents, { mode: 0o600 });
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(0);
expect(existsSync(join(storageRoot, "sessions", expiredName))).toBe(true);
expect(existsSync(join(storageRoot, "sessions", sessionFilename(0)))).toBe(true);
await expect(store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base))
.resolves.toMatchObject({ record: { nonce: "n".repeat(43) } });
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(1);
expect(existsSync(join(storageRoot, "sessions", expiredName))).toBe(false);
expect(existsSync(join(storageRoot, "sessions", sessionFilename(0)))).toBe(true);
});
test.skipIf(process.platform === "win32")("serializes concurrent continuation passes without stranding a later page", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const liveSeed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
const liveContents = readFileSync(digestPath(storageRoot, "sessions", liveSeed.token));
unlinkSync(digestPath(storageRoot, "sessions", liveSeed.token));
const expiredSeed = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
const expiredContents = readFileSync(digestPath(storageRoot, "sessions", expiredSeed.token));
unlinkSync(digestPath(storageRoot, "sessions", expiredSeed.token));
for (let index = 0; index < 512; index += 1) {
writeFileSync(join(storageRoot, "sessions", sessionFilename(index)), liveContents, { mode: 0o600 });
}
const expiredName = sessionFilename(512);
writeFileSync(join(storageRoot, "sessions", expiredName), expiredContents, { mode: 0o600 });
await expect(Promise.all([
store.prune(new Date(base.getTime() + 2 * 60_000)),
store.prune(new Date(base.getTime() + 2 * 60_000)),
])).resolves.toEqual([0, 1]);
expect(existsSync(join(storageRoot, "sessions", expiredName))).toBe(false);
expect(existsSync(join(storageRoot, "sessions", sessionFilename(0)))).toBe(true);
});
test.skipIf(process.platform === "win32")("fails closed on an unsafe ordinary record beyond the first continuation page", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const seed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
const contents = readFileSync(digestPath(storageRoot, "sessions", seed.token));
unlinkSync(digestPath(storageRoot, "sessions", seed.token));
for (let index = 0; index < 512; index += 1) {
writeFileSync(join(storageRoot, "sessions", sessionFilename(index)), contents, { mode: 0o600 });
}
symlinkSync(join(storageRoot, "missing-target"), join(storageRoot, "sessions", sessionFilename(512)));
await expect(store.prune(new Date(base.getTime() + 2 * 60_000)))
.rejects.toThrow("auth_session_store_invalid");
});
test.skipIf(process.platform === "win32")("fails closed if the ordinary-session directory changes during a page scan", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const seed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
const contents = readFileSync(digestPath(storageRoot, "sessions", seed.token));
unlinkSync(digestPath(storageRoot, "sessions", seed.token));
const filename = sessionFilename(0);
const sessionsDirectory = join(storageRoot, "sessions");
writeFileSync(join(sessionsDirectory, filename), contents, { mode: 0o600 });
fsHooks.beforeLstat = (path) => {
if (path !== join(sessionsDirectory, filename)) return false;
const changed = new Date(base.getTime() + 60 * 60_000);
utimesSync(sessionsDirectory, changed, changed);
return true;
};
await expect(store.prune(new Date(base.getTime() + 2 * 60_000)))
.rejects.toThrow("auth_session_store_invalid");
});
test("persists the OIDC transaction transport as consumed state metadata", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const created = await store.createOidcState({
...oidcInput("n".repeat(43), "v".repeat(43)),
browserTransactionTransport: "loopback_http",
});
await expect(store.consumeOidcState(created.state)).resolves.toMatchObject({
browserTransactionTransport: "loopback_http",
});
});
test("creates bounded OIDC state records that expire and are single-use", async () => { test("creates bounded OIDC state records that expire and are single-use", async () => {
const storageRoot = root(); const storageRoot = root();
const store = validStore(storageRoot); const store = validStore(storageRoot);
@@ -347,6 +451,7 @@ describe("file-backed auth session store", () => {
nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/", nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/",
authConfigRevision: revision, issuer: "https://issuer.example.test", authConfigRevision: revision, issuer: "https://issuer.example.test",
browserTransactionDigest: "b".repeat(64), browserTransactionDigest: "b".repeat(64),
browserTransactionTransport: "https",
}); });
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined(); await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
@@ -740,6 +845,19 @@ describe("file-backed auth session store", () => {
.filter((entry) => entry.startsWith(`${directory}/`)) .filter((entry) => entry.startsWith(`${directory}/`))
.map((entry) => ({ name: entry.slice(directory.length + 1), modifiedUnixMs: base.getTime() })); .map((entry) => ({ name: entry.slice(directory.length + 1), modifiedUnixMs: base.getTime() }));
}, },
listPage: async (_root: string, directory: string, after: string | undefined, maximumEntries: number) => {
calls.push("list");
if (directory !== "sessions") throw new Error("only ordinary sessions use continuation pages");
const names = [...records.keys()]
.filter((entry) => entry.startsWith("sessions/"))
.map((entry) => entry.slice("sessions/".length))
.filter((name) => after === undefined || name > after)
.sort();
return {
entries: names.slice(0, maximumEntries).map((name) => ({ name, modifiedUnixMs: base.getTime() })),
more: names.length > maximumEntries,
};
},
claimConsume: async (_root: string, filename: string) => { claimConsume: async (_root: string, filename: string) => {
calls.push("claim-consume"); calls.push("claim-consume");
const entry = key("oidc", filename); const entry = key("oidc", filename);
@@ -778,6 +896,75 @@ describe("file-backed auth session store", () => {
} }
}); });
test("continues Windows ordinary-session maintenance with a strict 512-record page cursor", async () => {
const records = new Map<string, Buffer>();
const pages: Array<{ after: string | undefined; maximumEntries: number }> = [];
const key = (directory: string, filename: string) => `${directory}/${filename}`;
const bridge = {
create: async (_root: string, directory: string, filename: string, contents: Buffer) => {
const entry = key(directory, filename);
if (records.has(entry)) return false;
records.set(entry, Buffer.from(contents));
return true;
},
read: async (_root: string, directory: string, filename: string) => {
const value = records.get(key(directory, filename));
return value === undefined ? undefined : Buffer.from(value);
},
remove: async (_root: string, directory: string, filename: string) => records.delete(key(directory, filename)),
list: async (_root: string, directory: string) => {
if (directory === "sessions") throw new Error("ordinary maintenance must use a continuation page");
return [];
},
listPage: async (_root: string, directory: string, after: string | undefined, maximumEntries: number) => {
if (directory !== "sessions") throw new Error("only ordinary sessions use continuation pages");
pages.push({ after, maximumEntries });
const names = [...records.keys()]
.filter((entry) => entry.startsWith("sessions/"))
.map((entry) => entry.slice("sessions/".length))
.filter((name) => after === undefined || name > after)
.sort();
return {
entries: names.slice(0, maximumEntries).map((name) => ({ name, modifiedUnixMs: base.getTime() })),
more: names.length > maximumEntries,
};
},
};
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatform) throw new Error("platform descriptor unavailable");
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
try {
const store = createFileAuthSessionStore("C:\\ProgramData\\ThothII\\auth", {
currentAuthConfigRevision: () => revision,
findLocalUser: async () => validLocalUser,
}, { windowsStorageBridge: bridge as never });
await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
const liveContents = [...records.values()][0];
records.clear();
const expiredSeed = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
const expiredContents = [...records.values()][0];
records.clear();
if (!liveContents || !expiredContents) throw new Error("seed records missing");
for (let index = 0; index < 512; index += 1) {
records.set(key("sessions", sessionFilename(index)), Buffer.from(liveContents));
}
const expiredName = sessionFilename(512);
records.set(key("sessions", expiredName), Buffer.from(expiredContents));
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(0);
expect(records.has(key("sessions", expiredName))).toBe(true);
await expect(store.prune(new Date(base.getTime() + 2 * 60_000))).resolves.toBe(1);
expect(records.has(key("sessions", expiredName))).toBe(false);
expect(records.has(key("sessions", sessionFilename(0)))).toBe(true);
expect(pages).toEqual([
{ after: undefined, maximumEntries: 512 },
{ after: sessionFilename(511), maximumEntries: 512 },
]);
} finally {
Object.defineProperty(process, "platform", originalPlatform);
}
});
test("starts OIDC on Windows without listing more than 256 ordinary session records", async () => { test("starts OIDC on Windows without listing more than 256 ordinary session records", async () => {
const records = new Map<string, Buffer>(); const records = new Map<string, Buffer>();
for (let index = 0; index < 300; index += 1) { for (let index = 0; index < 300; index += 1) {
@@ -863,6 +1050,8 @@ describe("file-backed auth session store", () => {
directory: "sessions" | "oidc"; directory: "sessions" | "oidc";
filename?: string; filename?: string;
contentBase64?: string; contentBase64?: string;
continuation?: boolean;
afterName?: string;
}; };
const entry = request.filename === undefined ? undefined : key(request.directory, request.filename); const entry = request.filename === undefined ? undefined : key(request.directory, request.filename);
switch (request.operation) { switch (request.operation) {
@@ -877,6 +1066,17 @@ describe("file-backed auth session store", () => {
case "remove": case "remove":
return response({ removed: entry !== undefined && records.delete(entry) }); return response({ removed: entry !== undefined && records.delete(entry) });
case "list": case "list":
if (request.continuation === true) {
const names = [...records.keys()]
.filter((value) => value.startsWith(`${request.directory}/`))
.map((value) => value.slice(request.directory.length + 1))
.filter((name) => request.afterName === undefined || name > request.afterName)
.sort();
return response({
entries: names.slice(0, 512).map((name) => ({ name, modifiedUnixMs: base.getTime() })),
more: names.length > 512,
});
}
return response({ return response({
// Raw lower-camel entry objects, exactly as authstorage's Go response emits them. // Raw lower-camel entry objects, exactly as authstorage's Go response emits them.
entries: [...records.keys()] entries: [...records.keys()]
@@ -909,11 +1109,19 @@ describe("file-backed auth session store", () => {
test("prunes empty Windows directories through the required Go entries array", async () => { test("prunes empty Windows directories through the required Go entries array", async () => {
const bridge = createWindowsAuthStorageBridge({ const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe", thtExecutable: "C:\\tht.exe",
invoke: async () => ({ invoke: async ({ input }) => {
code: 0, const request = JSON.parse(input.toString("utf8")) as { continuation?: boolean };
stdout: Buffer.from('{"version":1,"ok":true,"entries":[]}\n'), return {
stderr: Buffer.alloc(0), code: 0,
}), stdout: Buffer.from(`${JSON.stringify({
version: 1,
ok: true,
entries: [],
...(request.continuation === true ? { more: false } : {}),
})}\n`),
stderr: Buffer.alloc(0),
};
},
}); });
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatform) throw new Error("platform descriptor unavailable"); if (!originalPlatform) throw new Error("platform descriptor unavailable");
@@ -936,7 +1144,12 @@ describe("file-backed auth session store", () => {
const bridge = createWindowsAuthStorageBridge({ const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe", thtExecutable: "C:\\tht.exe",
invoke: async ({ input }) => { invoke: async ({ input }) => {
const request = JSON.parse(input.toString("utf8")) as { operation: string; directory: string; filename?: string }; const request = JSON.parse(input.toString("utf8")) as {
operation: string;
directory: string;
filename?: string;
continuation?: boolean;
};
const response = (value: Record<string, unknown>) => ({ const response = (value: Record<string, unknown>) => ({
code: 0, code: 0,
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...value })}\n`), stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...value })}\n`),
@@ -947,6 +1160,7 @@ describe("file-backed auth session store", () => {
entries: request.directory === "oidc" entries: request.directory === "oidc"
? [{ name: claim, modifiedUnixMs: base.getTime() }] ? [{ name: claim, modifiedUnixMs: base.getTime() }]
: [], : [],
...(request.continuation === true ? { more: false } : {}),
}); });
} }
if (request.operation === "remove" && request.directory === "oidc" && request.filename === claim) { if (request.operation === "remove" && request.directory === "oidc" && request.filename === claim) {
+1
View File
@@ -22,6 +22,7 @@ process.once("message", async (message) => {
authConfigRevision: "a".repeat(64), authConfigRevision: "a".repeat(64),
issuer: "https://issuer.example.test", issuer: "https://issuer.example.test",
browserTransactionDigest: "b".repeat(64), browserTransactionDigest: "b".repeat(64),
browserTransactionTransport: "https",
}, now); }, now);
created += 1; created += 1;
} catch (error) { } catch (error) {
+52
View File
@@ -215,6 +215,58 @@ describe("Windows auth-storage bridge", () => {
}); });
}); });
test("uses a strict, bounded continuation page for ordinary Windows session maintenance", async () => {
const invoke = vi.fn(async () => ({
code: 0,
stdout: Buffer.from(`{"version":1,"ok":true,"entries":[{"name":"${filename}","modifiedUnixMs":1}],"more":false}\n`),
stderr: Buffer.alloc(0),
}));
const bridge = createWindowsAuthStorageBridge({ thtExecutable: "C:\\tht.exe", invoke }) as unknown as {
listPage(root: string, directory: "sessions", after: string | undefined, maximumEntries: number): Promise<{
entries: Array<{ name: string; modifiedUnixMs: number }>;
more: boolean;
}>;
};
await expect(bridge.listPage(root, "sessions", "0".repeat(64) + ".json", 512)).resolves.toEqual({
entries: [{ name: filename, modifiedUnixMs: 1 }],
more: false,
});
expect(JSON.parse(invoke.mock.calls[0][0].input.toString("utf8"))).toMatchObject({
operation: "list",
directory: "sessions",
maximumEntries: 512,
continuation: true,
afterName: "0".repeat(64) + ".json",
});
});
test("rejects ambiguous ordinary-session continuation responses", async () => {
const after = "f".repeat(64) + ".json";
const low = "a".repeat(64) + ".json";
const high = "b".repeat(64) + ".json";
const cases = [
{ label: "missing more marker", body: { entries: [{ name: filename, modifiedUnixMs: 1 }] } },
{ label: "more without a full page", body: { entries: [{ name: filename, modifiedUnixMs: 1 }], more: true } },
{ label: "non-progressing name", body: { entries: [{ name: low, modifiedUnixMs: 1 }], more: false } },
{ label: "duplicate names", body: { entries: [{ name: high, modifiedUnixMs: 1 }, { name: high, modifiedUnixMs: 2 }], more: false } },
];
for (const { body } of cases) {
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
invoke: async () => ({
code: 0,
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...body })}\n`),
stderr: Buffer.alloc(0),
}),
}) as unknown as {
listPage(root: string, directory: "sessions", afterName: string | undefined, maximumEntries: number): Promise<unknown>;
};
await expect(bridge.listPage(root, "sessions", after, 512)).rejects.toThrow("auth_session_store_invalid");
}
});
test("accepts a bounded ordinary-session page larger than the legacy 256-entry limit", async () => { test("accepts a bounded ordinary-session page larger than the legacy 256-entry limit", async () => {
const entries = Array.from({ length: 300 }, (_unused, index) => ({ const entries = Array.from({ length: 300 }, (_unused, index) => ({
name: `${index.toString(16).padStart(64, "0")}.json`, name: `${index.toString(16).padStart(64, "0")}.json`,
+24 -5
View File
@@ -41,6 +41,8 @@ type request struct {
Filename string `json:"filename,omitempty"` Filename string `json:"filename,omitempty"`
ContentBase64 string `json:"contentBase64,omitempty"` ContentBase64 string `json:"contentBase64,omitempty"`
MaximumEntries int `json:"maximumEntries,omitempty"` MaximumEntries int `json:"maximumEntries,omitempty"`
AfterName string `json:"afterName,omitempty"`
Continuation bool `json:"continuation,omitempty"`
} }
type response struct { type response struct {
@@ -53,6 +55,7 @@ type response struct {
Claimed bool `json:"claimed,omitempty"` Claimed bool `json:"claimed,omitempty"`
ContentBase64 string `json:"contentBase64,omitempty"` ContentBase64 string `json:"contentBase64,omitempty"`
Entries *[]safeio.PrivateDirectoryEntry `json:"entries,omitempty"` Entries *[]safeio.PrivateDirectoryEntry `json:"entries,omitempty"`
More *bool `json:"more,omitempty"`
} }
// Run accepts exactly one strict JSON request on stdin and emits exactly one JSON response on // Run accepts exactly one strict JSON request on stdin and emits exactly one JSON response on
@@ -143,6 +146,19 @@ func execute(input request) (response, error) {
if limit == 0 { if limit == 0 {
limit = defaultMaximumEntries limit = defaultMaximumEntries
} }
if input.Continuation {
page, err := safeio.ListCanonicalPrivateDirectoryPage(
directory,
limit,
input.AfterName,
digestFilename.MatchString,
)
if err != nil {
return response{}, errInvalid
}
more := page.More
return response{Version: protocolVersion, OK: true, Entries: &page.Entries, More: &more}, nil
}
entries, err := safeio.ListCanonicalPrivateDirectory(directory, limit) entries, err := safeio.ListCanonicalPrivateDirectory(directory, limit)
if err != nil { if err != nil {
return response{}, errInvalid return response{}, errInvalid
@@ -182,17 +198,20 @@ func execute(input request) (response, error) {
func validOperationShape(input request) bool { func validOperationShape(input request) bool {
noContents := input.ContentBase64 == "" noContents := input.ContentBase64 == ""
noMaximumEntries := input.MaximumEntries == 0 noMaximumEntries := input.MaximumEntries == 0
noAfterName := input.AfterName == ""
noContinuation := !input.Continuation
switch input.Operation { switch input.Operation {
case "create", "replace": case "create", "replace":
return noMaximumEntries && (digestFilename.MatchString(input.Filename) || (input.Operation == "create" && input.Directory == "oidc" && oidcSlotFilename.MatchString(input.Filename))) return noMaximumEntries && noAfterName && noContinuation && (digestFilename.MatchString(input.Filename) || (input.Operation == "create" && input.Directory == "oidc" && oidcSlotFilename.MatchString(input.Filename)))
case "read": case "read":
return noContents && noMaximumEntries && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && oidcSlotFilename.MatchString(input.Filename))) return noContents && noMaximumEntries && noAfterName && noContinuation && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && oidcSlotFilename.MatchString(input.Filename)))
case "remove": case "remove":
return noContents && noMaximumEntries && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && (claimFilename.MatchString(input.Filename) || oidcSlotFilename.MatchString(input.Filename)))) return noContents && noMaximumEntries && noAfterName && noContinuation && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && (claimFilename.MatchString(input.Filename) || oidcSlotFilename.MatchString(input.Filename))))
case "list": case "list":
return input.Filename == "" && noContents && input.MaximumEntries >= 0 && input.MaximumEntries <= maximumEntries return input.Filename == "" && noContents && input.MaximumEntries >= 0 && input.MaximumEntries <= maximumEntries &&
((noContinuation && noAfterName) || (input.Continuation && input.Directory == "sessions" && input.MaximumEntries >= 1 && (noAfterName || digestFilename.MatchString(input.AfterName))))
case "claim-consume", "read-claim", "remove-claim": case "claim-consume", "read-claim", "remove-claim":
return input.Directory == "oidc" && noContents && noMaximumEntries && digestFilename.MatchString(input.Filename) return input.Directory == "oidc" && noContents && noMaximumEntries && noAfterName && noContinuation && digestFilename.MatchString(input.Filename)
default: default:
return false return false
} }
@@ -34,6 +34,34 @@ func TestProtocolListUsesACallerSuppliedBound(t *testing.T) {
runRejected(t, request{Version: 1, Operation: "read", Root: root, Directory: "sessions", Filename: filename, MaximumEntries: 1}) runRejected(t, request{Version: 1, Operation: "read", Root: root, Directory: "sessions", Filename: filename, MaximumEntries: 1})
} }
func TestProtocolListPaginatesOrdinarySessionRecords(t *testing.T) {
root := filepath.Join(privateTestRoot(t), "auth")
for index := 0; index < 513; index++ {
filename := fmt.Sprintf("%064x.json", index)
runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
}
first := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 512, Continuation: true})
if first.Entries == nil || len(*first.Entries) != 512 || first.More == nil || !*first.More {
t.Fatalf("first continuation page = %#v", first)
}
second := runRequest(t, request{
Version: 1,
Operation: "list",
Root: root,
Directory: "sessions",
MaximumEntries: 512,
Continuation: true,
AfterName: (*first.Entries)[511].Name,
})
if second.Entries == nil || len(*second.Entries) != 1 || second.More == nil || *second.More || (*second.Entries)[0].Name != fmt.Sprintf("%064x.json", 512) {
t.Fatalf("second continuation page = %#v", second)
}
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "oidc", MaximumEntries: 1, Continuation: true, AfterName: fmt.Sprintf("%064x.json", 0)})
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 1, Continuation: true, AfterName: "../unsafe.json"})
}
func TestProtocolCreatesReadsReplacesListsAndRemovesPrivateRecord(t *testing.T) { func TestProtocolCreatesReadsReplacesListsAndRemovesPrivateRecord(t *testing.T) {
root := filepath.Join(privateTestRoot(t), "auth") root := filepath.Join(privateTestRoot(t), "auth")
filename := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json" filename := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json"
@@ -215,6 +243,7 @@ func TestProtocolRejectsBoundsReparseAndUnexpectedStorageNames(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions"}) runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 1, Continuation: true})
outer := privateTestRoot(t) outer := privateTestRoot(t)
linkedRoot := filepath.Join(outer, "linked-auth") linkedRoot := filepath.Join(outer, "linked-auth")
+124
View File
@@ -15,6 +15,11 @@ import (
var ErrUnsafeFile = errors.New("unsafe file") var ErrUnsafeFile = errors.New("unsafe file")
// Pagination must never allocate or iterate indefinitely over a hostile private directory. This
// is deliberately independent from an auth record page: it permits many bounded pages while
// putting a fixed upper bound on one sorted keyset scan.
const maximumPrivateDirectoryPageScanEntries = 16384
// EnsurePrivateDirectory creates only the final canonical directory with the platform's // EnsurePrivateDirectory creates only the final canonical directory with the platform's
// owner-only protection, or validates an existing directory has that protection. // owner-only protection, or validates an existing directory has that protection.
func EnsurePrivateDirectory(path string) error { func EnsurePrivateDirectory(path string) error {
@@ -86,6 +91,28 @@ type PrivateDirectoryEntry struct {
ModifiedUnixMs int64 `json:"modifiedUnixMs"` ModifiedUnixMs int64 `json:"modifiedUnixMs"`
} }
// PrivateDirectoryPage is one lexically ordered, keyset-paginated directory page. More is true
// only when the page is full and another validated entry follows AfterName.
type PrivateDirectoryPage struct {
Entries []PrivateDirectoryEntry
More bool
}
func privateDirectoryScanSnapshot(path string) (os.FileInfo, error) {
if err := ValidatePrivateDirectory(path); err != nil {
return nil, ErrUnsafeFile
}
info, err := os.Lstat(path)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, ErrUnsafeFile
}
return info, nil
}
func samePrivateDirectoryScanSnapshot(left, right os.FileInfo) bool {
return left != nil && right != nil && os.SameFile(left, right) && left.Mode() == right.Mode() && left.ModTime().Equal(right.ModTime())
}
// ListCanonicalPrivateDirectory lists regular, non-symlinked direct children from an owner-only // ListCanonicalPrivateDirectory lists regular, non-symlinked direct children from an owner-only
// directory. It returns no content and bounds the number of entries before allocating output. // directory. It returns no content and bounds the number of entries before allocating output.
func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDirectoryEntry, error) { func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDirectoryEntry, error) {
@@ -125,6 +152,103 @@ func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDi
return result, nil return result, nil
} }
// ListCanonicalPrivateDirectoryPage scans a private directory without materializing a complete
// output listing, validates every encountered child, and keeps only the next bounded lexical page.
// The caller supplies the filename grammar because safeio is intentionally storage-format agnostic.
func ListCanonicalPrivateDirectoryPage(
path string,
maximumEntries int,
afterName string,
validName func(string) bool,
) (PrivateDirectoryPage, error) {
if maximumEntries < 1 || maximumEntries > 4096 || validName == nil || (afterName != "" && !validName(afterName)) {
return PrivateDirectoryPage{}, ErrUnsafeFile
}
before, err := privateDirectoryScanSnapshot(path)
if err != nil {
return PrivateDirectoryPage{}, err
}
directory, err := os.Open(path)
if err != nil {
return PrivateDirectoryPage{}, ErrUnsafeFile
}
defer directory.Close()
seen := make(map[string]struct{}, maximumEntries+1)
selected := make([]PrivateDirectoryEntry, 0, maximumEntries+1)
scanned := 0
for {
entries, readErr := directory.ReadDir(1)
if readErr != nil && !errors.Is(readErr, io.EOF) {
return PrivateDirectoryPage{}, ErrUnsafeFile
}
if len(entries) == 0 {
break
}
if len(entries) != 1 {
return PrivateDirectoryPage{}, ErrUnsafeFile
}
scanned++
if scanned > maximumPrivateDirectoryPageScanEntries {
return PrivateDirectoryPage{}, ErrUnsafeFile
}
name := entries[0].Name()
if name == "" || strings.Contains(name, string(filepath.Separator)) || !validName(name) {
return PrivateDirectoryPage{}, ErrUnsafeFile
}
if _, duplicate := seen[name]; duplicate {
return PrivateDirectoryPage{}, ErrUnsafeFile
}
seen[name] = struct{}{}
entryPath := filepath.Join(path, name)
if err := ValidatePrivateRegular(entryPath); err != nil {
return PrivateDirectoryPage{}, ErrUnsafeFile
}
info, err := os.Lstat(entryPath)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || !hasSingleLink(info) {
return PrivateDirectoryPage{}, ErrUnsafeFile
}
if name > afterName {
selected = appendBoundedPrivateDirectoryEntry(selected, PrivateDirectoryEntry{
Name: name, ModifiedUnixMs: info.ModTime().UnixMilli(),
}, maximumEntries+1)
}
if errors.Is(readErr, io.EOF) {
break
}
}
after, err := privateDirectoryScanSnapshot(path)
if err != nil || !samePrivateDirectoryScanSnapshot(before, after) {
return PrivateDirectoryPage{}, ErrUnsafeFile
}
sort.Slice(selected, func(left, right int) bool { return selected[left].Name < selected[right].Name })
more := len(selected) > maximumEntries
if more {
selected = selected[:maximumEntries]
}
return PrivateDirectoryPage{Entries: selected, More: more}, nil
}
func appendBoundedPrivateDirectoryEntry(
entries []PrivateDirectoryEntry,
entry PrivateDirectoryEntry,
maximumEntries int,
) []PrivateDirectoryEntry {
if len(entries) < maximumEntries {
return append(entries, entry)
}
maximum := 0
for index := 1; index < len(entries); index++ {
if entries[index].Name > entries[maximum].Name {
maximum = index
}
}
if entry.Name < entries[maximum].Name {
entries[maximum] = entry
}
return entries
}
func WriteCanonicalNewFile(path string, contents []byte, mode os.FileMode) error { func WriteCanonicalNewFile(path string, contents []byte, mode os.FileMode) error {
return writeCanonicalNewFile(path, contents, mode, false) return writeCanonicalNewFile(path, contents, mode, false)
} }
+68
View File
@@ -5,7 +5,9 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/testsupport" "github.com/aritmolab/thothii/tools/tht/internal/testsupport"
) )
@@ -125,3 +127,69 @@ func TestListCanonicalPrivateDirectoryBoundsAndSortsValidatedEntries(t *testing.
t.Fatalf("257-entry listing error = %v, want ErrUnsafeFile", err) t.Fatalf("257-entry listing error = %v, want ErrUnsafeFile", err)
} }
} }
func TestListCanonicalPrivateDirectoryPageContinuesPastOneBoundedPage(t *testing.T) {
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
if err != nil {
t.Fatal(err)
}
root, err := os.MkdirTemp(temporaryRoot, "tht-safeio-page-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(root) })
if err := ProtectPrivateDirectory(root); err != nil {
t.Fatal(err)
}
validName := func(name string) bool {
return len(name) == len(fmt.Sprintf("%064x.json", 0)) && strings.HasSuffix(name, ".json")
}
for index := 0; index < 513; index++ {
path := filepath.Join(root, fmt.Sprintf("%064x.json", index))
if err := WriteCanonicalNewFile(path, []byte("record"), 0o600); err != nil {
t.Fatalf("WriteCanonicalNewFile(%d) error = %v", index, err)
}
}
first, err := ListCanonicalPrivateDirectoryPage(root, 512, "", validName)
if err != nil || len(first.Entries) != 512 || !first.More || first.Entries[0].Name != fmt.Sprintf("%064x.json", 0) || first.Entries[511].Name != fmt.Sprintf("%064x.json", 511) {
t.Fatalf("first page = %#v error = %v", first, err)
}
second, err := ListCanonicalPrivateDirectoryPage(root, 512, first.Entries[511].Name, validName)
if err != nil || len(second.Entries) != 1 || second.More || second.Entries[0].Name != fmt.Sprintf("%064x.json", 512) {
t.Fatalf("second page = %#v error = %v", second, err)
}
}
func TestListCanonicalPrivateDirectoryPageRejectsInPlaceDirectoryMutation(t *testing.T) {
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
if err != nil {
t.Fatal(err)
}
root, err := os.MkdirTemp(temporaryRoot, "tht-safeio-page-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(root) })
if err := ProtectPrivateDirectory(root); err != nil {
t.Fatal(err)
}
filename := fmt.Sprintf("%064x.json", 0)
if err := WriteCanonicalNewFile(filepath.Join(root, filename), []byte("record"), 0o600); err != nil {
t.Fatal(err)
}
changed := false
_, err = ListCanonicalPrivateDirectoryPage(root, 1, "", func(name string) bool {
if !changed {
changed = true
at := time.Unix(1_893_456_245, 0)
if changeErr := os.Chtimes(root, at, at); changeErr != nil {
t.Fatalf("Chtimes() error = %v", changeErr)
}
}
return name == filename
})
if !errors.Is(err, ErrUnsafeFile) {
t.Fatalf("unstable page error = %v, want ErrUnsafeFile", err)
}
}