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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user