diff --git a/packages/runtime/src/band-server.ts b/packages/runtime/src/band-server.ts index 585f4d3..699e4da 100644 --- a/packages/runtime/src/band-server.ts +++ b/packages/runtime/src/band-server.ts @@ -20,7 +20,7 @@ */ import { Hono } from "hono"; -import { createHash } from "crypto"; +import { createHash, randomBytes } from "crypto"; import { buildCliWrapperScript, buildDenyPatternsFile, SAFE_CMD_NAME } from "./cli-wrapper"; const BAND_RUNNER_USER = "band-runner"; @@ -147,7 +147,7 @@ function shellIgnoreError(cmd: string): void { } function randomId(): string { - return Math.random().toString(36).slice(2, 10); + return randomBytes(6).toString("hex"); } /** Reject values containing shell metacharacters. Used for any band-config value interpolated into shell commands. */ @@ -163,6 +163,53 @@ function writeFile(path: string, content: string): void { shell(`echo '${b64}' | base64 -d > ${path}`); } +function shellWorkdir(value: string): string { + if (!/^\/tmp\/band-exec-[0-9a-f]+$/.test(value)) { + throw new Error(`Invalid workdir: ${value}`); + } + return value; +} + +async function writeSecretFile(path: string, content: string): Promise { + if (!/^\/tmp\/band-exec-[0-9a-f]+\/secrets\/[A-Za-z_][A-Za-z0-9_]*$/.test(path)) { + throw new Error(`Invalid secret path: ${path}`); + } + const proc = Bun.spawn( + ["sudo", "bash", "-c", `cat > "$1" && chmod 600 "$1"`, "bash", path], + { stdin: "pipe", stdout: "pipe", stderr: "pipe" } + ); + proc.stdin.write(content); + proc.stdin.end(); + const [exitCode, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(stderr || `Command failed: write ${path}`); + } +} + +function shellEnvName(value: string, label: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + throw new Error(`Invalid ${label}: ${value}`); + } + return value; +} + +export function buildSecretEnvLines( + workdir: string, + secrets: Record +): string[] { + const safeWorkdir = shellWorkdir(workdir); + const lines: string[] = []; + for (const key of Object.keys(secrets)) { + const name = shellEnvName(key, "secret name"); + lines.push(`IFS= read -r -d '' ${name} < "${safeWorkdir}/secrets/${name}" || true`); + lines.push(`export ${name}`); + } + return lines; +} + // ── Firewall ────────────────────────────────────────────────────────── function setupFirewall( @@ -180,6 +227,7 @@ function setupFirewall( return; } + const { uid } = getCachedBandRunnerIds(); const cmds: string[] = [ `iptables -N ${chainName} 2>/dev/null || iptables -F ${chainName}`, `iptables -A ${chainName} -o lo -j ACCEPT`, @@ -218,15 +266,15 @@ function setupFirewall( cmds.push(`iptables -A ${chainName} -j REJECT`); } - const { uid } = getCachedBandRunnerIds(); cmds.push(`iptables -I OUTPUT 1 -m owner --uid-owner ${uid} -m state --state NEW -j ${chainName}`); shell(cmds.join("\n")); } function teardownFirewall(chainName: string): void { + const { uid } = getCachedBandRunnerIds(); shellIgnoreError( - `iptables -D OUTPUT -m owner --uid-owner ${getCachedBandRunnerIds().uid} -m state --state NEW -j ${chainName} 2>/dev/null; ` + + `iptables -D OUTPUT -m owner --uid-owner ${uid} -m state --state NEW -j ${chainName} 2>/dev/null; ` + `iptables -F ${chainName} 2>/dev/null; ` + `iptables -X ${chainName} 2>/dev/null` ); @@ -624,9 +672,15 @@ async function executeScript(req: ExecRequest): Promise { if (req.config) { envLines.push(`export CONFIG_PATH=${workdir}/config.json`); } - for (const [key, value] of Object.entries(req.secrets ?? {})) { - const b64 = Buffer.from(value).toString("base64"); - envLines.push(`export ${key}=$(echo '${b64}' | base64 -d)`); + const secretEnvLines = buildSecretEnvLines(workdir, req.secrets ?? {}); + if (secretEnvLines.length > 0) { + const secretsDir = `${shellWorkdir(workdir)}/secrets`; + shell(`mkdir -p ${secretsDir} && chmod 700 ${secretsDir}`); + for (const [key, value] of Object.entries(req.secrets ?? {})) { + const name = shellEnvName(key, "secret name"); + await writeSecretFile(`${secretsDir}/${name}`, value); + } + envLines.push(...secretEnvLines); } // Point PATH to the cooked CLI wrapper dir (mounted at workdir/.band-cli inside bwrap) diff --git a/packages/runtime/test/unit/band-server.test.ts b/packages/runtime/test/unit/band-server.test.ts new file mode 100644 index 0000000..2ad1c8f --- /dev/null +++ b/packages/runtime/test/unit/band-server.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { randomBytes } from "crypto"; +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +import { buildSecretEnvLines } from "../../src/band-server"; + +const validWorkdir = "/tmp/band-exec-deadbeef"; + +describe("band-server secret env", () => { + test("reads secrets from files without embedding values or base64 subprocesses", () => { + const lines = buildSecretEnvLines(validWorkdir, { + API_KEY: "super-secret", + }); + + expect(lines).toEqual([ + `IFS= read -r -d '' API_KEY < "${validWorkdir}/secrets/API_KEY" || true`, + "export API_KEY", + ]); + expect(lines.join("\n")).not.toContain("super-secret"); + expect(lines.join("\n")).not.toContain("base64"); + expect(lines.join("\n")).not.toContain("$("); + }); + + test("generated env lines export multiline secret file contents", () => { + const workdir = join(tmpdir(), `band-exec-${randomBytes(6).toString("hex")}`); + mkdirSync(workdir); + try { + mkdirSync(join(workdir, "secrets")); + const secret = "top secret\nsecond line"; + writeFileSync(join(workdir, "secrets", "API_KEY"), secret); + + const script = [ + ...buildSecretEnvLines(workdir, { API_KEY: secret }), + `printf '%s' "$API_KEY"`, + ].join("\n"); + const proc = Bun.spawnSync(["bash", "-c", script]); + + expect(proc.exitCode).toBe(0); + expect(proc.stdout.toString()).toBe(secret); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + }); + + test("rejects invalid secret names before writing env lines", () => { + expect(() => + buildSecretEnvLines(validWorkdir, { "BAD/KEY": "secret" }) + ).toThrow("Invalid secret name: BAD/KEY"); + }); + + test("rejects unexpected workdir paths before writing env lines", () => { + expect(() => + buildSecretEnvLines("/tmp/band-exec-test;touch /tmp/bad", { + API_KEY: "secret", + }) + ).toThrow("Invalid workdir: /tmp/band-exec-test;touch /tmp/bad"); + }); +});