내부 builder 인증과 실행 허용 목록 및 자원 경계를 적용한다
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { validateComposeModel } from '../runtime/validate-compose-model.mjs';
|
||||
const safeRuntime = {
|
||||
environment: {
|
||||
CORE_SOURCE_MODE: 'clone',
|
||||
RELEASE_BUILDER_TOKEN_FILE: '/run/secrets/release_builder_token',
|
||||
NODE_OPTIONS: '--max-old-space-size=1536',
|
||||
TURN_DAEMON_NODE_OPTIONS: '--max-old-space-size=3072',
|
||||
RAYON_NUM_THREADS: '2',
|
||||
@@ -28,6 +29,7 @@ const safeRuntime = {
|
||||
GATEWAY_ACTIVE_RELEASE_GIT_REF: 'refs/sammo/active-gateway',
|
||||
},
|
||||
volumes: [
|
||||
{ type: 'bind', source: '/secrets/release_builder_token', target: '/run/secrets/release_builder_token', read_only: true },
|
||||
{
|
||||
type: 'bind',
|
||||
source: '/secrets/image_upload_core2026_secret',
|
||||
@@ -61,7 +63,9 @@ const safeRuntime = {
|
||||
};
|
||||
|
||||
const safeBuilder = {
|
||||
volumes: [{ type: 'bind', source: '/secrets/release_builder_token', target: '/run/secrets/release_builder_token', read_only: true }],
|
||||
environment: {
|
||||
RELEASE_BUILDER_TOKEN_FILE: '/run/secrets/release_builder_token',
|
||||
NODE_OPTIONS: '--max-old-space-size=3072',
|
||||
RAYON_NUM_THREADS: '2',
|
||||
},
|
||||
@@ -145,7 +149,8 @@ test('rejects missing or excessive build memory and parallelism limits', () => {
|
||||
...safeRuntime,
|
||||
environment: {
|
||||
CORE_SOURCE_MODE: 'clone',
|
||||
NODE_OPTIONS: '--max-old-space-size=4096',
|
||||
RELEASE_BUILDER_TOKEN_FILE: '/run/secrets/release_builder_token',
|
||||
NODE_OPTIONS: '--max-old-space-size=4096',
|
||||
TURN_DAEMON_NODE_OPTIONS: '--max-old-space-size=8192',
|
||||
RAYON_NUM_THREADS: '8',
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ const validEnv = {
|
||||
INITIAL_ADMIN_USERNAME: 'admin',
|
||||
INITIAL_ADMIN_PASSWORD: 'a'.repeat(16),
|
||||
KAKAO_REST_KEY: 'kakao-test-key',
|
||||
RELEASE_BUILDER_TOKEN_FILE: '/secrets/release_builder_token',
|
||||
};
|
||||
|
||||
test('accepts a complete public repository environment', () => {
|
||||
|
||||
Reference in New Issue
Block a user