Heimdall/tests/entrypoint-secrets.test.ts
Meta Vens 0f5ffd9e69 Every start connects to Postgres; pin the signing key's read refusal
Soul pass 3 (R1, R4). With GC_ACCESS_APPLY_SCHEMA_ON_STARTUP=0, startup
never opened a connection, so a misbound database URL surfaced only in a
request's error answer. createStore now connects on every Postgres start:
it applies the schema when asked and otherwise runs `SELECT 1`, and either
failure is the same fixed message naming GC_ACCESS_DATABASE_URL_FILE and a
code. The entrypoint harness gains that case with the canary in the host.

The harness also pins the signing key's read-failure branch with a key file
of mode 000. Root reads such a file, so the case runs the entrypoint as a
user a mode can refuse: the test process itself when it is not root, or
else the checkout's owner. It is skipped, with the reason in its title, on
Windows or as root in a root-owned checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:30:10 +02:00

253 lines
11 KiB
TypeScript

import { spawn } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, describe, expect, it } from "vitest";
// A secret must never reach a terminal. Under Idunn, Heimdall's stderr goes to
// the journal and the install script tails the journal to the operator's
// terminal, so the layer that matters is the process's own output, not an
// error's message. These cases run the real entrypoint with canary values in
// the places a misbinding puts secrets, and fail on any byte of the canary
// anywhere on stdout or stderr: in a message, an error cause, an error
// property, or anything else Node chooses to print.
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
const scratch = mkdtempSync(join(tmpdir(), "heimdall-entrypoint-"));
const CANARY = "CANARY";
afterAll(() => {
rmSync(scratch, { recursive: true, force: true });
});
function file(name: string, contents: string): string {
const path = join(scratch, name);
writeFileSync(path, contents);
return path;
}
interface Run {
code: number | null;
signal: NodeJS.Signals | null;
output: string;
}
interface Identity {
uid: number;
gid: number;
}
/**
* Who can be denied a read. Root reads a mode-000 file, so a read failure is
* only reachable as another user: the process itself when it is not root, or
* else the checkout's owner, who can still load the entrypoint. A string is
* the reason no such user exists here.
*/
function nonRootReader(): Identity | "self" | string {
if (process.platform === "win32") return "file modes do not deny reads on Windows";
if (process.getuid?.() !== 0) return "self";
const owner = statSync(repoRoot);
if (owner.uid === 0) return "running as root in a root-owned checkout, so no other user can load the entrypoint";
return { uid: owner.uid, gid: owner.gid };
}
/** Runs `tsx src/index.ts` with only the given environment, merging stdout and stderr. */
function runEntrypoint(name: string, env: Record<string, string>, identity?: Identity): Promise<Run> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [tsxCli, "src/index.ts"], {
cwd: repoRoot,
...identity,
env: {
PATH: process.env.PATH ?? "",
HOME: scratch,
GC_ACCESS_DATA_ROOT: join(scratch, `data-${name}`),
PORT: "0",
GC_ACCESS_PRIVATE_COMMAND_PORT: "0",
...env,
},
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
// A case that fails to fail would start serving; stop it rather than hang.
const timer = setTimeout(() => child.kill("SIGKILL"), 30_000);
child.on("error", reject);
child.on("close", (code, signal) => {
clearTimeout(timer);
resolve({ code, signal, output });
});
});
}
/** A base64 value that decodes to exactly 32 bytes and still carries the canary. */
function canaryTokenKey(tag: string): string {
return `${CANARY}${tag}`.padEnd(43, "A") + "=";
}
interface Case {
name: string;
env: () => Record<string, string>;
/** Something the failure must say, so a case cannot pass by never reaching its path. */
expect: RegExp;
/** Run the entrypoint as a user a file mode can refuse. */
nonRoot?: true;
}
const cases: Case[] = [
{
name: "a URL bound into an absolute _FILE slot",
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: `/postgres://heimdall:${CANARY}url@db/heimdall` }),
expect: /GC_ACCESS_DATABASE_URL_FILE could not be read \(ENOENT\)/,
},
{
name: "a _FILE path under a regular file",
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: join(file("plain", "x"), `${CANARY}notdir`) }),
expect: /GC_ACCESS_DATABASE_URL_FILE could not be read \(ENOTDIR\)/,
},
{
name: "a secret bound into a relative _FILE slot",
env: () => ({ GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE: `${CANARY}relative-secret` }),
expect: /GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE is not an absolute path/,
},
{
name: "a secret bound both ways",
env: () => ({
GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET: `${CANARY}plain`,
GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET_FILE: file("both", `${CANARY}file\n`),
}),
expect: /GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET and GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET_FILE are both set/,
},
{
name: "a blank secret file",
env: () => ({ GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE: join(scratch, "blank") }),
expect: /GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE is empty/,
},
{
name: "a token key that decodes short",
env: () => ({ GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE: file("short-key", `${CANARY}shortkey\n`) }),
expect: /GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE must decode to exactly 32 bytes/,
},
{
name: "a database URL with an unusable port",
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: file("db-port", `postgres://heimdall:${CANARY}port@db:notaport/heimdall\n`) }),
expect: /Postgres storage could not be prepared \(ERR_INVALID_URL\)/,
},
{
name: "a database URL that does not parse",
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: file("db-parse", `postgres://heimdall:${CANARY} parse@[bad/heimdall\n`) }),
expect: /Postgres storage could not be prepared \(E[A-Z_]+\)/,
},
{
name: "a database URL with a broken percent escape",
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: file("db-percent", `postgres://heimdall:${CANARY}%zz@127.0.0.1:1/heimdall\n`) }),
expect: /Postgres storage could not be prepared \(ECONNREFUSED\)/,
},
{
name: "a database that refuses the connection",
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: file("db-refused", `postgres://heimdall:${CANARY}refused@127.0.0.1:1/heimdall\n`) }),
expect: /Postgres storage could not be prepared \(ECONNREFUSED\)/,
},
{
// A password holding an unescaped delimiter moves part of it into the
// host, and the resolver's error names the host.
name: "a database host that does not resolve",
env: () => ({ GC_ACCESS_DATABASE_URL_FILE: file("db-host", `postgres://heimdall:pw@${CANARY}host.invalid/heimdall\n`) }),
expect: /Postgres storage could not be prepared \(E[A-Z_]+\)/,
},
{
// A start that does not apply the schema still connects, so a misbound URL
// fails at startup rather than in a request's error answer.
name: "a database host that does not resolve, on a start that does not apply the schema",
env: () => ({
GC_ACCESS_APPLY_SCHEMA_ON_STARTUP: "0",
GC_ACCESS_DATABASE_URL_FILE: file("db-host-noapply", `postgres://heimdall:pw@${CANARY}host.invalid/heimdall\n`),
}),
expect: /Postgres storage could not be prepared \(E[A-Z_]+\)/,
},
{
name: "private key text bound to the signing key path",
env: () => ({
GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: `-----BEGIN PRIVATE KEY-----\n${CANARY}pem\n-----END PRIVATE KEY-----\n`,
}),
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH is not an absolute path/,
},
{
name: "a signing key path that names no file",
env: () => ({ GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: join(scratch, `${CANARY}missing-key`) }),
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH names no file \(ENOENT\)/,
},
{
name: "a signing key file that is not a key",
env: () => ({ GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: file("not-a-key", `${CANARY}notakey\n`) }),
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH does not hold a usable private key/,
},
{
name: "a signing key file its reader may not read",
env: () => {
const key = file(`${CANARY}locked-key`, `${CANARY}pem\n`);
chmodSync(key, 0o000);
const dataRoot = join(scratch, "locked-key-data");
mkdirSync(dataRoot, { recursive: true });
chmodSync(dataRoot, 0o777);
return { GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: key, GC_ACCESS_DATA_ROOT: dataRoot };
},
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH could not be read \(EACCES\)/,
nonRoot: true,
},
{
name: "a signing key path that is a directory",
env: () => {
const dir = join(scratch, "key-dir");
mkdirSync(dir, { recursive: true });
return { GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: dir };
},
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH could not be read \(EISDIR\)/,
},
{
name: "a bootstrapped signing key whose directory cannot be created",
env: () => ({
GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: join(file("key-parent", "x"), `${CANARY}dir`, "key.pem"),
GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP: "1",
}),
expect: /GC_ACCESS_SIGNING_PRIVATE_KEY_PATH could not be created \(ENOTDIR\)/,
},
{
// Every secret is configured and valid, and startup fails afterwards, in
// the entrypoint's own catch: the one place that holds the whole config.
name: "a listen failure with every secret configured",
env: () => ({
HOST: "203.0.113.1",
GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE: file("listen-key", `${canaryTokenKey("key")}\n`),
GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE: file("listen-patron", `${CANARY}patron\n`),
GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE: file("listen-github", `${CANARY}github\n`),
GC_ACCESS_APP_BIFROST_SHARED_SECRET_FILE: file("listen-bifrost", `${CANARY}bifrost\n`),
}),
expect: /Heimdall failed to start/,
},
];
describe("the entrypoint never prints a secret", () => {
writeFileSync(join(scratch, "blank"), " \n");
// mkdtemp makes the scratch directory private to its creator; a non-root
// reader must still reach the files a case names inside it.
chmodSync(scratch, 0o755);
const reader = nonRootReader();
for (const testCase of cases) {
const skipReason = testCase.nonRoot && typeof reader === "string" && reader !== "self" ? reader : undefined;
const title = `refuses ${testCase.name} without printing the canary${skipReason ? ` (skipped: ${skipReason})` : ""}`;
it.skipIf(skipReason !== undefined)(title, async () => {
const identity = testCase.nonRoot && typeof reader === "object" ? reader : undefined;
const run = await runEntrypoint(testCase.name.replace(/\W+/g, "-"), testCase.env(), identity);
expect(run.signal, "the process must stop by itself").toBeNull();
expect(run.code, "startup must fail").not.toBe(0);
expect(run.output).toMatch(testCase.expect);
expect(run.output.includes(CANARY), "no byte of the canary may reach the terminal").toBe(false);
}, 45_000);
}
});