Node.js中Spawn子进程恶意命令注入防护及Worker使用疑问
问题描述
我基于Next.js和Node.js开发类似Vercel的项目部署平台,已完成GitHub OAuth、代码拉取与运行等功能。目前前端支持用户输入构建命令(如npm run build)、构建目录(如dist)等配置,后端用Node.js的spawn结合worker_threads实现异步部署处理。现咨询两个问题:
- 如何防止后端执行的命令被恶意注入?
- 部署启动后通过
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
相关产品推荐
相关产品推荐

