You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Node.js中Spawn子进程恶意命令注入防护及Worker使用疑问

问题描述

我基于Next.js和Node.js开发类似Vercel的项目部署平台,已完成GitHub OAuth、代码拉取与运行等功能。目前前端支持用户输入构建命令(如npm run build)、构建目录(如dist)等配置,后端用Node.js的spawn结合worker_threads实现异步部署处理。现咨询两个问题:

  1. 如何防止后端执行的命令被恶意注入?
  2. 部署启动后通过workerData传递deployment ObjectID给Worker,再通过parentPort返回结果的方式是否可行?

Worker文件(使用spawn)

import { parentPort, workerData } from "worker_threads";
import { spawn } from "child_process";
import fs from "fs";

const { project , deployment_id } = workerData;

const logs: { message: string; timestamp: Date }[] = [];

function log(message: string) {
  const entry = { message, timestamp: new Date() };
  logs.push(entry);
  parentPort?.postMessage({
    type: "log",
    message,
    deploy_target: project.deploy_target,
    project_name: project.project_name,
    deployment_id ,
    deployed_at : new Date()
  });
}

function run(cmd: string, cwd: string = project.local_path): Promise<void> {
  return new Promise((resolve, reject) => {
    const [bin, ...args] = cmd.split(" ");
    const child = spawn(bin, args, { cwd, shell: true });

    child.stdout.on("data", (data: Buffer) => {
      data
        .toString()
        .split("\n")
        .filter((line: string) => line.trim())
        .forEach((line: string) => log(line));
    });

    child.stderr.on("data", (data: Buffer) => {
      data
        .toString()
        .split("\n")
        .filter((line: string) => line.trim())
        .forEach((line: string) => log(line));
    });

    child.on("close", (code: number) => {
      if (code === 0) {
        resolve();
      } else {
        reject(new Error(`Command "${cmd}" exited with code ${code}`));
      }
    });

    child.on("error", reject);
  });
}

const started_at = Date.now();

async function deploy() {
  const exists = fs.existsSync(project.local_path);

  if (!exists) {
    log("Cloning repository...");
    await run(
      `git clone -b ${project.branch} ${project.repo_url} ${project.local_path}`,
      process.cwd()
    );
    log("Cloning completed!");
  } else {
    const is_git_repo = fs.existsSync(`${project.local_path}/.git`);

    if (is_git_repo) {
      log("Pulling latest code...");
      await run(`git pull origin ${project.branch}`);
      log("Pull completed!");
    } else {
      log("Directory exists but is not a git repo, replacing...");
      fs.rmSync(project.local_path, { recursive: true, force: true });
      log("Cloning repository...");
      await run(
        `git clone -b ${project.branch} ${project.repo_url} ${project.local_path}`,
        process.cwd()
      );
      log("Cloning completed!");
    }
  }

  log("Installing dependencies...");
  await run(`npm install`);
  log("Dependencies installed!");

  if (project.build_command) {
    log("Building project...");
    await run(project.build_command);
    log("Build completed!");
  }

  parentPort?.postMessage({
    type: "status",
    status: "success",
    message: "Deploy complete!",
    completed: true,
    failed: false,
    deploy_target: project.deploy_target,
    project_name: project.project_name,
    logs,
    duration_seconds: Math.floor((Date.now() - started_at) / 1000),
    deployment_id,
    deployed_at : new Date()
  });
}

deploy().catch((e: any) => {
  parentPort?.postMessage({
    type: "status",
    status: "failed",
    message: e.message,
    completed: false,
    failed: true,
    deploy_target: project.deploy_target,
    project_name: project.project_name,
    logs,
    deployed_at : new Date(),
    deployment_id,
    duration_seconds: Math.floor((Date.now() - started_at) / 1000),
  });
});

调用Worker的Node控制器文件

