PAYATHON 2026

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 自身的选项和地址语义。

具体应检查:

  1. 仓库来源和协议。当前场景使用 GitHub OAuth,可按产品规则限制到获准的 GitHub 仓库,避免接受任意本地路径、协议或内部地址。使用短期凭据时,也要避免它们进入日志或留在构建可读的配置中。
  2. 分支和其他参数。校验格式,拒绝不符合平台规则的值;在 Git 支持的位置使用 -- 分隔选项和位置参数。不要只删除几个 shell 特殊字符。
  3. 本地工作目录。由服务端根据租户和部署 ID 生成,不允许用户填写任意宿主机路径。
  4. 构建产物目录。只接受工作目录内的相对路径,拒绝绝对路径和越界路径;读取、复制产物时还要处理符号链接,避免访问目录外的文件。

尤其要收紧这一行:

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 的状态收敛、日志限额及任务恢复机制。

备注:内容仅供参考。