Files
core2026_docker/runtime/release-builder-security.mjs
T

224 lines
6.7 KiB
JavaScript

import { readFileSync, realpathSync, statSync } from "node:fs";
import path from "node:path";
export const MAX_BUILD_FRAME_BYTES = 512 * 1024;
export const BUILD_TIMEOUT_MS = 30 * 60 * 1000;
const packages = new Set(
[
"common",
"infra",
"logic",
"game-engine",
"game-api",
"game-frontend",
"gateway-api",
"gateway-frontend",
"release-controller",
].map((name) => `@sammo-ts/${name}`),
);
const within = (candidate, root) => {
const relative = path.relative(root, candidate);
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
};
export const readBuilderToken = (filename) => {
try {
if (
!filename ||
!statSync(filename).isFile() ||
statSync(filename).size > 256
)
throw new Error();
const token = readFileSync(filename, "utf8").trim();
if (
token.startsWith("replace-with-") ||
!/^[A-Za-z0-9_-]{43,128}$/.test(token)
)
throw new Error();
return token;
} catch {
throw new Error("A valid RELEASE_BUILDER_TOKEN_FILE is required.");
}
};
const statExists = (filename) => {
try {
statSync(filename);
return true;
} catch {
return false;
}
};
const safeCachePath = (value, roots) => {
if (!path.isAbsolute(value) || value.length > 2048)
throw new Error("Invalid build cache path.");
const resolved = path.resolve(value);
if (!roots.some((root) => within(resolved, root)))
throw new Error("Build cache is outside shared storage.");
let existing = resolved;
while (!statExists(existing)) existing = path.dirname(existing);
if (!roots.some((root) => within(realpathSync(existing), root)))
throw new Error("Build cache symlink leaves shared storage.");
return resolved;
};
const safeEnv = (env, roots) => {
if (
!env ||
typeof env !== "object" ||
Array.isArray(env) ||
Object.keys(env).length > 32
)
throw new Error("Invalid build environment.");
const result = {};
for (const [name, value] of Object.entries(env)) {
if (
typeof value !== "string" ||
value.length > 8192 ||
value.includes("\0")
)
throw new Error("Invalid build environment.");
if (
/^VITE_[A-Z0-9_]+$/.test(name) ||
name === "TZ" ||
(name === "CI" && ["true", "false", "1", "0"].includes(value))
)
result[name] = value;
else if (
["NODE_OPTIONS", "PROFILE_FRONTEND_BUILD_NODE_OPTIONS"].includes(name)
) {
const heap = /^--max-old-space-size=(\d+)$/.exec(value);
if (!heap || Number(heap[1]) < 512 || Number(heap[1]) > 3584)
throw new Error("Invalid build heap control.");
result[name] = value;
} else if (
["RAYON_NUM_THREADS", "RELEASE_TURBO_CONCURRENCY"].includes(name)
) {
if (!/^[1-4]$/.test(value))
throw new Error("Invalid build concurrency control.");
result[name] = value;
} else if (name === "TURBO_CACHE_DIR")
result[name] = safeCachePath(value, roots);
else if (name !== "PATH")
throw new Error("Unsupported build environment variable.");
}
return result;
};
export const validateBuildCommand = (raw, allowedRoots, cacheRoots) => {
if (
!raw ||
typeof raw !== "object" ||
Array.isArray(raw) ||
!Array.isArray(raw.args) ||
raw.args.length > 40 ||
typeof raw.cwd !== "string" ||
raw.cwd.length > 2048 ||
!path.isAbsolute(raw.cwd) ||
raw.args.some(
(arg) =>
typeof arg !== "string" || arg.length > 2048 || arg.includes("\0"),
)
)
throw new Error("Invalid build command.");
const cwd = realpathSync(raw.cwd);
if (!allowedRoots.some((root) => within(cwd, root)))
throw new Error("Build cwd is outside shared storage.");
const args = raw.args;
const install =
args.length === 2 &&
args[0] === "install" &&
args[1] === "--frozen-lockfile";
const packageTask =
args.length === 3 &&
args[0] === "--filter" &&
packages.has(args[1]) &&
(args[2] === "build" ||
(args[1] === "@sammo-ts/infra" && args[2] === "prisma:generate"));
let turbo = false;
if (
args[0] === "exec" &&
args[1] === "turbo" &&
args[2] === "run" &&
["build", "build:release"].includes(args[3])
) {
const flags = args.slice(4);
const seen = new Set();
let filters = 0;
turbo =
flags.every((flag) => {
if (flag.startsWith("--filter=")) {
filters += 1;
return packages.has(flag.slice(9));
}
const name = flag.split("=")[0];
if (seen.has(name)) return false;
seen.add(name);
if (flag.startsWith("--cache-dir=")) {
safeCachePath(flag.slice(12), cacheRoots);
return true;
}
return (
/^--concurrency=[1-4]$/.test(flag) ||
flag === "--ui=stream" ||
flag === "--output-logs=new-only"
);
}) && filters > 0;
}
const materialize =
args.length === 2 &&
args[0] === "tools/build-scripts/materialize-profile-frontend.mjs" &&
/^(?:che|kwe|pwe|twe|nya|pya|hwe):[A-Za-z0-9_.-]{1,64}$/.test(args[1]);
if (!(
(raw.command === "pnpm" && (install || packageTask || turbo)) ||
(raw.command === "node" && materialize)
))
throw new Error("Unsupported release build command.");
return {
command: raw.command,
args,
cwd,
env: safeEnv(raw.env ?? {}, cacheRoots),
};
};
export async function* readBuilderMessages(response) {
if (!response.ok || !response.body) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`Release builder returned HTTP ${response.status}.`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8", { fatal: true });
let pending = "";
let bytes = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > 64 * 1024 * 1024)
throw new Error("Release builder stream is too large.");
pending += decoder.decode(chunk.value, { stream: true });
let newline;
while ((newline = pending.indexOf("\n")) >= 0) {
const line = pending.slice(0, newline);
pending = pending.slice(newline + 1);
if (Buffer.byteLength(line) > MAX_BUILD_FRAME_BYTES)
throw new Error("Release builder frame is too large.");
if (line.trim()) yield JSON.parse(line);
}
if (Buffer.byteLength(pending) > MAX_BUILD_FRAME_BYTES)
throw new Error("Release builder frame is too large.");
}
pending += decoder.decode();
if (Buffer.byteLength(pending) > MAX_BUILD_FRAME_BYTES)
throw new Error("Release builder frame is too large.");
if (pending.trim()) yield JSON.parse(pending);
} finally {
await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
}