302 lines
9.8 KiB
JavaScript
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();
|
|
}
|
|
});
|