fix(auth): harden persistent sessions

This commit is contained in:
2026-08-16 21:03:07 +02:00
parent ab61c1ad6d
commit 6bf8218fea
3 changed files with 537 additions and 40 deletions
+206 -29
View File
@@ -7,6 +7,7 @@ import {
fstatSync,
fsyncSync,
lstatSync,
linkSync,
mkdirSync,
openSync,
readSync,
@@ -25,6 +26,7 @@ import type { AuthSessionRecord, OidcStateRecord, Permission, Role } from "./typ
const TOKEN_BYTES = 32;
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const DIGEST_FILENAME_PATTERN = /^[a-f0-9]{64}\.json$/;
const CLAIM_FILENAME_PATTERN = /^[a-f0-9]{64}\.claim$/;
const PRIVATE_DIRECTORY_MODE = 0o700;
const PRIVATE_FILE_MODE = 0o600;
const MAX_SESSION_RECORD_BYTES = 16 * 1024;
@@ -97,6 +99,7 @@ export interface AuthSessionStore {
interface FileIdentity {
dev: number;
ino: number;
uid: number;
size: number;
mtimeMs: number;
}
@@ -104,6 +107,7 @@ interface FileIdentity {
interface DirectoryIdentity {
dev: number;
ino: number;
uid: number;
mode?: number;
}
@@ -203,12 +207,12 @@ const oidcStateInputSchema = z.strictObject({
});
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
return left.dev === right.dev && left.ino === right.ino && left.size === right.size
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.size === right.size
&& left.mtimeMs === right.mtimeMs;
}
function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode;
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode;
}
function isNotFound(error: unknown): boolean {
@@ -229,8 +233,13 @@ function digestFilename(rawValue: string): string {
return `${createHash("sha256").update(rawValue).digest("hex")}.json`;
}
function assertFilename(filename: string): void {
function claimFilename(filename: string): string {
if (!DIGEST_FILENAME_PATTERN.test(filename)) throw invalid();
return filename.slice(0, -".json".length) + ".claim";
}
function assertFilename(filename: string): void {
if (!DIGEST_FILENAME_PATTERN.test(filename) && !CLAIM_FILENAME_PATTERN.test(filename)) throw invalid();
}
function filePath(directory: string, filename: string): string {
@@ -240,22 +249,31 @@ function filePath(directory: string, filename: string): string {
return path;
}
function fileIdentity(info: Stats): FileIdentity {
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1
|| (info.mode & 0o7777) !== PRIVATE_FILE_MODE || info.size < 0) {
function ownerId(): number {
const getEffectiveUserId = process.geteuid;
if (typeof getEffectiveUserId !== "function") throw invalid();
const euid = getEffectiveUserId();
if (!Number.isSafeInteger(euid) || euid < 0) throw invalid();
return euid;
}
function fileIdentity(info: Stats, expectedLinks = 1): FileIdentity {
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== expectedLinks
|| info.uid !== ownerId() || (info.mode & 0o7777) !== PRIVATE_FILE_MODE || info.size < 0) {
throw invalid();
}
return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs };
return { dev: info.dev, ino: info.ino, uid: info.uid, size: info.size, mtimeMs: info.mtimeMs };
}
function directoryIdentity(path: string): DirectoryIdentity {
const info = lstatSync(path) as Stats;
if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(path) !== path) throw invalid();
if (process.platform !== "win32" && (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid();
if (info.uid !== ownerId() || (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid();
return {
dev: info.dev,
ino: info.ino,
...(process.platform === "win32" ? {} : { mode: info.mode & 0o7777 }),
uid: info.uid,
mode: info.mode & 0o7777,
};
}
@@ -282,6 +300,11 @@ function privateDirectory(path: string): void {
}
function storageDirectories(root: string): StorageDirectories {
// Node's chmod is not a Windows DACL boundary. The existing Go operator store has a
// CreateFile security-descriptor path, but no equivalent safe Node primitive is available.
// Refuse before probing or creating the configured root rather than publishing browser state
// with inherited ACLs.
if (process.platform === "win32") throw invalid();
if (typeof root !== "string" || root.length === 0 || root.includes("\0")
|| !isAbsolute(root) || normalize(root) !== root) throw invalid();
privateDirectory(root);
@@ -328,6 +351,7 @@ function readTrusted<T>(
filename: string,
maximumBytes: number,
parse: (source: string) => T,
expectedLinks = 1,
): TrustedFile<T> | undefined {
const path = filePath(directory, filename);
let directoryDescriptor: number | undefined;
@@ -335,7 +359,7 @@ function readTrusted<T>(
try {
const beforeDirectory = directoryIdentity(directory);
const beforePath = lstatSync(path) as Stats;
const before = fileIdentity(beforePath);
const before = fileIdentity(beforePath, expectedLinks);
if (before.size > maximumBytes) throw invalid();
directoryDescriptor = openDirectory(directory);
@@ -344,7 +368,7 @@ function readTrusted<T>(
: directoryIdentityFromDescriptor(directoryDescriptor);
if (!sameDirectoryIdentity(beforeDirectory, openedDirectory)) throw invalid();
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
const opened = fileIdentity(fstatSync(descriptor) as Stats);
const opened = fileIdentity(fstatSync(descriptor) as Stats, expectedLinks);
if (!sameFileIdentity(before, opened) || opened.size > maximumBytes) throw invalid();
const contents = Buffer.allocUnsafe(maximumBytes + 1);
@@ -356,8 +380,8 @@ function readTrusted<T>(
}
if (offset > maximumBytes) throw invalid();
const after = fileIdentity(fstatSync(descriptor) as Stats);
const afterPath = fileIdentity(lstatSync(path) as Stats);
const after = fileIdentity(fstatSync(descriptor) as Stats, expectedLinks);
const afterPath = fileIdentity(lstatSync(path) as Stats, expectedLinks);
const afterDirectory = directoryIdentity(directory);
const afterOpenedDirectory = directoryDescriptor === undefined
? afterDirectory
@@ -384,11 +408,12 @@ function readTrusted<T>(
function directoryIdentityFromDescriptor(descriptor: number): DirectoryIdentity {
const info = fstatSync(descriptor) as Stats;
if (!info.isDirectory() || info.isSymbolicLink()) throw invalid();
if (process.platform !== "win32" && (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid();
if (info.uid !== ownerId() || (info.mode & 0o7777) !== PRIVATE_DIRECTORY_MODE) throw invalid();
return {
dev: info.dev,
ino: info.ino,
...(process.platform === "win32" ? {} : { mode: info.mode & 0o7777 }),
uid: info.uid,
mode: info.mode & 0o7777,
};
}
@@ -459,11 +484,23 @@ function replaceTrusted(
}
}
function removeTrusted(directory: string, filename: string, expected?: FileIdentity): boolean {
function removeTrusted(
directory: string,
filename: string,
expected?: FileIdentity,
expectedLinks = 1,
): boolean {
const path = filePath(directory, filename);
try {
const current = fileIdentity(lstatSync(path) as Stats);
const beforeDirectory = directoryIdentity(directory);
const current = fileIdentity(lstatSync(path) as Stats, expectedLinks);
if (expected && !sameFileIdentity(expected, current)) throw invalid();
// Revalidate both names immediately before unlink. On POSIX unlink never follows a final
// symlink, and this closes the observable replacement window before that operation.
const finalDirectory = directoryIdentity(directory);
const final = fileIdentity(lstatSync(path) as Stats, expectedLinks);
if (!sameDirectoryIdentity(beforeDirectory, finalDirectory) || !sameFileIdentity(current, final)
|| (expected !== undefined && !sameFileIdentity(expected, final))) throw invalid();
unlinkSync(path);
syncDirectory(directory);
return true;
@@ -489,6 +526,99 @@ function parseOidcStateRecord(source: string): OidcStateRecord {
}
}
interface OidcStateClaim {
state: TrustedFile<OidcStateRecord>;
claimIdentity: FileIdentity;
}
function inspectOidcStateClaim(
directory: string,
filename: string,
): OidcStateClaim | "orphan" | undefined {
const claimedFilename = claimFilename(filename);
let claimInfo: Stats;
try {
claimInfo = lstatSync(filePath(directory, claimedFilename)) as Stats;
} catch (error) {
if (isNotFound(error)) return undefined;
throw invalid();
}
let sourceInfo: Stats;
try {
sourceInfo = lstatSync(filePath(directory, filename)) as Stats;
} catch (error) {
if (!isNotFound(error)) throw invalid();
// A consumer may have just unlinked the source and not yet removed its claim. Do not
// remove that orphan here: doing so could make the winning consumer fail closed after it
// has read the state. prune() removes abandoned orphan claims after the state lifetime.
fileIdentity(claimInfo, 1);
return "orphan";
}
const sourceIdentity = fileIdentity(sourceInfo, 2);
const claimIdentity = fileIdentity(claimInfo, 2);
if (!sameFileIdentity(sourceIdentity, claimIdentity)) throw invalid();
const state = readTrusted(directory, filename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord, 2);
if (!state || !sameFileIdentity(sourceIdentity, state.identity)) throw invalid();
return { state, claimIdentity };
}
function oidcClaimExists(directory: string, filename: string): boolean {
const claimedFilename = claimFilename(filename);
try {
const info = lstatSync(filePath(directory, claimedFilename)) as Stats;
if (info.nlink !== 1 && info.nlink !== 2) throw invalid();
fileIdentity(info, info.nlink);
return true;
} catch (error) {
if (isNotFound(error)) return false;
throw invalid();
}
}
/**
* Claim a state by creating a hard link with a deterministic digest-only name. link(2) / NTFS
* CreateHardLink fails if another process has already installed that name, unlike rename which
* can overwrite the previous claimant. A crash leaves the pair unavailable until expiry/prune.
*/
function claimOidcState(directory: string, filename: string): OidcStateClaim | undefined {
// A competing process may be partway through consumption. It has already won and must be
// the only process allowed to deserialize the record; this process simply treats it as used.
if (oidcClaimExists(directory, filename)) return undefined;
const statePath = filePath(directory, filename);
const claimedFilename = claimFilename(filename);
const claimedPath = filePath(directory, claimedFilename);
let before: FileIdentity;
try {
before = fileIdentity(lstatSync(statePath) as Stats);
} catch (error) {
if (isNotFound(error)) return undefined;
throw invalid();
}
try {
linkSync(statePath, claimedPath);
} catch (error) {
if (isNotFound(error)) return undefined;
if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") return undefined;
throw invalid();
}
try {
const sourceIdentity = fileIdentity(lstatSync(statePath) as Stats, 2);
const claimIdentity = fileIdentity(lstatSync(claimedPath) as Stats, 2);
if (!sameFileIdentity(before, sourceIdentity) || !sameFileIdentity(sourceIdentity, claimIdentity)) throw invalid();
const state = readTrusted(directory, filename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord, 2);
if (!state || !sameFileIdentity(state.identity, sourceIdentity)) throw invalid();
return { state, claimIdentity };
} catch {
throw invalid();
}
}
function removeClaimedOidcState(directory: string, filename: string, claim: OidcStateClaim): void {
if (!removeTrusted(directory, filename, claim.state.identity, 2)) throw invalid();
if (!removeTrusted(directory, claimFilename(filename), claim.claimIdentity)) throw invalid();
}
function serialize(record: AuthSessionRecord | OidcStateRecord, maximumBytes: number): Buffer {
const contents = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
if (contents.length > maximumBytes) throw invalid();
@@ -530,7 +660,9 @@ function validLocalUser(user: LocalSessionUser | undefined, record: AuthSessionR
}
async function recordIsCurrent(record: AuthSessionRecord, validity: AuthSessionValidity | undefined): Promise<boolean> {
if (!validity) return true;
// A root-only store remains useful for creation/diagnostics, but is intentionally incapable
// of authenticating a principal. Task 8 must supply config and local-registry dependencies.
if (!validity) return false;
const revision = await validity.currentAuthConfigRevision();
if (typeof revision !== "string" || revision !== record.authConfigRevision) return false;
if (record.method !== "local") return true;
@@ -704,10 +836,16 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
const filename = digestFilename(state);
return withLock(lockKey(root, "oidc", filename), async () => {
const directories = storageDirectories(root);
const trusted = readTrusted(directories.oidc, filename, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
if (!trusted) return undefined;
removeTrusted(directories.oidc, filename, trusted.identity);
return oidcStateExpired(trusted.value, nowMs) ? undefined : trusted.value;
const claim = claimOidcState(directories.oidc, filename);
// An installed claim belongs to another process/store instance. Only the process which
// created the hard link is allowed to receive the record.
if (!claim) return undefined;
if (oidcStateExpired(claim.state.value, nowMs)) {
removeClaimedOidcState(directories.oidc, filename, claim);
return undefined;
}
removeClaimedOidcState(directories.oidc, filename, claim);
return claim.state.value;
});
}
@@ -745,13 +883,52 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
parseSessionRecord,
(record, timestamp) => sessionExpired(record as AuthSessionRecord, timestamp),
);
await pruneDirectory(
directories.oidc,
"oidc",
MAX_OIDC_STATE_RECORD_BYTES,
parseOidcStateRecord,
(record, timestamp) => oidcStateExpired(record as OidcStateRecord, timestamp),
);
let oidcEntries: string[];
try {
oidcEntries = readdirSync(directories.oidc);
} catch {
throw invalid();
}
const stateFilenames = new Set(oidcEntries.filter((entry) => DIGEST_FILENAME_PATTERN.test(entry)));
for (const filename of stateFilenames) {
await withLock(lockKey(root, "oidc", filename), async () => {
const claim = inspectOidcStateClaim(directories.oidc, filename);
if (claim === "orphan") return;
if (claim) {
if (oidcStateExpired(claim.state.value, nowMs)) {
removeClaimedOidcState(directories.oidc, filename, claim);
removed += 1;
}
return;
}
const trusted = readTrusted(
directories.oidc,
filename,
MAX_OIDC_STATE_RECORD_BYTES,
parseOidcStateRecord,
);
if (trusted && oidcStateExpired(trusted.value, nowMs)
&& removeTrusted(directories.oidc, filename, trusted.identity)) {
removed += 1;
}
});
}
for (const claimedFilename of oidcEntries) {
if (!CLAIM_FILENAME_PATTERN.test(claimedFilename)) continue;
const filename = `${claimedFilename.slice(0, -".claim".length)}.json`;
if (stateFilenames.has(filename)) continue;
await withLock(lockKey(root, "oidc", filename), async () => {
const claim = inspectOidcStateClaim(directories.oidc, filename);
if (claim !== "orphan") return;
const claimIdentity = fileIdentity(lstatSync(filePath(directories.oidc, claimedFilename)) as Stats);
// A hard link retains the source mtime. It is therefore a conservative, bounded
// expiry marker even if a process crashed between the two unlink operations.
if (nowMs >= claimIdentity.mtimeMs + OIDC_STATE_TTL_MS
&& removeTrusted(directories.oidc, claimedFilename, claimIdentity)) {
removed += 1;
}
});
}
return removed;
}