diff --git a/backend/src/auth/routes.ts b/backend/src/auth/routes.ts index 81a62337..71e7ad97 100644 --- a/backend/src/auth/routes.ts +++ b/backend/src/auth/routes.ts @@ -1,6 +1,13 @@ 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 { + AuthenticationConfigProvider, + LoadedAuthConfig, + OidcAuthenticationConfig, + OidcStateRecord, + OidcTransactionTransport, + Role, +} from "./types.js"; import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js"; import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js"; import { rolesToPermissions } from "./config.js"; @@ -9,6 +16,7 @@ import { requirePermission, isPrincipalContext } from "./authorization.js"; import { deriveCsrfToken } from "./csrf.js"; import { verifyWithDummy } from "./password.js"; import type { OidcProtocol } from "./oidc-client.js"; +import { parseConfiguredTransportUrl } from "./url-policy.js"; const TEN_MINUTES_MS = 10 * 60 * 1000; 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_CALLBACK_QUERY_LENGTH = 4096; 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_VALUE_PATTERN = /^[A-Za-z0-9_-]{43}$/; +interface OidcTransactionCookieProfile { + transport: OidcTransactionTransport; + name: string; + secure: boolean; +} + +const OIDC_TRANSACTION_COOKIE_PROFILES: Readonly> = { + https: { transport: "https", name: "__Host-thothii_oidc_tx", secure: true }, + loopback_http: { transport: "loopback_http", name: "thothii_oidc_tx", secure: false }, +}; + export interface AuthRouteDependencies { authMode: "local" | "oidc" | "upstream" | "none" | "mock"; authentication?: AuthenticationConfigProvider; @@ -224,8 +242,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen if (!oidcInitiationLimiter.consume(boundedAddress(request.ip))) return loginLimited(reply); const loaded = captureAuthConfigSnapshot(request, deps.authentication); const configured = currentOidcConfig(loaded, deps); - if (!configured || !deps.sessionStore) { - clearOidcTransactionCookie(reply); + const transactionProfile = configured === undefined + ? undefined + : oidcTransactionCookieProfile(configured.loaded); + if (!configured || !transactionProfile || !deps.sessionStore) { + clearOidcTransactionCookies(reply); return unavailable(reply); } const nonce = randomOidcValue(); @@ -239,18 +260,20 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen authConfigRevision: configured.loaded.revision, issuer: configured.config.oidc.issuer, browserTransactionDigest: oidcTransactionDigest(browserTransaction).toString("hex"), + browserTransactionTransport: transactionProfile.transport, }); try { 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); } catch { await deps.sessionStore.consumeOidcState(created.state).catch(() => undefined); - clearOidcTransactionCookie(reply); + clearOidcTransactionCookies(reply, transactionProfile); return unavailable(reply); } } catch (error) { - clearOidcTransactionCookie(reply); + clearOidcTransactionCookies(reply, transactionProfile); if (error instanceof OidcStateCapacityError) return loginLimited(reply); return unavailable(reply); } @@ -258,20 +281,31 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen app.get("/auth/oidc/callback", async (request, reply) => { const loaded = captureAuthConfigSnapshot(request, deps.authentication); - clearOidcTransactionCookie(reply); const callback = oidcCallbackUrl(request, loaded?.value.publicUrl); - if (!deps.sessionStore || !callback.state) return oidcCallbackFailed(reply); - let state; + if (!deps.sessionStore || !callback.state) { + clearOidcTransactionCookies(reply); + return oidcCallbackFailed(reply); + } + let state: OidcStateRecord | undefined; try { state = await deps.sessionStore.consumeOidcState(callback.state); } catch { + clearOidcTransactionCookies(reply); return oidcCallbackFailed(reply); } + const stateTransactionProfile = oidcTransactionCookieProfileForTransport(state?.browserTransactionTransport); + clearOidcTransactionCookies(reply, stateTransactionProfile); const configured = currentOidcConfig(loaded, deps); - if (!callback.currentUrl || !configured || !state || state.returnTo !== "/" - || !oidcTransactionMatches(request.cookies[OIDC_TRANSACTION_COOKIE], state.browserTransactionDigest) + const configuredTransactionProfile = configured === undefined + ? 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.issuer !== configured.config.oidc.issuer) { + || state.issuer !== configured.config.oidc.issuer + || stateTransactionProfile.transport !== configuredTransactionProfile.transport) { return oidcCallbackFailed(reply); } try { @@ -432,25 +466,91 @@ function oidcTransactionMatches(value: string | undefined, expectedDigest: strin 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 { httpOnly: true, sameSite: "lax" as const, path: "/", - secure: true, + secure: profile.secure, maxAge: OIDC_TRANSACTION_COOKIE_SECONDS, }; } -function clearOidcTransactionCookie(reply: FastifyReply): void { - reply.clearCookie(OIDC_TRANSACTION_COOKIE, { +function clearOidcTransactionCookie(reply: FastifyReply, profile: OidcTransactionCookieProfile): void { + reply.clearCookie(profile.name, { httpOnly: true, sameSite: "lax", 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( request: FastifyRequest, publicUrl: string | undefined, diff --git a/backend/src/auth/session-store.ts b/backend/src/auth/session-store.ts index 91bbea47..60aab608 100644 --- a/backend/src/auth/session-store.ts +++ b/backend/src/auth/session-store.ts @@ -21,7 +21,13 @@ import type { Stats } from "node:fs"; import { dirname, isAbsolute, join, normalize } from "node:path"; import { z } from "zod"; 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 { createWindowsAuthStorageBridge, type WindowsAuthStorageBridge, @@ -42,6 +48,9 @@ const OIDC_STATE_TTL_MS = 10 * 60 * 1000; const OIDC_STATE_CAPACITY = 64; const MAX_OIDC_STORAGE_ENTRIES = OIDC_STATE_CAPACITY * 3; 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 CSRF_CONTEXT = Buffer.from("thothii-csrf-v1", "utf8"); const EMPTY_HKDF_SALT = Buffer.alloc(0); @@ -76,6 +85,7 @@ export interface OidcStateCreateInput { authConfigRevision: string; issuer: string; browserTransactionDigest: string; + browserTransactionTransport: OidcTransactionTransport; } export interface CreatedOidcState { @@ -149,6 +159,11 @@ interface DirectoryIdentity { mode?: number; } +interface DirectoryScanIdentity extends DirectoryIdentity { + mtimeMs: number; + ctimeMs: number; +} + interface TrustedFile { value: T; identity: FileIdentity; @@ -160,6 +175,11 @@ interface StorageDirectories { oidc: string; } +interface SessionDirectoryPage { + entries: string[]; + more: boolean; +} + const text = z.string().min(1).max(512).refine((value) => !/[\u0000-\u001f\u007f]/.test(value)); const timestamp = z.string().length(24).refine((value) => { const parsed = Date.parse(value); @@ -207,6 +227,9 @@ const oidcStateRecordSchema = z.strictObject({ authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/), issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)), 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(), createdAt: timestamp, expiresAt: timestamp, @@ -254,6 +277,7 @@ const oidcStateInputSchema = z.strictObject({ authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/), issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)), browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/), + browserTransactionTransport: z.enum(["https", "loopback_http"]), }); 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; } +function sameDirectoryScanIdentity(left: DirectoryScanIdentity, right: DirectoryScanIdentity): boolean { + return sameDirectoryIdentity(left, right) && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; +} + function isNotFound(error: unknown): boolean { 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 { let created = false; 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 | undefined; + try { + const before = directoryScanIdentity(directory); + handle = opendirSync(directory); + const seen = new Set(); + 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 { if (process.platform === "win32") return undefined; return openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) @@ -835,12 +937,65 @@ export function createFileAuthSessionStore( const windowsStorage = process.platform === "win32" ? options.windowsStorageBridge ?? createWindowsAuthStorageBridge() : undefined; + let sessionPruneCursor: string | undefined; function requiredWindowsStorage(): WindowsAuthStorageBridge { if (windowsStorage === undefined) throw invalid(); return windowsStorage; } + async function ordinarySessionPage(after: string | undefined): Promise { + 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(); + 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 { + 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 { const entries = process.platform === "win32" ? (await requiredWindowsStorage().list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES)).map((entry) => entry.name) @@ -1239,6 +1394,7 @@ export function createFileAuthSessionStore( authConfigRevision: validated.authConfigRevision, issuer: validated.issuer, browserTransactionDigest: validated.browserTransactionDigest, + browserTransactionTransport: validated.browserTransactionTransport, capacitySlot, createdAt: isoAt(nowMs), expiresAt: isoAt(expiresMs), @@ -1284,32 +1440,10 @@ export function createFileAuthSessionStore( async function prune(now = new Date()): Promise { const nowMs = dateMilliseconds(now); - if (process.platform === "win32") { - const bridge = requiredWindowsStorage(); - const sessionEntries = await bridge.list(root, "sessions", MAX_SESSION_PRUNE_ENTRIES); - if (sessionEntries.length > MAX_SESSION_PRUNE_ENTRIES) throw invalid(); - 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); + // Cursor advancement is process-local, so concurrent timer/manual invocations must not + // observe the same page and strand a later page forever. + return await withLock(lockKey(root, "sessions", "maintenance"), async () => + (await pruneOrdinarySessions(nowMs)) + (await pruneOidcStates(nowMs))); } return { diff --git a/backend/src/auth/types.ts b/backend/src/auth/types.ts index 55ec5cf2..88292315 100644 --- a/backend/src/auth/types.ts +++ b/backend/src/auth/types.ts @@ -55,6 +55,9 @@ export interface AuthenticationConfigProvider { 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. */ export interface AuthSessionRecord { version: 1; @@ -82,6 +85,8 @@ export interface OidcStateRecord { authConfigRevision: string; issuer: string; browserTransactionDigest: string; + /** Optional only to safely consume and reject a short-lived pre-transport legacy state. */ + browserTransactionTransport?: OidcTransactionTransport; capacitySlot?: number; createdAt: string; expiresAt: string; diff --git a/backend/src/auth/windows-auth-storage.ts b/backend/src/auth/windows-auth-storage.ts index 4c42a8bc..36720739 100644 --- a/backend/src/auth/windows-auth-storage.ts +++ b/backend/src/auth/windows-auth-storage.ts @@ -24,6 +24,11 @@ export interface WindowsAuthStorageEntry { modifiedUnixMs: number; } +export interface WindowsAuthStoragePage { + entries: WindowsAuthStorageEntry[]; + more: boolean; +} + /** Internal adapter boundary for the file-session store's native Windows path. */ export interface WindowsAuthStorageBridge { create(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise; @@ -35,6 +40,12 @@ export interface WindowsAuthStorageBridge { directory: WindowsAuthStorageDirectory, maximumEntries?: number, ): Promise; + listPage( + root: string, + directory: "sessions", + afterName: string | undefined, + maximumEntries: number, + ): Promise; claimConsume(root: string, filename: string): Promise; readClaim(root: string, filename: string): Promise; removeClaim(root: string, filename: string): Promise; @@ -92,6 +103,7 @@ const responseSchema = z.strictObject({ name: z.string().max(128), modifiedUnixMs: z.number().int().safe().nonnegative(), })).max(MAX_ENTRIES).optional(), + more: z.boolean().optional(), }); type BridgeResponse = z.infer; @@ -104,6 +116,8 @@ interface BridgeRequest { filename?: string; contentBase64?: string; maximumEntries?: number; + afterName?: string; + continuation?: true; } 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.maximumEntries !== undefined && (!Number.isInteger(request.maximumEntries) || 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 { - if (request.maximumEntries !== undefined) throw invalid(); + if (request.maximumEntries !== undefined || request.afterName !== undefined || request.continuation !== undefined) throw invalid(); if (request.filename === undefined) throw invalid(); const allowClaim = request.operation === "remove" && 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); } +function listedEntries( + response: BridgeResponse, + directory: WindowsAuthStorageDirectory, + maximumEntries: number, +): WindowsAuthStorageEntry[] { + if (response.entries === undefined || response.entries.length > maximumEntries) throw invalid(); + const names = new Set(); + 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 { const executable = safeThtExecutable(options.thtExecutable); const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht( @@ -368,13 +405,32 @@ export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridge directory, maximumEntries, }); - if (response.entries === undefined) throw invalid(); - if (response.entries.length > maximumEntries) throw invalid(); - 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 (response.more !== undefined) throw invalid(); + return listedEntries(response, directory, maximumEntries); + }, + async listPage(root, directory, afterName, maximumEntries) { + 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) { return contentFrom(await request(recordRequest("claim-consume", root, "oidc", filename)), MAX_OIDC_BYTES); diff --git a/backend/test/auth-routes-oidc.test.ts b/backend/test/auth-routes-oidc.test.ts index f433939b..cd169633 100644 --- a/backend/test/auth-routes-oidc.test.ts +++ b/backend/test/auth-routes-oidc.test.ts @@ -12,6 +12,7 @@ const state = "s".repeat(43); const nonce = "n".repeat(43); const verifier = "v".repeat(43); const transactionCookieName = "__Host-thothii_oidc_tx"; +const loopbackTransactionCookieName = "thothii_oidc_tx"; const createdApps: Array> = []; function setCookieHeaders(response: { headers: Record }): string[] { @@ -19,15 +20,42 @@ function setCookieHeaders(response: { headers: Record }): string | undefined { +function transactionCookie( + response: { headers: Record }, + name = transactionCookieName, +): string | undefined { return setCookieHeaders(response) - .find((header) => header.startsWith(`${transactionCookieName}=`)) + .find((header) => header.startsWith(`${name}=`) && !header.includes("Max-Age=0")) ?.split(";", 1)[0]; } -function expectTransactionCleared(response: { headers: Record }): void { - expect(setCookieHeaders(response).some((header) => - header.startsWith(`${transactionCookieName}=`) && header.includes("Max-Age=0"))).toBe(true); +function expectTransactionCleared( + response: { headers: Record }, + 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 }, + activeName: string, + activeSecure: boolean, +): void { + expectTransactionCleared(response, activeName, activeSecure); + expectTransactionCleared( + response, + activeName === transactionCookieName ? loopbackTransactionCookieName : transactionCookieName, + activeName !== transactionCookieName, + ); } function config(overrides: Partial = {}): LoadedAuthConfig { @@ -58,6 +86,7 @@ function stateRecord(extra: Partial = {}): OidcStateRecord { version: 1, nonce, codeVerifier: verifier, returnTo: "/", authConfigRevision: revision, issuer, browserTransactionDigest: "b".repeat(64), + browserTransactionTransport: "https", createdAt: "2030-01-01T00:00:00.000Z", expiresAt: "2030-01-01T00:10:00.000Z", ...extra, } as OidcStateRecord; @@ -106,6 +135,8 @@ function fixture(options: { }); (record as OidcStateRecord & { browserTransactionDigest: string }).browserTransactionDigest = 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; storedState = record; return { state, record: storedState }; @@ -149,7 +180,10 @@ afterEach(async () => { async function beginOidcLogin(subject: ReturnType) { 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( @@ -208,12 +242,16 @@ test("creates digest-only bound state, maps exact groups, creates a cookie sessi expect(subject.stateInputs[0]).toMatchObject({ returnTo: "/", authConfigRevision: revision, issuer, browserTransactionDigest: expect.stringMatching(/^[a-f0-9]{64}$/), + browserTransactionTransport: "https", }); expect(cookie).toMatch(new RegExp(`^${transactionCookieName}=[A-Za-z0-9_-]{43}$`)); - expect(setCookieHeaders(start).join("\n")).toContain("HttpOnly"); - expect(setCookieHeaders(start).join("\n")).toContain("SameSite=Lax"); - expect(setCookieHeaders(start).join("\n")).toContain("Secure"); - expect(setCookieHeaders(start).join("\n")).toContain("Path=/"); + const issued = setCookieHeaders(start) + .find((header) => header.startsWith(`${transactionCookieName}=`) && !header.includes("Max-Age=0")); + expect(issued).toContain("HttpOnly"); + 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"); 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("SameSite=Lax"); 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.creates).toHaveLength(1); 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"); }); +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 () => { const subject = fixture({ callbackFailure: true }); const { cookie } = await beginOidcLogin(subject); const failed = await finishOidcLogin(subject, cookie); expect(failed.statusCode).toBe(401); - expectTransactionCleared(failed); + expectBothTransactionVariantsCleared(failed, transactionCookieName, true); const replay = await finishOidcLogin(subject, cookie); expect(replay.statusCode).toBe(401); 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); expect(failed.statusCode).toBe(401); expect(subject.stateWasConsumed()).toBe(true); + expectBothTransactionVariantsCleared(failed, transactionCookieName, true); }); 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(subject.callbacks).toEqual([]); expect(subject.creates).toEqual([]); + expectBothTransactionVariantsCleared(callback, transactionCookieName, true); }); 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); expect(callback.statusCode).toBe(401); expect(subject.callbacks).toEqual([]); + expectBothTransactionVariantsCleared(callback, transactionCookieName, true); }); 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)}`); expect(failed.statusCode).toBe(401); - expectTransactionCleared(failed); + expectBothTransactionVariantsCleared(failed, transactionCookieName, true); expect(subject.stateWasConsumed()).toBe(true); expect(subject.creates).toEqual([]); @@ -328,7 +460,7 @@ test.each([ const { cookie } = await beginOidcLogin(subject); const failed = await finishOidcLogin(subject, cookie, query); expect(failed.statusCode).toBe(401); - expectTransactionCleared(failed); + expectBothTransactionVariantsCleared(failed, transactionCookieName, true); expect(subject.stateWasConsumed()).toBe(true); 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); expect(failed.statusCode).toBe(401); - expectTransactionCleared(failed); + expectBothTransactionVariantsCleared(failed, transactionCookieName, true); expect(subject.stateWasConsumed()).toBe(true); expect(subject.callbacks).toEqual([]); 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 callback = await finishOidcLogin(subject, cookie); expect(callback.statusCode).toBe(401); - expectTransactionCleared(callback); + expectBothTransactionVariantsCleared(callback, transactionCookieName, true); expect(subject.creates).toEqual([]); 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 () => { const tokenExpiresAt = new Date(Date.now() + 120_000); const subject = fixture({ identity: { diff --git a/backend/test/auth-session-store.test.ts b/backend/test/auth-session-store.test.ts index 7560bdd6..cc3e22c4 100644 --- a/backend/test/auth-session-store.test.ts +++ b/backend/test/auth-session-store.test.ts @@ -13,6 +13,7 @@ import { renameSync, rmSync, symlinkSync, + utimesSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -84,9 +85,14 @@ function oidcInput(nonce: string, codeVerifier: string) { authConfigRevision: revision, issuer: "https://issuer.example.test", browserTransactionDigest: "b".repeat(64), + browserTransactionTransport: "https" as const, }; } +function sessionFilename(index: number): string { + return `${index.toString(16).padStart(64, "0")}.json`; +} + afterEach(() => { fsHooks.afterRead = undefined; fsHooks.afterWrite = undefined; @@ -334,6 +340,104 @@ describe("file-backed auth session store", () => { 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 () => { const storageRoot = root(); const store = validStore(storageRoot); @@ -347,6 +451,7 @@ describe("file-backed auth session store", () => { nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/", authConfigRevision: revision, issuer: "https://issuer.example.test", browserTransactionDigest: "b".repeat(64), + browserTransactionTransport: "https", }); await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined(); @@ -740,6 +845,19 @@ describe("file-backed auth session store", () => { .filter((entry) => entry.startsWith(`${directory}/`)) .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) => { calls.push("claim-consume"); 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(); + 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 () => { const records = new Map(); for (let index = 0; index < 300; index += 1) { @@ -863,6 +1050,8 @@ describe("file-backed auth session store", () => { directory: "sessions" | "oidc"; filename?: string; contentBase64?: string; + continuation?: boolean; + afterName?: string; }; const entry = request.filename === undefined ? undefined : key(request.directory, request.filename); switch (request.operation) { @@ -877,6 +1066,17 @@ describe("file-backed auth session store", () => { case "remove": return response({ removed: entry !== undefined && records.delete(entry) }); 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({ // Raw lower-camel entry objects, exactly as authstorage's Go response emits them. 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 () => { const bridge = createWindowsAuthStorageBridge({ thtExecutable: "C:\\tht.exe", - invoke: async () => ({ - code: 0, - stdout: Buffer.from('{"version":1,"ok":true,"entries":[]}\n'), - stderr: Buffer.alloc(0), - }), + invoke: async ({ input }) => { + const request = JSON.parse(input.toString("utf8")) as { continuation?: boolean }; + return { + 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"); if (!originalPlatform) throw new Error("platform descriptor unavailable"); @@ -936,7 +1144,12 @@ describe("file-backed auth session store", () => { const bridge = createWindowsAuthStorageBridge({ thtExecutable: "C:\\tht.exe", 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) => ({ code: 0, 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" ? [{ name: claim, modifiedUnixMs: base.getTime() }] : [], + ...(request.continuation === true ? { more: false } : {}), }); } if (request.operation === "remove" && request.directory === "oidc" && request.filename === claim) { diff --git a/backend/test/fixtures/oidc-state-creator.mts b/backend/test/fixtures/oidc-state-creator.mts index 58acd0db..895cdfaf 100644 --- a/backend/test/fixtures/oidc-state-creator.mts +++ b/backend/test/fixtures/oidc-state-creator.mts @@ -22,6 +22,7 @@ process.once("message", async (message) => { authConfigRevision: "a".repeat(64), issuer: "https://issuer.example.test", browserTransactionDigest: "b".repeat(64), + browserTransactionTransport: "https", }, now); created += 1; } catch (error) { diff --git a/backend/test/windows-auth-storage.test.ts b/backend/test/windows-auth-storage.test.ts index 434d7f78..20dfa3d6 100644 --- a/backend/test/windows-auth-storage.test.ts +++ b/backend/test/windows-auth-storage.test.ts @@ -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; + }; + 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 () => { const entries = Array.from({ length: 300 }, (_unused, index) => ({ name: `${index.toString(16).padStart(64, "0")}.json`, diff --git a/tools/tht/internal/authstorage/storage.go b/tools/tht/internal/authstorage/storage.go index d5a7034e..6b18fffa 100644 --- a/tools/tht/internal/authstorage/storage.go +++ b/tools/tht/internal/authstorage/storage.go @@ -41,6 +41,8 @@ type request struct { Filename string `json:"filename,omitempty"` ContentBase64 string `json:"contentBase64,omitempty"` MaximumEntries int `json:"maximumEntries,omitempty"` + AfterName string `json:"afterName,omitempty"` + Continuation bool `json:"continuation,omitempty"` } type response struct { @@ -53,6 +55,7 @@ type response struct { Claimed bool `json:"claimed,omitempty"` ContentBase64 string `json:"contentBase64,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 @@ -143,6 +146,19 @@ func execute(input request) (response, error) { if limit == 0 { 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) if err != nil { return response{}, errInvalid @@ -182,17 +198,20 @@ func execute(input request) (response, error) { func validOperationShape(input request) bool { noContents := input.ContentBase64 == "" noMaximumEntries := input.MaximumEntries == 0 + noAfterName := input.AfterName == "" + noContinuation := !input.Continuation switch input.Operation { 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": - 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": - 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": - 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": - return input.Directory == "oidc" && noContents && noMaximumEntries && digestFilename.MatchString(input.Filename) + return input.Directory == "oidc" && noContents && noMaximumEntries && noAfterName && noContinuation && digestFilename.MatchString(input.Filename) default: return false } diff --git a/tools/tht/internal/authstorage/storage_test.go b/tools/tht/internal/authstorage/storage_test.go index bbb1cae1..dcf8f1cf 100644 --- a/tools/tht/internal/authstorage/storage_test.go +++ b/tools/tht/internal/authstorage/storage_test.go @@ -34,6 +34,34 @@ func TestProtocolListUsesACallerSuppliedBound(t *testing.T) { 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) { root := filepath.Join(privateTestRoot(t), "auth") filename := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json" @@ -215,6 +243,7 @@ func TestProtocolRejectsBoundsReparseAndUnexpectedStorageNames(t *testing.T) { 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", MaximumEntries: 1, Continuation: true}) outer := privateTestRoot(t) linkedRoot := filepath.Join(outer, "linked-auth") diff --git a/tools/tht/internal/safeio/files.go b/tools/tht/internal/safeio/files.go index e61a3357..01ce24ce 100644 --- a/tools/tht/internal/safeio/files.go +++ b/tools/tht/internal/safeio/files.go @@ -15,6 +15,11 @@ import ( 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 // owner-only protection, or validates an existing directory has that protection. func EnsurePrivateDirectory(path string) error { @@ -86,6 +91,28 @@ type PrivateDirectoryEntry struct { 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 // directory. It returns no content and bounds the number of entries before allocating output. func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDirectoryEntry, error) { @@ -125,6 +152,103 @@ func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDi 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 { return writeCanonicalNewFile(path, contents, mode, false) } diff --git a/tools/tht/internal/safeio/files_test.go b/tools/tht/internal/safeio/files_test.go index 6f32a7f3..c58607bf 100644 --- a/tools/tht/internal/safeio/files_test.go +++ b/tools/tht/internal/safeio/files_test.go @@ -5,7 +5,9 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" + "time" "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) } } + +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) + } +}