fix(auth): harden unified diagnostic execution
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user