19. 命令执行安全与进程隔离
本章解决的问题
child_process 能执行操作系统程序,也把命令注入、越权文件访问、资源耗尽和敏感信息泄漏带进了应用边界。参数数组能显著降低 Shell 注入风险,但它不是完整安全方案。
最危险的写法
import { exec } from "node:child_process";
exec(`python analyze.py ${request.body.inputPath}`);
当用户输入被拼进 Shell 命令时,Shell 会解释引号、分号、管道、重定向和命令替换。攻击者可能追加其他命令。Windows cmd.exe、PowerShell 和 Bash 的元字符不同,手工转义很难长期正确。
推荐:程序和参数分离
import * as path from "node:path";
import { spawn } from "node:child_process";
const inputRoot = path.resolve("data/uploads");
const requestedPath = path.resolve(inputRoot, request.body.fileName);
const relativePath = path.relative(inputRoot, requestedPath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
throw new Error("输入路径越出允许目录");
}
const child = spawn("python", [
"scripts/analyze.py",
"--input",
requestedPath,
], {
shell: false,
cwd: path.resolve("runtime/analyzer"),
env: {
PATH: process.env.PATH ?? "",
LANG: "C.UTF-8",
},
stdio: ["ignore", "pipe", "pipe"],
});
spawn(command, args, { shell: false }) 不让 Shell 重新解释普通参数,因此文件名即使包含空格,也作为一个参数传递。不要为了“命令能跑”随意设置 shell: true。
参数数组不能解决什么
即使不经过 Shell,仍需防范:
- 用户选择任意可执行文件;
../路径穿越、符号链接绕过;- 工具自身的危险选项,例如覆盖任意输出;
- 读取服务账号有权限访问的敏感文件;
- 超大输入、无限输出、CPU/内存/磁盘耗尽;
- 第三方工具或解析器漏洞;
- 将密码、Token 和完整环境变量传入子进程。
命令和允许的选项应由服务端白名单决定,用户只提供经过验证的业务值。
路径校验的边界
path.relative() 检查能防止普通路径越界,但高风险场景还要考虑符号链接和检查后替换(TOCTOU)。可采用:
- 上传后由服务端生成随机文件名,不使用用户原始路径;
- 使用专用任务目录和最小权限账号;
- 必要时解析真实路径并验证仍位于允许根目录;
- 输出先写临时文件,再原子移动到受控位置;
- 容器或沙箱隔离不可信程序。
环境变量最小化
const childEnvironment: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
LANG: process.env.LANG ?? "C.UTF-8",
ANALYSIS_MODE: "safe",
};
不要默认传递包含数据库密码、云密钥、JWT Secret 的完整 process.env。同时注意 PATH 会影响实际执行到哪个程序;生产中可使用经过部署验证的绝对可执行文件路径,并记录工具版本。
输出、错误和资源上限
let stderrBytes = 0;
const maxStderrBytes = 1024 * 1024;
child.stderr.on("data", (chunk: Buffer) => {
stderrBytes += chunk.length;
if (stderrBytes <= maxStderrBytes) {
process.stderr.write(chunk);
}
});
还应同时限制:输入大小、并发子进程数、运行时间、stdout/stderr 保存量、临时目录总量和结果文件大小。Node.js 的超时与 kill() 只是应用层控制,强隔离需要操作系统用户、容器、cgroup、作业系统等协助。
stderr 可能暴露服务器路径、命令参数和数据内容。日志中应记录经过脱敏的工具名、任务 ID、退出码、Signal 和耗时,不应把子进程原始错误完整返回给终端用户。
安全执行时间线
接收业务参数
→ Schema 校验与大小限制
→ 服务端选择命令/允许参数
→ 创建受控任务目录
→ spawn(shell: false, env 白名单)
→ 流式消费受限输出
→ 超时/取消时终止并等待 close
→ 验证退出码与结果文件
→ 原子发布结果
→ 清理临时目录
任何一步失败都要进入有界清理,不能只返回 HTTP 错误而留下子进程继续运行。
可复现性也是生产安全
调用 Python、R、FFmpeg、samtools、bcftools 等程序时建议记录:
- 工具名称和版本;
- 服务端生成的参数快照(敏感值脱敏);
- 脚本或镜像版本;
- 参考数据版本与校验值;
- 开始/结束时间、退出码和 Signal;
- 输入输出文件校验值(适用时)。
这能帮助判断“同一任务为什么得到不同结果”,也有利于审计和故障复盘。
跨平台注意事项
- Bash、PowerShell、
cmd.exe使用不同转义规则,不应共用字符串拼命令函数。 - Windows 可执行文件可能带
.exe,Linux 脚本需要 Shebang 和执行权限。 - 路径大小写、分隔符、符号链接和文件删除语义不同。
- Signal 和进程树终止方式不同;必须在部署目标平台测试超时清理。
常见错误
exec()拼接用户输入,或以为手工加引号就永远安全。spawn(..., { shell: true })后仍认为参数数组完全隔离 Shell。- 允许请求直接决定 command、cwd、env 或输出路径。
- 只检查扩展名,不检查真实内容、大小和目录边界。
- 无上限累计 stdout/stderr。
- Node.js 进程以 root/管理员权限运行第三方脚本。
- 子进程失败后把完整 stderr 返回给客户端。
练习
- 找出
exec(\convert ${fileName} output.pdf`)` 的风险,并改写为参数数组。 - 编写函数,验证解析后的输入路径必须位于指定根目录内。
- 为图片转换任务列出命令、参数、目录、环境、时间、并发和输出限制。
- 模拟子进程不断输出 stderr,验证日志保存量受到限制但流仍被消费。
验收清单
- [ ] 不把用户输入拼入 Shell 命令。
- [ ] 命令、参数、cwd 和 env 都由服务端白名单控制。
- [ ] 输入输出路径有目录边界检查和唯一任务目录。
- [ ] 子进程具有超时、并发、输出和磁盘限制。
- [ ] 失败后等待
close并清理临时资源。 - [ ] 日志和客户端错误不会泄露 Secret 或敏感数据。