Node.js 使用 spawn 执行部署命令时,如何防止命令注入和恶意代码?
必须先移除 shell: true,使用后端控制的可执行文件和参数数组,并将用户项目的安装、构建放进受限制的独立执行环境。仅修改 spawn 参数无法阻止仓库或依赖中的恶意代码。worker_threads 可以组织任务、传递消息,但不能用作安全沙箱。
当前代码为什么存在命令注入风险?
风险出在下面的调用中:
const [bin, ...args] = cmd.split(" ");
const child = spawn(bin, args, { cwd, shell: true });
启用 shell: true 后,命令会交给 shell 处理。用户输入中的 &&、;、管道、重定向或命令替换等内容可能被解释为额外操作,具体语法取决于使用的 shell。
cmd.split(" ") 既不能消除这些风险,也不能正确解析引号、转义字符和包含空格的参数,因此不能作为可靠的命令解析器。
需要检查的还有 project.build_command 之外的输入。当前 Git 命令直接插入了 project.branch、project.repo_url 和 project.local_path,这些值同样可能影响命令执行。数据存进数据库后,也不能直接当作可信输入使用。
如何修改命令执行方式?
如果平台只需要支持 npm run build,可以让用户在前端选择这一构建方式,由后端将其映射到固定参数。不要让用户决定可执行文件路径,也不要将整段输入重新交给 shell。
下面的示例假设构建环境为 Linux,Node.js 和 npm 工具链由平台安装并保护,工作目录已经由服务端创建和校验。NPM_CLI 是示例路径,需要替换为实际的 npm CLI 绝对路径。
import { spawn } from "node:child_process";
const NPM_CLI = "/opt/build-tools/npm/bin/npm-cli.js";
const buildCommands = new Map<string, readonly string[]>([
["npm run build", ["run", "build"]],
]);
function runNpm(
args: readonly string[],
workspace: string,
onLog: (message: string) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [NPM_CLI, ...args], {
cwd: workspace,
shell: false,
env: {
PATH: "/opt/build-tools/bin:/usr/bin:/bin",
HOME: "/tmp/build-home",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (data: Buffer) => {
onLog(data.toString());
});
child.stderr.on("data", (data: Buffer) => {
onLog(data.toString());
});
child.once("error", reject);
child.once("close", (code, signal) => {
if (code === 0) {
resolve();
} else {
reject(new Error(
`Build command failed: code=${code}, signal=${signal}`,
));
}
});
});
}
async function buildProject(
buildCommand: string,
workspace: string,
onLog: (message: string) => void,
): Promise<void> {
const args = buildCommands.get(buildCommand);
if (!args) {
throw new Error("Unsupported build command");
}
// Requires a compatible package-lock.json or npm-shrinkwrap.json.
await runNpm(["ci"], workspace, onLog);
await runNpm(args, workspace, onLog);
}
示例使用可信的 Node.js 可执行文件直接运行 npm CLI,避免依赖用户指定的程序路径。平台也应按需提供环境变量,避免将后端完整的 process.env 传给构建进程。示例中的 HOME 目录需要在每个隔离任务内创建,项目需要的其他环境变量也应单独授权。
npm ci 需要兼容的锁文件;没有锁文件时,应明确选择平台支持的安装策略。它按锁文件安装依赖,不负责验证依赖是否安全。
这段代码只说明调用方式,实际执行仍须放在隔离环境内,并补上资源限制和日志限额。
为什么只允许 npm run build 仍然不安全?
npm run build 最终执行的是仓库 package.json 中定义的脚本。用户可以把脚本写成任何具有当前进程权限的操作。构建工具加载的配置文件、插件和依赖也可能运行代码。
安装阶段同样可能执行代码:npm install 和 npm ci 可能触发依赖的生命周期脚本。即使前端完全不提供命令输入框,构建不可信仓库仍意味着执行不可信代码。
可以评估在安装阶段使用 --ignore-scripts,但某些依赖需要安装脚本才能正常工作。这个选项也不会让显式执行的构建脚本变成可信代码。
命令白名单用于限制平台暴露的操作范围,执行环境则负责限制恶意代码能够接触的资源。
构建任务应该放在哪里运行?
每次部署都应使用独立的构建环境,例如经过限制的容器或虚拟机。对开放给陌生用户的多租户平台,需要结合威胁模型评估隔离强度。容器共享宿主机内核,不能仅凭使用了容器就认定隔离充分。
构建环境至少应具备以下限制:
- 使用非 root 用户,不启用特权模式,不挂载 Docker socket、宿主机敏感目录或其他租户目录。
- 每次部署使用独立工作目录,只开放必要的源码、临时目录和产物目录。
- 不提供后端数据库凭据、平台密钥或完整服务器环境变量。仓库拉取凭据应尽量限制在拉取阶段使用。
- 设置 CPU、内存、进程数量、磁盘、日志量和执行时间上限。
- 按任务需要控制出站网络,限制访问宿主机服务、内部数据库、云元数据服务和其他内部地址。
- 任务结束或超时后,清理整个执行环境及其子进程。
child.kill() 只针对指定进程,不能假设它会清理所有后代进程。构建脚本可能继续派生进程,因此超时处理最好由容器、虚拟机或其他进程管理边界统一完成。
Git 参数和目录路径还要检查什么?
Git 调用也应使用固定可执行文件和参数数组,并关闭 shell。参数数组可以避免 shell 解释,但仍需防范 Git 自身的选项和地址语义。
具体应检查:
- 仓库来源和协议。当前场景使用 GitHub OAuth,可按产品规则限制到获准的 GitHub 仓库,避免接受任意本地路径、协议或内部地址。使用短期凭据时,也要避免它们进入日志或留在构建可读的配置中。
- 分支和其他参数。校验格式,拒绝不符合平台规则的值;在 Git 支持的位置使用
--分隔选项和位置参数。不要只删除几个 shell 特殊字符。 - 本地工作目录。由服务端根据租户和部署 ID 生成,不允许用户填写任意宿主机路径。
- 构建产物目录。只接受工作目录内的相对路径,拒绝绝对路径和越界路径;读取、复制产物时还要处理符号链接,避免访问目录外的文件。
尤其要收紧这一行:
fs.rmSync(project.local_path, { recursive: true, force: true });
关闭 shell 不会影响 fs.rmSync 的权限。如果路径来源不可信,它依然可能删除服务器上的其他目录。更稳妥的做法是为每次构建创建新的专用工作目录,清理时只针对平台记录的任务目录。
worker_threads 的使用方式是否合适?
可以通过 workerData 传递项目数据和字符串形式的部署 ID,再通过 parentPort.postMessage() 返回日志及状态。应只传递任务实际需要的数据,并校验接收到的消息结构。
Worker 线程没有独立的操作系统权限边界,不能限制构建子进程对文件、网络和凭据的访问。spawn 本身已经是异步 API,也不需要为了避免等待外部进程阻塞事件循环而专门使用 Worker。
如果部署需要在 Web 服务重启后继续执行,应将部署记录持久化,并使用任务队列交给独立构建执行器处理。执行器管理隔离环境,Web 服务负责提交任务和展示状态。
Controller 中还需要补哪些处理?
当前代码还有几处问题会影响任务状态和服务稳定性:
worker.on("error")只广播失败,没有更新数据库,部署记录可能一直停留在running。还要处理exit:如果 Worker 退出时尚未记录终态,应补记失败。worker.on("message", async (...) => ...)返回的 Promise 不会被事件发射器自动等待。数据库写入失败时,需要在回调内显式捕获,并纳入任务失败或重试流程。- 成功、失败、超时和异常退出可能竞争更新状态。应使用条件更新等方式,让终态写入保持幂等。
- 父线程已持有
deployment_id,数据库更新应绑定这个任务上下文,而不是只检查消息中的 ID 是否符合 ObjectID 格式。 logs会持续占用 Worker 内存,消息还会逐条触发数据库写入。需要限制日志总量、单条长度和写入速度,并考虑批量写入。输出流的一个数据块不一定是一整行,按行记录时要缓存未结束的行。- 项目访问权限需要明确校验。
ObjectId.isValid()只能验证 ID 格式;租户数据库隔离之外,是否还需要项目级权限取决于业务模型。
另外,Promise.all 数组中的第二个操作前多写了一个 await,会让该操作先等待完成再进入 Promise.all。可以移除这个内部 await。如果 last_deployed_at 表示最近一次成功部署时间,应只在成功状态下更新。
优先处理 shell: true、任意目录删除和构建环境隔离,再完善 Worker 的状态收敛、日志限额及任务恢复机制。
备注:内容仅供参考。