export async function DeployProject(req: Request, res: Response) {
  try {
    const { id } = req.params as { id: string };
    if (!id || !ObjectId.isValid(id)) {
      return res.status(400).json({
        msg: "Invalid Data",
      });
    }

    const tenant_key = `tenant_${(req as any).user.tenant_id}`;
    const user_id = (req as any).user.user_id;
    if (!user_id || !ObjectId.isValid(user_id)) {
      return res.status(401).json({
        msg: "UnAuthorized",
      });
    }
    const db = await getDb(tenant_key);
    const project = await db
      .collection("projects")
      .findOne({ _id: new ObjectId(id) });


    if (!project || Number(project.dels) === 1) {
      return res.status(404).json({ msg: "Project not found" });
    }

    const isDev = process.env.NODE_ENV !== "production";

    const tenantDb = await createTenantDeployLogs((req as any).user.tenant_id);
    const deployment = await tenantDb.collection("deployments").insertOne({
      fk_project_id: new ObjectId(id),
      project_name: project.project_name,
      deploy_target: project.deploy_target,
      status: "running",
      deployed_at: new Date(),
      duration_seconds: null,
      dels: 0
    });

    if (!deployment || !deployment.insertedId) {
      return res.status(400).json({
        msg: "Failed to Deploy",
      });
    }

    const deployment_id = deployment.insertedId.toString();

    const worker_path = isDev
      ? path.join(process.cwd(), "workers/deploy.worker.ts")
      : path.join(process.cwd(), "dist/workers/deploy.worker.js");
    const worker = new Worker(worker_path, {
      execArgv: isDev ? ["--import", "tsx"] : [],
      workerData: { project, deployment_id },
    });
    worker.on("message", async (data) => {
      broadcast(user_id, data);

      if (
        data.type === "log" &&
        data.deployment_id &&
        ObjectId.isValid(data.deployment_id)
      ) {
        await tenantDb.collection("deploy_logs").insertOne({
          deployed_at: new Date(),

          meta: {
            fk_deployment_id: new ObjectId(data.deployment_id),
            project_name: project.project_name,
            deploy_target: project.deploy_target,
          },

          message: data.message,
        });
      }

      if (
        data.type === "status" &&
        data.deployment_id &&
        ObjectId.isValid(data.deployment_id)
      ) {

        await Promise.all([

          tenantDb.collection("deployments").updateOne(
            {
              _id: new ObjectId(data.deployment_id),
            },
            {
              $set: {
                status: data.status,
                duration_seconds: data.duration_seconds,

              },
            },
          ),
          await db
            .collection("projects")
            .updateOne({ _id: new ObjectId(id) }, {
              $set: {
                last_deployed_at: new Date()
              }
            })
        ])


      }
    });
    worker.on("error", (e: any) =>
      broadcast(user_id, {
        type: "status",
        status: "failed",
        message: e.message,
      }),
    );

    return res.status(200).json({
      msg: "Project Deployment Started...",
      success: true,
    });
  } catch (e) {
    return res.status(500).json({
      msg: "Internal server error",
    });
  }
}

问题解答

1. 防止命令注入的解决方案

当前代码中run函数启用了shell: true,且直接用字符串拆分命令,存在严重的命令注入风险,需从以下层面修复:

核心措施:禁用Shell执行

将spawn的shell选项设为false(默认值),直接执行二进制文件而非通过shell解析命令,从根源避免注入。同时要将命令拆分为二进制路径+参数数组的形式,而非拼接字符串:

// 改进后的run函数
function run(bin: string, args: string[], cwd: string = project.local_path): Promise<void> {
  return new Promise((resolve, reject) => {
    // 白名单限制允许执行的二进制文件
    const allowedBins = new Set(["npm", "yarn", "pnpm", "git"]);
    if (!allowedBins.has(bin)) {
      return reject(new Error(`命令 ${bin} 不被允许`));
    }

    const child = spawn(bin, args, { cwd, shell: false });

    // 日志处理逻辑不变...
  });
}

// 使用示例
await run("git", ["clone", "-b", project.branch, project.repo_url, project.local_path], process.cwd());
await run("npm", ["install"]);

辅助防护措施

  • 严格校验用户输入:
    • 限制构建命令格式,仅允许[包管理器] run [脚本名]的结构,禁止包含|、>、&&、||等shell特殊字符;
    • 禁止用户输入绝对路径的命令(如/bin/bash、rm)。
  • 正确解析命令参数:使用shell-quote等库拆分用户输入的命令(避免手动split(" ")破坏带空格的参数):
    import { parse } from 'shell-quote';
    if (project.build_command) {
      const [bin, ...args] = parse(project.build_command);
      await run(bin as string, args as string[]);
    }
    
  • 沙箱隔离:用Docker容器运行部署任务,每个任务在独立容器中执行,即使被注入也不会影响主机系统;若不用Docker,需用非root用户执行命令,并限制文件系统访问范围。

2. 传递deployment ObjectID的方案可行性

这种方式完全可行,且是合理的实现方案,仅需优化几个细节:

可行性确认

通过workerData传递deployment_id是Worker Threads的标准用法,Worker启动时即可获取ID,后续通过parentPort.postMessage携带ID返回日志和状态,控制器根据ID更新对应部署记录,流程逻辑通顺。

优化建议

  • 校验返回的ID一致性:在控制器中验证Worker返回的deployment_id是否与当前任务的ID一致,避免多Worker并发时更新错误记录;
  • 补全错误处理:Worker抛出错误时,除了广播消息,还要更新对应部署记录的状态为失败:
    worker.on("error", async (e: any) => {
      const errorMsg = {
        type: "status",
        status: "failed",
        message: e.message,
        deployment_id
      };
      broadcast(user_id, errorMsg);
      // 更新部署记录
      await tenantDb.collection("deployments").updateOne(
        { _id: new ObjectId(deployment_id) },
        { $set: { status: "failed", duration_seconds: Math.floor((Date.now() - Date.now()) / 1000) } }
      );
      worker.terminate();
    });
    
  • 资源清理:Worker完成任务后(成功/失败),调用worker.terminate()释放资源,避免内存泄漏。

内容的提问来源于stack exchange,提问作者Vicky

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.01 13:32:32