fix(auth): pin native auth storage operations
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user