Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 61 additions & 7 deletions packages/runtime/src/band-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand All @@ -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<void> {
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, string>
): 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(
Expand All @@ -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`,
Expand Down Expand Up @@ -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`
);
Expand Down Expand Up @@ -624,9 +672,15 @@ async function executeScript(req: ExecRequest): Promise<ExecResponse> {
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)
Expand Down
60 changes: 60 additions & 0 deletions packages/runtime/test/unit/band-server.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading