fix(auth): pin native auth storage operations

This commit is contained in:
2026-08-17 14:52:21 +02:00
parent 6d438f4c7e
commit cb0e873ed7
23 changed files with 3016 additions and 1385 deletions
+252 -157
View File
@@ -7,18 +7,20 @@ import {
lstatSync,
mkdirSync,
mkdtempSync,
opendirSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
statSync,
symlinkSync,
utimesSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { basename, dirname, join } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
const fsHooks = vi.hoisted(() => ({
@@ -66,12 +68,15 @@ vi.mock("node:fs", async (importOriginal) => {
import {
createFileAuthSessionStore,
deriveCsrfToken,
validateAuthSessionRoot,
type AuthSessionStore,
type FileAuthSessionStoreOptions,
type SessionCreateInput,
} from "../src/auth/session-store.js";
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
import {
createWindowsAuthStorageBridge,
type WindowsAuthStorageBridge,
type WindowsAuthStorageDirectory,
} from "../src/auth/windows-auth-storage.js";
const roots: string[] = [];
const base = new Date("2030-01-02T03:04:05.000Z");
@@ -123,11 +128,203 @@ function claimPath(rootPath: string, rawState: string): string {
return join(rootPath, "oidc", `${createHash("sha256").update(rawState).digest("hex")}.claim`);
}
// This fixture adapter keeps session-store behavior tests self-contained. Production has no
// Node filesystem fallback: it always uses the hidden Go bridge. The Go authstorage suite owns
// descriptor-pinning/race assertions; this adapter only supplies ordinary fixture semantics.
function testPosixStorageBridge(
ensureOverride?: (rootPath: string) => Promise<void>,
): WindowsAuthStorageBridge {
let overrideUsed = false;
const ensure = async (rootPath: string): Promise<void> => {
if (ensureOverride && !overrideUsed) {
overrideUsed = true;
await ensureOverride(rootPath);
return;
}
if (!existsSync(rootPath)) {
if (realpathSync(dirname(rootPath)) !== dirname(rootPath)) throw new Error("invalid");
mkdirSync(rootPath, { mode: 0o700 });
chmodSync(rootPath, 0o700);
}
const rootInfo = lstatSync(rootPath);
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink() || (rootInfo.mode & 0o7777) !== 0o700) throw new Error("invalid");
for (const child of ["sessions", "oidc"]) {
const path = join(rootPath, child);
if (!existsSync(path)) {
mkdirSync(path, { mode: 0o700 });
chmodSync(path, 0o700);
}
const info = lstatSync(path);
if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o7777) !== 0o700) throw new Error("invalid");
}
};
const directory = async (rootPath: string, name: WindowsAuthStorageDirectory): Promise<string> => {
await ensure(rootPath);
return join(rootPath, name);
};
const record = (path: string, links: readonly number[]): import("node:fs").Stats => {
const info = lstatSync(path);
if (!info.isFile() || info.isSymbolicLink() || !links.includes(info.nlink) || (info.mode & 0o7777) !== 0o600
|| (typeof process.geteuid === "function" && info.uid !== process.geteuid())) {
throw new Error("invalid");
}
return info;
};
const same = (left: import("node:fs").Stats, right: import("node:fs").Stats): boolean =>
left.dev === right.dev && left.ino === right.ino && left.nlink === right.nlink;
const paths = async (rootPath: string, name: WindowsAuthStorageDirectory, filename: string): Promise<string> =>
join(await directory(rootPath, name), filename);
const listNames = async (
rootPath: string,
name: WindowsAuthStorageDirectory,
maximumEntries: number,
): Promise<{ name: string; modifiedUnixMs: number }[]> => {
const handle = opendirSync(await directory(rootPath, name));
const entries: string[] = [];
try {
for (;;) {
const entry = handle.readSync();
if (entry === null) break;
entries.push(entry.name);
if (entries.length > maximumEntries) throw new Error("invalid");
}
} finally {
handle.closeSync();
}
return entries.sort().map((filename) => {
const links = name === "oidc" ? [1, 2] : [1];
const info = record(join(rootPath, name, filename), links);
return { name: filename, modifiedUnixMs: info.mtimeMs };
});
};
return {
validateRoot: async (rootPath) => { await ensure(rootPath); },
ensureLayout: ensure,
readAuthConfig: (path) => readFileSync(path),
readLocalUsers: async (path) => readFileSync(path),
create: async (rootPath, name, filename, contents) => {
const path = await paths(rootPath, name, filename);
try {
writeFileSync(path, contents, { flag: "wx", mode: 0o600 });
chmodSync(path, 0o600);
return true;
} catch (error: any) {
if (error?.code !== "EEXIST") throw error;
record(path, [1]);
return false;
}
},
read: async (rootPath, name, filename) => {
const path = await paths(rootPath, name, filename);
try {
record(path, [1]);
} catch (error: any) {
if (error?.code === "ENOENT") return undefined;
throw error;
}
return readFileSync(path);
},
replace: async (rootPath, name, filename, contents) => {
const path = await paths(rootPath, name, filename);
record(path, [1]);
const temporary = `${path}.test-replacement`;
writeFileSync(temporary, contents, { flag: "wx", mode: 0o600 });
chmodSync(temporary, 0o600);
renameSync(temporary, path);
},
remove: async (rootPath, name, filename) => {
const path = await paths(rootPath, name, filename);
try {
record(path, [1]);
} catch (error: any) {
if (error?.code === "ENOENT") return false;
throw error;
}
unlinkSync(path);
return true;
},
list: async (rootPath, name, maximumEntries = 256) => await listNames(rootPath, name, maximumEntries),
listPage: async (rootPath, name, afterName, maximumEntries) => {
if (name !== "sessions") throw new Error("invalid");
const handle = opendirSync(await directory(rootPath, name));
const all: string[] = [];
try {
for (;;) {
const entry = handle.readSync();
if (entry === null) break;
all.push(entry.name);
}
} finally {
handle.closeSync();
}
all.sort();
for (const filename of all) record(join(rootPath, name, filename), [1]);
const selected = all.filter((filename) => afterName === undefined || filename > afterName).slice(0, maximumEntries + 1);
return {
entries: selected.slice(0, maximumEntries).map((filename) => {
const info = statSync(join(rootPath, name, filename));
return { name: filename, modifiedUnixMs: info.mtimeMs };
}),
more: selected.length > maximumEntries,
};
},
claimConsume: async (rootPath, filename) => {
const source = await paths(rootPath, "oidc", filename);
const claim = source.replace(/\.json$/, ".claim");
try {
record(source, [1]);
} catch (error: any) {
if (error?.code === "ENOENT") return undefined;
return undefined;
}
try {
linkSync(source, claim);
} catch (error: any) {
if (error?.code === "EEXIST") return undefined;
throw error;
}
const contents = readFileSync(source);
unlinkSync(source);
unlinkSync(claim);
return contents;
},
readClaim: async (rootPath, filename) => {
const source = await paths(rootPath, "oidc", filename);
const claim = source.replace(/\.json$/, ".claim");
try {
const sourceInfo = record(source, [2]);
const claimInfo = record(claim, [2]);
if (!same(sourceInfo, claimInfo)) throw new Error("invalid");
return readFileSync(source);
} catch (error: any) {
if (error?.code === "ENOENT") return undefined;
return undefined;
}
},
removeClaim: async (rootPath, filename) => {
const source = await paths(rootPath, "oidc", filename);
const claim = source.replace(/\.json$/, ".claim");
try {
const sourceInfo = record(source, [2]);
const claimInfo = record(claim, [2]);
if (!same(sourceInfo, claimInfo)) throw new Error("invalid");
} catch (error: any) {
if (error?.code === "ENOENT") return false;
return false;
}
unlinkSync(source);
unlinkSync(claim);
return true;
},
};
}
function validStore(storageRoot: string, options: FileAuthSessionStoreOptions = {}): AuthSessionStore {
const posixStorageBridge = options.posixStorageBridge ?? testPosixStorageBridge();
return createFileAuthSessionStore(storageRoot, {
currentAuthConfigRevision: () => revision,
findLocalUser: async () => validLocalUser,
}, options);
}, { ...options, posixStorageBridge });
}
async function create(
@@ -246,56 +443,7 @@ async function isolatedOidcCreator(storageRoot: string, attempts: number): Promi
}
describe("file-backed auth session store", () => {
test.skipIf(process.platform === "win32")("exports its side-effect-free canonical session-root validator", () => {
const valid = root();
expect(() => validateAuthSessionRoot(valid)).not.toThrow();
const outer = root();
const realRoot = join(outer, "real-auth");
mkdirSync(realRoot, { mode: 0o700 });
chmodSync(realRoot, 0o700);
const linkedRoot = join(outer, "linked-auth");
symlinkSync(realRoot, linkedRoot);
const traversal = `${valid}/../${basename(valid)}`;
const absent = join(outer, "absent-auth");
const absentNested = join(outer, "absent-parent", "auth");
const fileParent = join(outer, "not-a-directory");
writeFileSync(fileParent, "blocked", { mode: 0o600 });
expect(() => validateAuthSessionRoot(absent)).not.toThrow();
expect(existsSync(absent)).toBe(false);
for (const unsafe of [traversal, linkedRoot, absentNested, join(fileParent, "auth")]) {
expect(() => validateAuthSessionRoot(unsafe)).toThrow("auth_session_store_invalid");
}
expect(existsSync(join(outer, "absent-parent"))).toBe(false);
for (const child of ["sessions", "oidc"] as const) {
const childRoot = join(outer, `child-${child}`);
mkdirSync(childRoot, { mode: 0o700 });
chmodSync(childRoot, 0o700);
const childPath = join(childRoot, child);
const outside = root();
symlinkSync(outside, childPath);
expect(() => validateAuthSessionRoot(childRoot)).toThrow("auth_session_store_invalid");
expect(readdirSync(outside).sort()).toEqual(["oidc", "sessions"]);
unlinkSync(childPath);
mkdirSync(childPath, { mode: 0o700 });
chmodSync(childPath, 0o750);
expect(() => validateAuthSessionRoot(childRoot)).toThrow("auth_session_store_invalid");
}
const missingChildren = join(outer, "missing-children");
mkdirSync(missingChildren, { mode: 0o700 });
chmodSync(missingChildren, 0o700);
expect(() => validateAuthSessionRoot(missingChildren)).not.toThrow();
expect(existsSync(join(missingChildren, "sessions"))).toBe(false);
expect(existsSync(join(missingChildren, "oidc"))).toBe(false);
chmodSync(valid, 0o750);
expect(() => validateAuthSessionRoot(valid)).toThrow("auth_session_store_invalid");
});
test.skipIf(process.platform === "win32")("delegates missing layout creation and then enforces static/runtime parity", async () => {
test.skipIf(process.platform === "win32")("delegates missing layout creation to the retained native bridge", async () => {
const storageRoot = join(root(), "auth");
const ensureLayout = vi.fn(async (requestedRoot: string) => {
expect(requestedRoot).toBe(storageRoot);
@@ -306,14 +454,12 @@ describe("file-backed auth session store", () => {
chmodSync(join(requestedRoot, child), 0o700);
}
});
const store = validStore(storageRoot, { posixStorageBridge: { ensureLayout } });
const store = validStore(storageRoot, { posixStorageBridge: testPosixStorageBridge(ensureLayout) });
await expect(create(store)).resolves.toMatchObject({ record: { method: "local" } });
expect(ensureLayout).toHaveBeenCalledOnce();
expect(() => validateAuthSessionRoot(storageRoot)).not.toThrow();
chmodSync(join(storageRoot, "oidc"), 0o750);
expect(() => validateAuthSessionRoot(storageRoot)).toThrow("auth_session_store_invalid");
await expectStoreInvalid(store.createOidcState(oidcInput("n".repeat(16), "v".repeat(43)), base));
});
@@ -330,7 +476,7 @@ describe("file-backed auth session store", () => {
symlinkSync(outside, parent);
throw new Error(`${storageRoot} rejected`);
});
const store = validStore(storageRoot, { posixStorageBridge: { ensureLayout } });
const store = validStore(storageRoot, { posixStorageBridge: testPosixStorageBridge(ensureLayout) });
await expectStoreInvalid(create(store));
expect(ensureLayout).toHaveBeenCalledOnce();
@@ -349,14 +495,13 @@ describe("file-backed auth session store", () => {
expect(existsSync(join(outside, "auth"))).toBe(false);
});
test.skipIf(process.platform === "win32")("rejects an uncreatable missing root without side effects in static and runtime paths", async () => {
test.skipIf(process.platform === "win32")("rejects an uncreatable missing root without side effects", async () => {
const outer = root();
const lockedParent = join(outer, "locked-parent");
mkdirSync(lockedParent, { mode: 0o700 });
chmodSync(lockedParent, 0o500);
const storageRoot = join(lockedParent, "auth");
try {
expect(() => validateAuthSessionRoot(storageRoot)).toThrow("auth_session_store_invalid");
await expectStoreInvalid(create(validStore(storageRoot)));
expect(existsSync(storageRoot)).toBe(false);
} finally {
@@ -364,43 +509,20 @@ describe("file-backed auth session store", () => {
}
});
test.skipIf(process.platform === "win32")("detects an ancestor replacement before creating any session directory", async () => {
const outer = root();
const outside = root();
const parent = join(outer, "parent");
const movedParent = join(outer, "parent-original");
mkdirSync(parent, { mode: 0o700 });
chmodSync(parent, 0o700);
let replaced = false;
fsHooks.afterLstat = (observed) => {
if (observed !== parent) return false;
renameSync(parent, movedParent);
symlinkSync(outside, parent);
replaced = true;
return true;
};
test("does not fall back to Node paths when the retained POSIX bridge rejects creation", async () => {
const storageRoot = join(root(), "auth");
const bridge = {
...testPosixStorageBridge(),
create: async () => { throw new Error("native create rejected"); },
} as WindowsAuthStorageBridge;
await expectStoreInvalid(create(validStore(join(parent, "auth"))));
expect(replaced).toBe(true);
expect(existsSync(join(outside, "auth"))).toBe(false);
expect(existsSync(join(movedParent, "auth"))).toBe(false);
});
test.skipIf(process.platform === "win32")("rejects a session root owned by another identity", () => {
const storageRoot = root();
fsHooks.transformLstat = (observed, info) => {
if (observed !== storageRoot) return info;
const foreign = Object.create(info) as import("node:fs").Stats;
Object.defineProperty(foreign, "uid", { value: info.uid + 1 });
return foreign;
};
expect(() => validateAuthSessionRoot(storageRoot)).toThrow("auth_session_store_invalid");
await expectStoreInvalid(create(validStore(storageRoot, { posixStorageBridge: bridge })));
expect(existsSync(storageRoot)).toBe(false);
});
test("fails closed and revokes a session when constructed without validity dependencies", async () => {
const storageRoot = root();
const store = createFileAuthSessionStore(storageRoot);
const store = createFileAuthSessionStore(storageRoot, undefined, { posixStorageBridge: testPosixStorageBridge() });
const created = await create(store);
await expect(store.resolve(created.token)).resolves.toBeUndefined();
@@ -412,7 +534,7 @@ describe("file-backed auth session store", () => {
const store = createFileAuthSessionStore(storageRoot, {
currentAuthConfigRevision: () => { throw new Error("dependency unavailable"); },
findLocalUser: async () => validLocalUser,
});
}, { posixStorageBridge: testPosixStorageBridge() });
const created = await create(store);
await expectStoreInvalid(store.resolve(created.token));
@@ -562,21 +684,13 @@ describe("file-backed auth session store", () => {
.rejects.toThrow("auth_session_store_invalid");
});
test.skipIf(process.platform === "win32")("fails closed if the ordinary-session directory changes during a page scan", async () => {
test("fails closed when the retained POSIX bridge rejects a session 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));
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;
};
const bridge = {
...testPosixStorageBridge(),
listPage: async () => { throw new Error("native page rejected"); },
} as WindowsAuthStorageBridge;
const store = validStore(storageRoot, { posixStorageBridge: bridge });
await expect(store.prune(new Date(base.getTime() + 2 * 60_000)))
.rejects.toThrow("auth_session_store_invalid");
@@ -853,46 +967,38 @@ describe("file-backed auth session store", () => {
await expectStoreInvalid(create(validStore(linkedRoot)));
});
test.skipIf(process.platform === "win32")("refuses storage owned by a different identity", async () => {
test("fails closed when the retained POSIX bridge rejects a session read", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const bridge = {
...testPosixStorageBridge(),
read: async () => { throw new Error("native read rejected"); },
} as WindowsAuthStorageBridge;
const store = validStore(storageRoot, { posixStorageBridge: bridge });
const created = await create(store);
const sessions = join(storageRoot, "sessions");
fsHooks.transformLstat = (observed, info) => {
if (observed !== sessions) return info;
const foreign = Object.create(info) as import("node:fs").Stats;
Object.defineProperty(foreign, "uid", { value: info.uid + 1 });
return foreign;
};
await expectStoreInvalid(store.resolve(created.token));
});
test.skipIf(process.platform === "win32")("refuses directory replacement during a session read", async () => {
test("does not bypass a retained POSIX bridge read failure", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const bridge = {
...testPosixStorageBridge(),
read: async () => { throw new Error("native root replacement rejected"); },
} as WindowsAuthStorageBridge;
const store = validStore(storageRoot, { posixStorageBridge: bridge });
const created = await create(store);
const sessions = join(storageRoot, "sessions");
const replacement = join(storageRoot, "sessions-replacement");
fsHooks.afterRead = () => {
renameSync(sessions, replacement);
symlinkSync(replacement, sessions);
};
await expectStoreInvalid(store.resolve(created.token));
});
test("refuses file replacement during a touch", async () => {
test("fails closed when the retained POSIX bridge rejects a session replacement", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const bridge = {
...testPosixStorageBridge(),
replace: async () => { throw new Error("native replace rejected"); },
} as WindowsAuthStorageBridge;
const store = validStore(storageRoot, { posixStorageBridge: bridge });
const created = await create(store);
const path = digestPath(storageRoot, "sessions", created.token);
const replacement = `${path}.replacement`;
fsHooks.afterWrite = () => {
writeFileSync(replacement, "{}", { encoding: "utf8", mode: 0o600 });
chmodSync(replacement, 0o600);
renameSync(replacement, path);
};
await expectStoreInvalid(store.touch(created.token, new Date(base.getTime() + 5 * 60_000)));
});
@@ -914,37 +1020,26 @@ describe("file-backed auth session store", () => {
await expectStoreInvalid(store.touch(created.token, new Date(base.getTime() + 5 * 60_000)));
});
test("refuses file replacement during revoke", async () => {
test("fails closed when the retained POSIX bridge rejects a session removal", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const bridge = {
...testPosixStorageBridge(),
remove: async () => { throw new Error("native remove rejected"); },
} as WindowsAuthStorageBridge;
const store = validStore(storageRoot, { posixStorageBridge: bridge });
const created = await create(store);
const path = digestPath(storageRoot, "sessions", created.token);
const replacement = `${path}.replacement`;
writeFileSync(replacement, "{}", { encoding: "utf8", mode: 0o600 });
chmodSync(replacement, 0o600);
fsHooks.afterLstat = (observed) => {
if (observed !== path) return false;
renameSync(replacement, path);
return true;
};
await expectStoreInvalid(store.revoke(created.token));
expect(readFileSync(path, "utf8")).toBe("{}");
});
test.skipIf(process.platform === "win32")("refuses directory replacement during revoke", async () => {
test("does not bypass a retained POSIX bridge removal failure", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
const bridge = {
...testPosixStorageBridge(),
remove: async () => { throw new Error("native root replacement rejected"); },
} as WindowsAuthStorageBridge;
const store = validStore(storageRoot, { posixStorageBridge: bridge });
const created = await create(store);
const path = digestPath(storageRoot, "sessions", created.token);
const sessions = join(storageRoot, "sessions");
const replacement = join(storageRoot, "sessions-replacement");
fsHooks.afterLstat = (observed) => {
if (observed !== path) return false;
renameSync(sessions, replacement);
symlinkSync(replacement, sessions);
return true;
};
await expectStoreInvalid(store.revoke(created.token));
});
@@ -1354,7 +1449,7 @@ describe("file-backed auth session store", () => {
const store = createFileAuthSessionStore(storageRoot, {
currentAuthConfigRevision: () => currentRevision,
findLocalUser: async () => localUser,
});
}, { posixStorageBridge: testPosixStorageBridge() });
const configChanged = await create(store);
currentRevision = "b".repeat(64);