先记住这个答案
execFile 默认直接启动指定可执行文件,将参数数组交给程序,不先把整段字符串交给 shell 解析,因此分号、管道和命令替换等字符不会仅因出现于参数中就触发另一条 shell 命令。这个结论以没有启用 shell、执行文件受控为前提。目标程序仍可能把参数识别为危险选项、脚本或文件路径,所以还要限制可执行文件、校验参数语义和访问范围,并管理权限、输出及运行时限。execFile 是更清晰的执行边界,不是任意输入的安全证明。
- 默认直接执行,避免 shell 重新解释参数
- 目标程序仍会解析自己的选项和输入
- shell:true 会重新引入 shell 语义
直接传参保留的是参数边界
对于默认 execFile,一个包含空格或分号的字符串可以作为单独参数进入目标程序,不需要先拼成整条命令再手工拆分。这样减少了平台相关转义错误,也更容易审核哪个值对应哪个参数位置。
不要把执行文件本身交给用户随意选择,也不要为了方便管道而临时开启 shell 后继续套用无 shell 的安全结论。执行方式一变,元字符的解释边界也变了,需要重新审查命令与输入的关系。
用固定程序验证元字符只是文本
示例运行当前 Node 可执行文件,执行代码是固定字符串,用户值只作为参数传给回显程序。双横线终止 Node 的选项解析,让以短横线开头的演示值也按参数传入;目标程序是否支持这种分隔符要单独核对。
调用者可以传入包含分号、美元符号或空格的短字符串,核对返回值逐字相同。这个实验只证明该调用没有让 shell 解释文本,不证明把同样的值传给任意下载器、压缩工具或脚本解释器都安全。
import { execFile } from 'node:child_process';
export function echoArgument(value: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(process.execPath, [
'-e', 'process.stdout.write(process.argv[1])', '--', value
], { shell: false, encoding: 'utf8', maxBuffer: 4096, timeout: 2000 },
(error, stdout) => {
if (error) reject(error);
else resolve(stdout);
});
});
}演示值进入 argv,不进入固定的执行代码。即使回显通过,真实业务仍应限制长度并验证目标程序接受的参数形式;输出上限和超时用于约束这个小实验,也不是完整的进程隔离方案。
程序选项、文件路径和平台仍需审核
用户控制的字符串若被目标程序解释为覆盖输出文件、加载配置或执行脚本的选项,仍可能造成危险行为。需要按该程序的参数协议设置允许名单,必要时使用其支持的选项结束标记,并验证文件访问范围。
Windows 的批处理脚本与普通可执行文件不同,不能假定所有文件都可按同一种无 shell 方式启动。应明确实际解释器与参数协议;跨平台代码不能依赖一种终端的引号规则在其他系统上也成立。
容易答错的地方
- 把 JSON.stringify 当 shell 转义
- JSON 字符串编码解决的是 JSON 语法,不是各种 shell 的解释规则。拼接它的输出仍可能保留会被 shell 执行的语法,优先采用受控可执行文件和参数数组,避免在两个语法层之间反复转换。
- 认为没有 shell 就可以传任意程序选项
- 目标程序自己的选项可能具有写文件、加载插件或执行代码能力。应验证参数的业务含义和调用权限,不能因为分号没有被解释,就宣称整条外部程序调用已经安全。
面试官还会怎么问?
spawn 默认无 shell 时是否有类似优势?
有,默认 spawn 也把程序和参数分开。两者主要还在输出收集便利性等方面不同;一旦显式开启 shell,就要重新考虑解释规则,不能只看使用的函数名称。
用双横线就能防止所有参数注入吗?
不能。只有目标程序支持并在对应位置把它作为选项结束标记时才有效,而且结束选项解析后,文件路径或数据内容本身仍可能有风险。需要阅读目标程序的协议并验证真实调用。
服务端执行外部工具还需要哪些限制?
围绕实际工具配置最小权限、允许的输入路径、并发数、输出预算和取消回收策略。对于不可信任务还需要隔离环境;这些措施与是否经过 shell 是互补关系,不能由一个 API 选择全部代替。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。