fix(auth): harden unified diagnostic execution

This commit is contained in:
2026-08-17 17:25:26 +02:00
parent 30ee9433dc
commit ef244ab56d
18 changed files with 670 additions and 68 deletions
+113 -22
View File
@@ -1,7 +1,10 @@
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import {
closeSync, constants, fstatSync, lstatSync, openSync, readFileSync,
} from "node:fs";
import { loadConfig, type AppConfig } from "../config.js";
import { loadSecretBundle, SECRET_BUNDLE_KEYS, secretValue } from "../config/secret-bundle.js";
import { loadSecretBundle, secretValue } from "../config/secret-bundle.js";
import { createAuthentikGroupCatalog } from "./authentik-group-catalog.js";
import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./local-registry.js";
import { createOidcProtocol, OidcDeviceFlowUnavailableError, type OidcProtocol } from "./oidc-client.js";
@@ -10,27 +13,96 @@ import { decodeAuthDiagnostics, type GroupCatalog } from "./group-catalog.js";
import type { LoadedAuthConfig } from "./types.js";
const AUTH_SECRET_REFERENCES = ["THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN"] as const;
const MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES = 64 * 1024;
const MAX_DIAGNOSTIC_SECRET_VALUES = 4096;
const MAX_DIAGNOSTIC_SECRET_DEPTH = 32;
export function configuredSecretValues(config: AppConfig): readonly string[] {
const values = new Set<string>();
if (config.secretsFile) {
try {
for (const value of loadSecretBundle(config.secretsFile).values()) values.add(value);
} catch {
return [];
function unavailableSecretCorpus(): Error {
return new Error("diagnostic secret corpus is unavailable");
}
function readMountedSecretSource(file: string): string {
let fd: number | undefined;
try {
if (!file || file.trim() !== file || file.includes("\0")) throw unavailableSecretCorpus();
const before = lstatSync(file);
if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES) {
throw unavailableSecretCorpus();
}
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
const opened = fstatSync(fd);
if (!opened.isFile() || opened.size > MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES
|| before.dev !== opened.dev || before.ino !== opened.ino) {
throw unavailableSecretCorpus();
}
const value = readFileSync(fd, "utf8");
if (Buffer.byteLength(value, "utf8") > MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES) {
throw unavailableSecretCorpus();
}
return value;
} catch {
throw unavailableSecretCorpus();
} finally {
if (fd !== undefined) try { closeSync(fd); } catch { /* fixed failure surface above */ }
}
for (const reference of SECRET_BUNDLE_KEYS) {
try {
const value = secretValue({ secretFiles: config.secretFiles }, reference);
if (value !== undefined) values.add(value);
} catch {
// The fixed report below is the only externally-visible failure surface.
}
function parsedSecretValues(raw: string, requireJson: boolean): readonly string[] {
const trimmed = raw.trim();
if (!trimmed) return [];
const values = new Set<string>([raw.replace(/[\r\n]+$/u, "")]);
const looksJson = trimmed.startsWith("{") || trimmed.startsWith("[");
if (!looksJson) {
if (requireJson) throw unavailableSecretCorpus();
return [...values];
}
let document: unknown;
try { document = JSON.parse(trimmed); } catch { throw unavailableSecretCorpus(); }
if (requireJson && (!document || typeof document !== "object" || Array.isArray(document))) {
throw unavailableSecretCorpus();
}
const pending: Array<{ value: unknown; depth: number }> = [{ value: document, depth: 0 }];
let scalarCount = 0;
while (pending.length > 0) {
const current = pending.pop()!;
if (current.depth > MAX_DIAGNOSTIC_SECRET_DEPTH) throw unavailableSecretCorpus();
if (Array.isArray(current.value)) {
for (const item of current.value) pending.push({ value: item, depth: current.depth + 1 });
} else if (current.value && typeof current.value === "object") {
for (const item of Object.values(current.value as Record<string, unknown>)) {
pending.push({ value: item, depth: current.depth + 1 });
}
} else {
scalarCount += 1;
if (scalarCount > 1024) throw unavailableSecretCorpus();
if (typeof current.value === "string" && current.value.length > 0) values.add(current.value);
}
}
return [...values];
}
export function configuredSecretValues(config: AppConfig): readonly string[] {
try {
const values = new Set<string>();
if (config.secretsFile) {
for (const value of loadSecretBundle(config.secretsFile).values()) values.add(value);
}
const legacyFiles = new Set(Object.values(config.secretFiles).filter(
(file): file is string => file !== undefined,
));
for (const file of legacyFiles) {
for (const value of parsedSecretValues(readMountedSecretSource(file), false)) values.add(value);
}
if (config.piAuthFile) {
for (const value of parsedSecretValues(readMountedSecretSource(config.piAuthFile), true)) values.add(value);
}
if (values.size > MAX_DIAGNOSTIC_SECRET_VALUES) throw unavailableSecretCorpus();
return [...values];
} catch {
throw unavailableSecretCorpus();
}
}
export interface ConfiguredAuthDiagnoserOptions {
localUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined;
oidcProtocol?: (loaded: LoadedAuthConfig) => OidcProtocol | undefined;
@@ -231,17 +303,36 @@ export async function runDiagnosticCommand(
}
async function main(): Promise<void> {
let config: AppConfig | undefined;
try { config = loadConfig(process.env); } catch { /* turn startup faults into the closed report below */ }
const exitCode = await runDiagnosticCommand(process.argv.slice(2), {
diagnoser: config ? createConfiguredAuthDiagnoser(config) : { inspect: async () => genericFailure() },
...(config ? { secretValues: configuredSecretValues(config) } : {}),
stdout: (line) => process.stdout.write(line),
stderr: (line) => process.stderr.write(`${line}\n`),
});
const exitCode = await runConfiguredDiagnosticCommand(
process.argv.slice(2), process.env,
(line) => process.stdout.write(line),
(line) => process.stderr.write(`${line}\n`),
);
process.exitCode = exitCode;
}
export async function runConfiguredDiagnosticCommand(
args: readonly string[],
env: Record<string, string | undefined>,
stdout: (line: string) => void,
stderr: (line: string) => void,
): Promise<number> {
let diagnoser: AuthDiagnoser = { inspect: async () => genericFailure() };
let secretValues: readonly string[] | undefined;
try {
const config = loadConfig(env);
// Complete this preflight before constructing a diagnoser that may forward a device prompt.
secretValues = configuredSecretValues(config);
diagnoser = createConfiguredAuthDiagnoser(config);
} catch { /* turn startup or corpus faults into the closed report below */ }
return runDiagnosticCommand(args, {
diagnoser,
...(secretValues === undefined ? {} : { secretValues }),
stdout,
stderr,
});
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
void main();
}
+7
View File
@@ -27,6 +27,7 @@ export interface AppConfig {
ollamaEnsureTimeoutMs: number;
piManagementTimeoutMs: number;
secretsFile?: string;
piAuthFile?: string;
secretFiles: Readonly<Record<string, string | undefined>>;
modelApiKeyFile?: string;
/**
@@ -259,6 +260,11 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
secretsFile.trim() !== secretsFile || secretsFile.length === 0 || secretsFile.includes("\0")
|| !path.isAbsolute(secretsFile)
)) throw new Error("secret bundle configuration is invalid");
const piAuthFile = env.THT_PI_AUTH_FILE;
if (piAuthFile !== undefined && (
piAuthFile.trim() !== piAuthFile || piAuthFile.length === 0 || piAuthFile.includes("\0")
|| !path.isAbsolute(piAuthFile)
)) throw new Error("Pi authentication source configuration is invalid");
const secretFiles: Record<string, string | undefined> = {};
for (const name of [
"THT_MODEL_API_KEY_SECRET_FILE", "THT_DWH_API_KEY_SECRET_FILE", "THT_VEC_API_KEY_SECRET_FILE",
@@ -335,6 +341,7 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
piManagementTimeoutMs: piManagementTimeout(env.PI_MANAGEMENT_TIMEOUT_MS),
secretsFile,
piAuthFile,
secretFiles,
modelApiKeyFile,
dwhPrecheck: env.THT_DWH_PRECHECK === "true" || env.THT_DWH_PRECHECK === "1",
@@ -6,6 +6,7 @@ import type { AuthDiagnoser, AuthDiagnostics } from "../src/auth/diagnostics.js"
import {
configuredSecretValues,
createConfiguredAuthDiagnoser,
runConfiguredDiagnosticCommand,
runDiagnosticCommand,
} from "../src/auth/diagnostic-command.js";
import type { AppConfig } from "../src/config.js";
@@ -201,6 +202,59 @@ test("redacts every parsed mounted secret before writing an interactive prompt",
expect(stdout.join("") + stderr.join("")).not.toContain(legacyVectorSecret);
});
test("includes every nested Pi authentication scalar in the direct-command redaction corpus", () => {
const piApiKey = "pi-api-key-SENTINEL";
const piAccessToken = "pi-access-token-SENTINEL";
const piAuthFile = secretBundle(JSON.stringify({
providers: {
anthropic: {
key: piApiKey,
oauth: { access: piAccessToken, expires: 1_800_000_000 },
},
},
}));
const config = {
secretsFile: secretBundle("THT_MODEL_API_KEY=model-secret\n"),
secretFiles: {},
piAuthFile,
} as AppConfig;
expect(configuredSecretValues(config)).toEqual(expect.arrayContaining([
"model-secret", piApiKey, piAccessToken,
]));
});
test("fails closed when any declared direct-command secret source cannot be loaded", () => {
const root = mkdtempSync(join(tmpdir(), "thothii-diagnostic-command-missing-"));
roots.push(root);
const config = {
secretsFile: secretBundle("THT_MODEL_API_KEY=model-secret\n"),
secretFiles: {},
piAuthFile: join(root, "missing-pi-auth.json"),
} as AppConfig;
expect(() => configuredSecretValues(config)).toThrow("diagnostic secret corpus is unavailable");
});
test("suppresses interactive prompt forwarding when the production secret preflight is incomplete", async () => {
const root = mkdtempSync(join(tmpdir(), "thothii-diagnostic-command-preflight-"));
roots.push(root);
const stdout: string[] = [];
const stderr: string[] = [];
const exitCode = await runConfiguredDiagnosticCommand(
["--json", "--interactive"],
{ NODE_ENV: "test", AUTH_MODE: "none", THT_PI_AUTH_FILE: join(root, "missing-auth.json") },
(line) => stdout.push(line),
(line) => stderr.push(line),
);
expect(exitCode).toBe(1);
expect(stderr).toEqual([]);
expect(stdout).toHaveLength(1);
expect(JSON.parse(stdout[0])).toMatchObject({ ready: false, checks: [{ code: "auth_config_invalid" }] });
});
test.each([
{
ready: true, mode: "oidc", checks: [{ level: "error", code: "oidc_secret_missing", message: "attacker-message" }],
+7
View File
@@ -47,6 +47,13 @@ test("loadConfig accepts container listening and runtime paths", () => {
});
});
test("loadConfig accepts only an absolute mounted Pi authentication source", () => {
expect(loadConfig({ THT_PI_AUTH_FILE: "/home/thoth/.pi/agent/auth.json" }).piAuthFile)
.toBe("/home/thoth/.pi/agent/auth.json");
expect(() => loadConfig({ THT_PI_AUTH_FILE: "relative/auth.json" }))
.toThrow("Pi authentication source configuration is invalid");
});
test("loadConfig keeps local development defaults", () => {
expect(loadConfig({})).toMatchObject({
host: "127.0.0.1",