343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
import {
|
|
chmodSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
renameSync,
|
|
realpathSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
utimesSync,
|
|
writeFileSync,
|
|
linkSync,
|
|
} from "node:fs";
|
|
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
|
|
type OwnershipObservation =
|
|
| "file-lstat"
|
|
| "file-fstat"
|
|
| "directory-lstat"
|
|
| "directory-fstat";
|
|
|
|
const ownershipOverride = vi.hoisted(() => ({
|
|
observation: undefined as OwnershipObservation | undefined,
|
|
uid: undefined as number | undefined,
|
|
}));
|
|
|
|
vi.mock("node:fs", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
const replaceUid = <T extends object>(value: T, uid: number): T => new Proxy(value, {
|
|
get(target, property) {
|
|
if (property === "uid") return uid;
|
|
const member = Reflect.get(target, property, target);
|
|
return typeof member === "function" ? member.bind(target) : member;
|
|
},
|
|
});
|
|
const maybeReplace = <T extends import("node:fs").Stats>(
|
|
value: T,
|
|
source: "lstat" | "fstat",
|
|
): T => {
|
|
const kind = value.isDirectory() ? "directory" : "file";
|
|
return ownershipOverride.observation === `${kind}-${source}` && ownershipOverride.uid !== undefined
|
|
? replaceUid(value, ownershipOverride.uid)
|
|
: value;
|
|
};
|
|
return {
|
|
...actual,
|
|
lstatSync(path: import("node:fs").PathLike) {
|
|
return maybeReplace(actual.lstatSync(path), "lstat");
|
|
},
|
|
fstatSync(fd: number) {
|
|
return maybeReplace(actual.fstatSync(fd), "fstat");
|
|
},
|
|
};
|
|
});
|
|
|
|
import { createCurrentLocalUserRegistryResolver, createLocalUserRegistry } from "../src/auth/local-registry.js";
|
|
|
|
const password = "correct horse battery staple";
|
|
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
|
const adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
|
const userId = "7ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
|
|
|
const createdRoots: string[] = [];
|
|
|
|
afterEach(() => {
|
|
ownershipOverride.observation = undefined;
|
|
ownershipOverride.uid = undefined;
|
|
for (const root of createdRoots.splice(0)) {
|
|
for (const name of ["users.yaml", "users-link.yaml", "users-target.yaml", "replacement.yaml"]) {
|
|
const path = join(root, name);
|
|
if (existsSync(path) || lstatMaybe(path)) unlinkSync(path);
|
|
}
|
|
}
|
|
});
|
|
|
|
function lstatMaybe(path: string): boolean {
|
|
try {
|
|
lstatSync(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function root(): string {
|
|
const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-local-registry-"));
|
|
chmodSync(path, 0o700);
|
|
if ((lstatSync(path).mode & 0o7777) !== 0o700) throw new Error("test root is not private");
|
|
createdRoots.push(path);
|
|
return path;
|
|
}
|
|
|
|
function userYaml(options: {
|
|
id?: string;
|
|
username?: string;
|
|
displayName?: string;
|
|
enabled?: boolean;
|
|
role?: "user" | "admin";
|
|
} = {}): string {
|
|
return [
|
|
` - id: ${options.id ?? adminId}`,
|
|
` username: ${options.username ?? "Admin"}`,
|
|
` displayName: ${options.displayName ?? "Admin"}`,
|
|
` passwordHash: ${passwordHash}`,
|
|
` roles:`,
|
|
` - ${options.role ?? "admin"}`,
|
|
` enabled: ${options.enabled ?? true}`,
|
|
` authRevision: 1`,
|
|
].join("\n") + "\n";
|
|
}
|
|
|
|
function registryYaml(users: string): string {
|
|
return `version: 1\nusers:\n${users}`;
|
|
}
|
|
|
|
function writeRegistry(contents: string, file = "users.yaml"): { root: string; path: string } {
|
|
const directory = root();
|
|
const path = join(directory, file);
|
|
writeFileSync(path, contents, { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(path, 0o600);
|
|
return { root: directory, path };
|
|
}
|
|
|
|
async function expectInvalid(operation: Promise<unknown>, secrets: string[] = []): Promise<void> {
|
|
try {
|
|
await operation;
|
|
throw new Error("operation unexpectedly succeeded");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
expect(message).toBe("local_user_registry_invalid");
|
|
for (const secret of secrets) expect(message).not.toContain(secret);
|
|
}
|
|
}
|
|
|
|
describe("local user registry", () => {
|
|
test("resolves a projected local registry from its frozen in-memory users", async () => {
|
|
const resolver = createCurrentLocalUserRegistryResolver();
|
|
const users = Object.freeze([Object.freeze({
|
|
id: adminId,
|
|
username: "ProjectedAdmin",
|
|
normalizedUsername: "projectedadmin",
|
|
passwordHash,
|
|
roles: Object.freeze(["admin"] as const),
|
|
enabled: true,
|
|
authRevision: 1,
|
|
})]);
|
|
const loaded = {
|
|
value: {
|
|
version: 1 as const,
|
|
mode: "local" as const,
|
|
publicUrl: "http://127.0.0.1:8080",
|
|
session: {
|
|
regularTtlSeconds: 1,
|
|
regularIdleSeconds: 1,
|
|
rememberTtlSeconds: 1,
|
|
rememberIdleSeconds: 1,
|
|
oidcTtlSeconds: 1,
|
|
},
|
|
local: { usersFile: "users.yaml" },
|
|
},
|
|
revision: "sha256:synthetic",
|
|
sourcePath: "/definitely/not/opened/auth.yaml",
|
|
runtimeProjection: Object.freeze({
|
|
generation: "a".repeat(64),
|
|
canonicalRevision: `sha256:${"a".repeat(64)}`,
|
|
localUsers: users,
|
|
}),
|
|
};
|
|
const registry = resolver.resolve(loaded);
|
|
await expect(registry?.findByUsername("PROJECTEDADMIN")).resolves.toMatchObject({ id: adminId });
|
|
await expect(registry?.verify(await registry?.findByUsername("projectedadmin"), password)).resolves.toBe(true);
|
|
});
|
|
|
|
test("reads known fields, performs case-insensitive lookup, and verifies passwords", async () => {
|
|
const fixture = writeRegistry(registryYaml(userYaml({ displayName: "Local administrator" })));
|
|
const registry = createLocalUserRegistry(fixture.path);
|
|
|
|
await expect(registry.findByUsername("aDmIn")).resolves.toMatchObject({
|
|
id: adminId,
|
|
username: "Admin",
|
|
normalizedUsername: "admin",
|
|
displayName: "Local administrator",
|
|
passwordHash,
|
|
roles: ["admin"],
|
|
enabled: true,
|
|
authRevision: 1,
|
|
});
|
|
await expect(registry.findBySubject(adminId)).resolves.toMatchObject({ username: "Admin" });
|
|
await expect(registry.verify(await registry.findByUsername("admin"), password)).resolves.toBe(true);
|
|
await expect(registry.verify(await registry.findByUsername("admin"), `${password}!`)).resolves.toBe(false);
|
|
});
|
|
|
|
test("uses a dummy verification path for unknown and disabled users", async () => {
|
|
const fixture = writeRegistry(registryYaml(userYaml() + userYaml({
|
|
id: userId,
|
|
username: "operator",
|
|
role: "user",
|
|
enabled: false,
|
|
})));
|
|
const registry = createLocalUserRegistry(fixture.path);
|
|
|
|
await expect(registry.verify(undefined, password)).resolves.toBe(false);
|
|
await expect(registry.verify(await registry.findByUsername("operator"), password)).resolves.toBe(false);
|
|
});
|
|
|
|
test("reports whether a structurally valid registry has an enabled administrator", async () => {
|
|
const admin = createLocalUserRegistry(writeRegistry(registryYaml(userYaml())).path);
|
|
const usersOnly = createLocalUserRegistry(writeRegistry(registryYaml(userYaml({ role: "user" }))).path);
|
|
const disabledAdmin = createLocalUserRegistry(writeRegistry(registryYaml(userYaml({ enabled: false }))).path);
|
|
|
|
await expect(admin.hasEnabledAdmin()).resolves.toBe(true);
|
|
await expect(usersOnly.hasEnabledAdmin()).resolves.toBe(false);
|
|
await expect(disabledAdmin.hasEnabledAdmin()).resolves.toBe(false);
|
|
await expectInvalid(usersOnly.findByUsername("admin"));
|
|
await expectInvalid(disabledAdmin.findBySubject(adminId));
|
|
});
|
|
|
|
test("rejects a valid registry under a non-private authentication directory", async () => {
|
|
const fixture = writeRegistry(registryYaml(userYaml()));
|
|
chmodSync(fixture.root, 0o750);
|
|
expect(lstatSync(fixture.root).mode & 0o7777).toBe(0o750);
|
|
|
|
await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]);
|
|
});
|
|
|
|
test("rejects a valid registry under a symlinked authentication directory", async () => {
|
|
const outer = root();
|
|
const realDirectory = join(outer, "real-auth");
|
|
const linkedDirectory = join(outer, "linked-auth");
|
|
mkdirSync(realDirectory, { mode: 0o700 });
|
|
chmodSync(realDirectory, 0o700);
|
|
const path = join(realDirectory, "users.yaml");
|
|
writeFileSync(path, registryYaml(userYaml()), { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(path, 0o600);
|
|
symlinkSync(realDirectory, linkedDirectory);
|
|
|
|
await expectInvalid(createLocalUserRegistry(join(linkedDirectory, "users.yaml")).findByUsername("admin"), ["admin", passwordHash]);
|
|
});
|
|
|
|
test.each([
|
|
["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))],
|
|
["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))],
|
|
["unknown YAML fields", `${registryYaml(userYaml())}unexpected: true\n`],
|
|
["duplicate roles", registryYaml(userYaml().replace(" - admin", " - admin\n - admin"))],
|
|
["invalid password hash", registryYaml(userYaml().replace(passwordHash, "not-a-password-hash"))],
|
|
["control character in display name", registryYaml(userYaml().replace("displayName: Admin", 'displayName: "Admin\\tUser"'))],
|
|
])("rejects %s", async (_name, contents) => {
|
|
const fixture = writeRegistry(contents);
|
|
await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]);
|
|
});
|
|
|
|
test.each(["symlink", "hard link", "mode wider than 0600", "file larger than 1 MiB"])(
|
|
"rejects unsafe %s registry metadata",
|
|
async (kind) => {
|
|
const fixture = writeRegistry(registryYaml(userYaml()));
|
|
if (kind === "symlink") {
|
|
const target = join(fixture.root, "users-target.yaml");
|
|
renameSync(fixture.path, target);
|
|
symlinkSync(target, fixture.path);
|
|
} else if (kind === "hard link") {
|
|
linkSync(fixture.path, join(fixture.root, "users-link.yaml"));
|
|
} else if (kind === "mode wider than 0600") {
|
|
chmodSync(fixture.path, 0o640);
|
|
} else {
|
|
writeFileSync(fixture.path, "#".repeat((1 << 20) + 1), { encoding: "utf8", mode: 0o600 });
|
|
}
|
|
await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]);
|
|
},
|
|
);
|
|
|
|
test.runIf(process.platform !== "win32").each([
|
|
"file-lstat",
|
|
"file-fstat",
|
|
"directory-lstat",
|
|
"directory-fstat",
|
|
] as const)("rejects foreign ownership at the %s boundary", async (observation) => {
|
|
const fixture = writeRegistry(registryYaml(userYaml()));
|
|
const owner = process.geteuid();
|
|
ownershipOverride.observation = observation;
|
|
ownershipOverride.uid = owner === 0 ? 1 : owner - 1;
|
|
|
|
await expectInvalid(
|
|
createLocalUserRegistry(fixture.path).findByUsername("admin"),
|
|
["admin", passwordHash, fixture.path],
|
|
);
|
|
});
|
|
|
|
test.runIf(process.platform !== "win32")("fails closed when the effective UID is invalid", async () => {
|
|
const fixture = writeRegistry(registryYaml(userYaml()));
|
|
const getuid = vi.spyOn(process, "geteuid").mockReturnValue(-1);
|
|
try {
|
|
await expectInvalid(
|
|
createLocalUserRegistry(fixture.path).findByUsername("admin"),
|
|
["admin", passwordHash, fixture.path],
|
|
);
|
|
} finally {
|
|
getuid.mockRestore();
|
|
}
|
|
});
|
|
|
|
test("reloads a same-size atomic replacement with changed metadata", async () => {
|
|
const fixture = writeRegistry(registryYaml(userYaml({ displayName: "Admin" })));
|
|
const registry = createLocalUserRegistry(fixture.path);
|
|
await expect(registry.findByUsername("admin")).resolves.toMatchObject({ displayName: "Admin" });
|
|
|
|
const replacement = join(fixture.root, "replacement.yaml");
|
|
writeFileSync(replacement, registryYaml(userYaml({ displayName: "Owner" })), { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(replacement, 0o600);
|
|
utimesSync(replacement, new Date("2035-01-01T00:00:00Z"), new Date("2035-01-01T00:00:00Z"));
|
|
expect(lstatSync(replacement).size).toBe(lstatSync(fixture.path).size);
|
|
renameSync(replacement, fixture.path);
|
|
|
|
await expect(registry.findByUsername("admin")).resolves.toMatchObject({ displayName: "Owner" });
|
|
});
|
|
|
|
test("routes native Windows users.yaml loading only through the bounded auth-storage bridge", async () => {
|
|
const usersPath = "C:\\ProgramData\\ThothII\\auth\\users.yaml";
|
|
const readLocalUsers = vi.fn(async (path: string) => {
|
|
expect(path).toBe(usersPath);
|
|
return Buffer.from(registryYaml(userYaml({ displayName: "Bridge administrator" })), "utf8");
|
|
});
|
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
|
if (!originalPlatform) throw new Error("platform descriptor unavailable");
|
|
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
|
|
try {
|
|
const registry = createLocalUserRegistry(usersPath, { windowsStorageBridge: { readLocalUsers } } as never);
|
|
await expect(registry.findByUsername("ADMIN")).resolves.toMatchObject({
|
|
id: adminId,
|
|
displayName: "Bridge administrator",
|
|
});
|
|
await expect(registry.hasEnabledAdmin()).resolves.toBe(true);
|
|
// Windows reloads from the bridge on every registry observation so an atomic host
|
|
// replacement cannot be missed between authorization checks.
|
|
expect(readLocalUsers).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", originalPlatform);
|
|
}
|
|
});
|
|
});
|