내부 builder 인증과 실행 허용 목록 및 자원 경계를 적용한다
This commit is contained in:
1 parent
6d05c01a76
commit
f78ab77850
13 files changed
+979
-205
No files matched your search
@@ -0,0 +1,223 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user