Files
core2026_docker/test/release-builder-security.test.mjs

302 lines
9.8 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { request } from "node:http";
import { createReleaseBuilderServer } from "../runtime/release-builder.mjs";
import {
validateBuildCommand,
readBuilderToken,
} from "../runtime/release-builder-security.mjs";
const token = "t".repeat(64);
const fixture = async (options = {}) => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "builder-http-security-"),
);
const bin = path.join(root, "bin");
await fs.mkdir(bin);
await fs.writeFile(
path.join(bin, "pnpm"),
`#!${process.execPath}\nconst mode=process.env.VITE_FIXTURE_MODE;\nif(mode==='hold'||mode==='ignore'){if(mode==='ignore')process.on('SIGTERM',()=>{});console.log('ready');setInterval(()=>{},1000)}else if(mode==='flood'){process.stdout.write('x'.repeat(20*1024*1024));}else{console.log(JSON.stringify({args:process.argv.slice(2),secretInChild:Boolean(process.env.RELEASE_BUILDER_TOKEN_FILE||process.env.DATABASE_URL||process.env.UNRELATED_SECRET),publicValue:process.env.VITE_FIXTURE_VALUE}));}\n`,
{ mode: 0o755 },
);
const children = [];
const server = createReleaseBuilderServer({
token,
allowedRoots: [root],
cacheRoots: [root],
baseEnv: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
RELEASE_BUILDER_TOKEN_FILE: "/private/token",
DATABASE_URL: "synthetic-private",
UNRELATED_SECRET: "synthetic-private",
},
spawnImpl: (...args) => {
const child = spawn(...args);
children.push(child);
return child;
},
...options,
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${server.address().port}/v1/builds`;
const command = (env = {}) => ({
command: "pnpm",
args: ["install", "--frozen-lockfile"],
cwd: root,
env,
});
const post = (commands = [command()], presented = token, signal) =>
fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-release-builder-token": presented,
},
body: JSON.stringify({ commands }),
signal,
});
const close = async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fs.rm(root, { recursive: true, force: true });
};
return { root, url, command, post, children, close };
};
const alive = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
const waitFor = async (predicate) => {
const until = Date.now() + 5000;
while (!predicate()) {
if (Date.now() >= until)
throw new Error("Fixture state deadline exceeded.");
await new Promise((resolve) => setTimeout(resolve, 20));
}
};
test("rejects missing, placeholder and oversized token files without exposing values", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "builder-secret-test-"));
try {
assert.throws(
() => readBuilderToken(path.join(root, "missing")),
/A valid RELEASE_BUILDER_TOKEN_FILE/,
);
const filename = path.join(root, "token");
for (const value of ["replace-with-token", "x".repeat(257)]) {
await fs.writeFile(filename, value);
assert.throws(
() => readBuilderToken(filename),
/A valid RELEASE_BUILDER_TOKEN_FILE/,
);
}
await fs.writeFile(filename, token);
assert.equal(readBuilderToken(filename), token);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
test("rejects unauthenticated HTTP before parsing a command and executes authenticated work without inherited secrets", async () => {
const f = await fixture();
try {
for (const presented of ["", "x".repeat(64)]) {
const response = await f.post([f.command()], presented);
assert.equal(response.status, 401);
assert.equal(f.children.length, 0);
}
const response = await f.post([
f.command({ VITE_FIXTURE_VALUE: "public", PATH: "/attacker/bin" }),
]);
assert.equal(response.status, 200);
const body = await response.text();
assert.match(body, /secretInChild\\\":false/);
assert.match(body, /publicValue\\\":\\\"public/);
assert.ok(!body.includes(token));
assert.ok(!body.includes("/attacker/bin"));
assert.equal(JSON.parse(body.trim().split("\n").at(-1)).result.ok, true);
} finally {
await f.close();
}
});
test("denies extra pnpm flags, arbitrary packages/tasks, unsafe Node controls and escaping symlinks", async () => {
const f = await fixture();
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "builder-outside-"));
try {
await fs.symlink(outside, path.join(f.root, "escape"));
const invalid = [
{ ...f.command(), args: ["install", "--frozen-lockfile", "--dir=/tmp"] },
{
...f.command(),
args: ["exec", "turbo", "run", "arbitrary", "--filter=@sammo-ts/infra"],
},
{ ...f.command(), args: ["--filter", "attacker-package", "build"] },
{ ...f.command(), env: { NODE_OPTIONS: "--require=/tmp/attack.cjs" } },
{
...f.command(),
env: { TURBO_CACHE_DIR: path.join(f.root, "escape", "cache") },
},
{ ...f.command(), cwd: path.join(f.root, "escape") },
{ ...f.command(), command: "node", args: ["-e", "process.exit(0)"] },
];
for (const command of invalid) {
assert.equal((await f.post([command])).status, 400);
assert.equal(f.children.length, 0);
}
const valid = {
...f.command(),
args: [
"exec",
"turbo",
"run",
"build:release",
"--filter=@sammo-ts/game-frontend",
`--cache-dir=${f.root}/cache`,
"--concurrency=1",
"--ui=stream",
"--output-logs=new-only",
],
env: {
NODE_OPTIONS: "--max-old-space-size=3072",
RAYON_NUM_THREADS: "2",
},
};
assert.deepEqual(
validateBuildCommand(valid, [f.root], [f.root]).args,
valid.args,
);
} finally {
await f.close();
await fs.rm(outside, { recursive: true, force: true });
}
});
test("caps actual UTF-8 request bytes before child execution", async () => {
const f = await fixture();
try {
const response = await fetch(f.url, {
method: "POST",
headers: { "x-release-builder-token": token },
body: JSON.stringify({
commands: [f.command()],
extra: "한".repeat(100_000),
}),
});
assert.equal(response.status, 413);
assert.equal(f.children.length, 0);
} finally {
await f.close();
}
});
test("caps active plus queued requests and reclaims capacity after client disconnects", async () => {
const f = await fixture();
const controllers = Array.from({ length: 3 }, () => new AbortController());
try {
const responses = [];
for (const controller of controllers)
responses.push(
await f.post(
[f.command({ VITE_FIXTURE_MODE: "hold" })],
token,
controller.signal,
),
);
const denied = await f.post();
assert.equal(denied.status, 429);
assert.equal(denied.headers.get("retry-after"), "1");
await waitFor(() => f.children.length > 0);
for (const controller of controllers) controller.abort();
await Promise.all(
responses.map((response) => response.text().catch(() => "")),
);
await waitFor(() => f.children.every((child) => !alive(child.pid)));
let recovered;
for (let i = 0; i < 100; i++) {
recovered = await f.post();
if (recovered.status !== 429) break;
await recovered.text();
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(recovered.status, 200);
assert.equal(
JSON.parse((await recovered.text()).trim().split("\n").at(-1)).result.ok,
true,
);
} finally {
for (const controller of controllers) controller.abort();
await f.close();
}
});
test("job deadline kills a subprocess that ignores SIGTERM and returns a failed result", async () => {
const f = await fixture({ jobTimeoutMs: 1000, terminateGraceMs: 50 });
try {
const response = await f.post([f.command({ VITE_FIXTURE_MODE: "ignore" })]);
const body = await response.text();
assert.match(body, /ready/);
const result = JSON.parse(body.trim().split("\n").at(-1)).result;
assert.equal(result.ok, false);
assert.equal(result.aborted, true);
assert.equal(f.children.length, 1);
assert.equal(alive(f.children[0].pid), false);
} finally {
await f.close();
}
});
test("slow response consumer cannot retain a flooding build subprocess", async () => {
const f = await fixture({ terminateGraceMs: 50 });
try {
const response = await f.post([f.command({ VITE_FIXTURE_MODE: "flood" })]);
await waitFor(() => f.children.length === 1 && !alive(f.children[0].pid));
await response.body.cancel().catch(() => {});
const next = await f.post();
assert.equal(next.status, 200);
assert.equal(
JSON.parse((await next.text()).trim().split("\n").at(-1)).result.ok,
true,
);
} finally {
await f.close();
}
});
test("a stalled authenticated body is rejected within the body deadline", async () => {
const f = await fixture({ bodyTimeoutMs: 100 });
try {
const status = await new Promise((resolve, reject) => {
const req = request(
f.url,
{
method: "POST",
headers: {
"x-release-builder-token": token,
"content-length": "100",
},
},
(res) => {
res.resume();
res.once("end", () => resolve(res.statusCode));
},
);
req.once("error", reject);
req.flushHeaders();
});
assert.equal(status, 408);
assert.equal(f.children.length, 0);
} finally {
await f.close();
}
});