fix(auth): make diagnostics match runtime safety
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
chmodSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
import {
|
||||
createAuthenticationConfigProvider,
|
||||
@@ -33,10 +43,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
function writeFixture(value: unknown): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "thothii-auth-config-"));
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-"));
|
||||
chmodSync(directory, 0o700);
|
||||
directories.push(directory);
|
||||
const file = join(directory, "auth.yaml");
|
||||
writeFileSync(file, stringify(value), "utf8");
|
||||
writeFileSync(file, stringify(value), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(file, 0o600);
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -214,7 +226,8 @@ test("provider reloads after an atomic configuration replacement", () => {
|
||||
const provider = createAuthenticationConfigProvider(file);
|
||||
const original = provider.current();
|
||||
const replacement = `${file}.replacement`;
|
||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), "utf8");
|
||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(replacement, 0o600);
|
||||
renameSync(replacement, file);
|
||||
|
||||
const reloaded = provider.current();
|
||||
@@ -225,13 +238,91 @@ test("provider reloads after an atomic configuration replacement", () => {
|
||||
test("provider retries when replacement occurs between its read and cache identity check", () => {
|
||||
const file = writeFixture(localConfig());
|
||||
const replacement = `${file}.replacement`;
|
||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), "utf8");
|
||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(replacement, 0o600);
|
||||
const provider = createAuthenticationConfigProvider(file);
|
||||
readHook.callback = () => renameSync(replacement, file);
|
||||
|
||||
expect(provider.current().value.publicUrl).toBe("http://127.0.0.1:9999");
|
||||
});
|
||||
|
||||
test.each(["symlink", "hard link", "mode wider than 0600", "non-private parent"])(
|
||||
"rejects auth.yaml with unsafe %s storage",
|
||||
(kind) => {
|
||||
const file = writeFixture(localConfig());
|
||||
if (kind === "symlink") {
|
||||
const target = `${file}.target`;
|
||||
renameSync(file, target);
|
||||
symlinkSync(target, file);
|
||||
} else if (kind === "hard link") linkSync(file, `${file}.link`);
|
||||
else if (kind === "mode wider than 0600") chmodSync(file, 0o640);
|
||||
else chmodSync(dirname(file), 0o750);
|
||||
|
||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects auth.yaml beneath a symlinked parent without exposing its path", () => {
|
||||
const outer = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-parent-"));
|
||||
chmodSync(outer, 0o700);
|
||||
directories.push(outer);
|
||||
const realDirectory = join(outer, "real-auth");
|
||||
const linkedDirectory = join(outer, "linked-auth");
|
||||
mkdirSync(realDirectory, { mode: 0o700 });
|
||||
chmodSync(realDirectory, 0o700);
|
||||
const realFile = join(realDirectory, "auth.yaml");
|
||||
writeFileSync(realFile, stringify(localConfig()), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(realFile, 0o600);
|
||||
symlinkSync(realDirectory, linkedDirectory);
|
||||
const unsafePath = join(linkedDirectory, "auth.yaml");
|
||||
|
||||
try {
|
||||
loadAuthenticationConfig(unsafePath);
|
||||
throw new Error("unsafe auth configuration unexpectedly loaded");
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toBe("authentication configuration is invalid");
|
||||
expect(String(error)).not.toContain(unsafePath);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects auth.yaml when its owner is not the runtime owner", () => {
|
||||
const geteuid = process.geteuid;
|
||||
if (!geteuid) return;
|
||||
const owner = geteuid();
|
||||
const file = writeFixture(localConfig());
|
||||
const spy = vi.spyOn(process, "geteuid").mockReturnValue(owner + 1);
|
||||
try {
|
||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider redacts an absent canonical path", () => {
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-config-absent-"));
|
||||
chmodSync(directory, 0o700);
|
||||
directories.push(directory);
|
||||
const missing = join(directory, "private-path-UNIQUE-4K6.yaml");
|
||||
|
||||
try {
|
||||
createAuthenticationConfigProvider(missing).current();
|
||||
throw new Error("missing authentication configuration unexpectedly loaded");
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toBe("authentication configuration is invalid");
|
||||
expect(String(error)).not.toContain(missing);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a path replacement during the bounded auth.yaml read", () => {
|
||||
const file = writeFixture(localConfig());
|
||||
const replacement = `${file}.replacement`;
|
||||
writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(replacement, 0o600);
|
||||
readHook.callback = () => renameSync(replacement, file);
|
||||
|
||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||
});
|
||||
|
||||
test("rejects input larger than one MiB", () => {
|
||||
const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`);
|
||||
expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid");
|
||||
|
||||
Reference in New Issue
Block a user