Node.js 安全实践:彻底避免 eval 等字符串代码执行函数(nodebestpractices 指南详解)

原创2026-10-02 17:47:50916 阅读
文章标签:文档教程后端

Node.js 安全实践:彻底避免 eval 等字符串代码执行函数(nodebestpractices 指南详解)

导读

在 Node.js 服务端代码中,eval()、setTimeout()、setInterval() 与 new Function() 这类接受"字符串形式的代码"并直接执行它们的全局函数,是代码注入攻击(Remote Code Execution,RCE)的高危入口。本文基于开源仓库 nodebestpractices 的《Node.js 最佳实践清单》中 sections/security/avoideval.md 一节的完整内容(含日文版 avoideval.japanese.md),系统讲解为什么必须避免这些语法、恶意输入如何导致服务器沦陷、如何用静态检查(ESLint 安全规则)与代码重构来根除风险,并结合仓库内其他安全章节给出可落地的防御方案。读完本文,你将掌握识别高危代码模式、配置自动化检测规则以及设计安全替代实现的完整方法。

eval 及其"同类":四类接受字符串代码的全局函数

Node.js 中经常被误用的全局函数共有四类,它们共同的特点是接受一个字符串参数,该字符串会被解析并当作 JavaScript 表达式、语句或语句序列来执行:

函数 字符串参数含义 典型误用场景
eval(code) 解析并执行任意 JavaScript 表达式/语句序列 解析 JSON(应使用 JSON.parse)、动态拼装表达式
setTimeout(code, delay) 延迟执行一段代码字符串 把回调写成字符串传给定时器
setInterval(code, delay) 周期性执行一段代码字符串 同上
new Function(code) 从字符串构造一个函数体 动态生成函数、模板求值

这些函数在语法层面"合法",但安全隐患在于:一旦不可信的用户输入流入了被执行的字符串,评估用户代码本质上等同于允许攻击者执行你(应用进程)能够执行的任何操作。攻击者输入一段代码字符串,就相当于拿到了 Node.js 进程的完整权限——读取环境变量与文件、调用 child_process 执行系统命令、篡改数据,直至让整个服务器沦陷。

原文档(avoideval.japanese.md)明确指出,建议对代码进行重构,避免依赖那些"用户输入可能被传入并执行"的函数。这是一条需要写进团队规范与代码评审清单的硬性约束。

攻击场景复现:一段字符串如何清空你的服务器

原文档给出了一段极具冲击力的攻击示例——攻击者只需把下面的字符串注入你的应用,恶意代码就会被原样执行:

// 攻击者が入力できる悪意のあるコードの例
// 攻击者可以输入的恶意代码示例
const userInput = "require('child_process').spawn('rm', ['-rf', '/'])";

// 恶意代码被执行
eval(userInput);

这段代码的执行路径是:eval() 将 userInput 字符串当作 JavaScript 代码解析,触发 require('child_process').spawn('rm', ['-rf', '/']),即让 Node.js 进程启动一个子进程执行 rm -rf /。如果承载 eval(userInput) 的服务以 root 权限运行,其结果就是灾难性的数据毁灭。

需要特别强调的是:恶意字符串不一定来自 userInput 这样一个直接变量,它可能藏在 URL 查询参数、请求体、HTTP 头、Cookie、WebSocket 消息、命令行参数,甚至数据库中被攻击者写入的字段里。只要最终拼进 eval 的字符串包含攻击者可控片段,注入就成立。这也是为什么原文档的结论如此斩钉截铁:只要存在"用户输入可能传入并执行"的函数调用,就必须重构。

深层原理:从代码执行到系统级命令注入

eval(userInput) 之所以危险,是因为它的能力半径是整个 Node.js 运行时。攻击者注入的字符串中可以引用任何内置模块,其中最致命的是 child_process。仓库中 childprocesses.md 一节专门警示了未净化的输入进入系统级命令执行的危险性,其示例与本主题互为印证:

const { exec } = require('child_process');

// 其中一个参数是未经净化的用户输入
exec('"/path/to/test file/someScript.sh" --someOption ' + input);

// 如果用户输入 '&& rm -rf --no-preserve-root /' 会发生什么?
// 你将得到一个"意外的惊喜"

该章节引用的 Node.js 官方文档措辞同样严厉:"绝不要把未经净化的用户输入传给此函数,任何包含 shell 元字符的输入都可能被用来触发任意命令执行。" 也就是说,eval 是"代码注入"的第一道闸门,而 child_process 是把注入升级为"命令注入"的放大器——两者叠加即构成完整的 RCE 链条。规避 eval 等于在链条源头就切断了攻击面。

用 Linter 安全规则自动封堵:detect-eval-with-expression

人肉评审难免遗漏,更好的做法是把"禁止 eval"变成机器强制执行的规则。仓库 lintrules.md 一节给出了配套方案:使用 eslint-plugin-security(ESLint 生态)或 tslint-config-security(TSLint 生态)等安全插件,基于已知漏洞模式对代码做安全检查,其中就包括对 eval() 不安全使用的检测。

eslint-plugin-security 提供的 detect-eval-with-expression 规则正是针对本主题的专门检测器,它会标记把非字面量表达式(如请求体中的用户输入)传给 eval() 的代码:

const userinput = req.body.userinput;
eval(userinput);   // 触发 detect-eval-with-expression 规则

eslint-plugin-security 还能一并捕获其他高危模式,例如:

  • detect-non-literal-fs-filename:用非字面量文件名访问文件系统(fs.readFile(path),path 来自 req.body);
  • detect-non-literal-regexp:用非字面量构造正则(new RegExp(...)),可能引入 ReDoS 与注入风险;
  • detect-pseudoRandomBytes:使用不安全的伪随机字节生成。

对包含上述不安全代码的 Node.js 项目运行 eslint-plugin-security,可以得到如下直观的报告结果:

eslint-plugin-security 检测出不安全 eval 等模式的项目运行示例

(该截图来自 lintrules.md,展示了在真实项目上执行安全 lint 后命中的问题列表。)

配合 git hooks(如 pre-git)在代码提交到远端之前强制执行这些规则,可以让"禁止 eval"从口头约定升级为持续集成的硬门槛。值得记住的是 ESLint 生态中还有其他同类规则(如 no-eval、no-implied-eval),实际项目中建议组合启用。

重构替代方案:四类高危场景的安全写法

规避 eval 的核心原则是:能不用动态求值就不用;必须执行动态代码时,把它隔离在受控的沙箱里;所有外部输入在进入任何敏感 API 之前先经过显式验证。 结合仓库各安全章节,可按以下方案逐一替换。

方案一:为每类场景选用安全的原生替代

原始写法(危险) 安全替代
eval(jsonString) JSON.parse(jsonString)(严格解析,不执行代码)
eval('obj.' + key) obj[key] 或 Map 索引查找
setTimeout('doSomething()', 1000) setTimeout(() => doSomething(), 1000)(传入函数引用而非字符串)
new Function('a', 'return a * 2') 直接定义普通函数 const f = (a) => a * 2

原则:回调一律传函数引用,绝不传字符串;需要"动态执行"的场景绝大多数都能用高阶函数、查找表或配置数据替代。

方案二:杜绝动态模块加载(避免变量路径 require)

仓库 safemoduleloading.md 指出,即使绕开 eval,用"来自用户输入的可变路径"去 require/import 模块同样是高危行为:

// 危险:helperPath 可能已被用户输入篡改
const badWayToRequireUploadHelpers = require(helperPath);

// 安全:使用固定的字面量路径
const uploadHelpers = require('./helpers/upload');

动态 require 本质上是"模块级代码注入"——攻击者若能控制路径,就能让进程加载任意文件。这一条规则同样适用于 fs.readFile() 等敏感资源访问:凡是访问路径来自用户输入,都必须先校验或改为白名单映射。

方案三:必须执行动态代码时,放入隔离沙箱

某些真实场景(如 webpack 这类支持运行时动态加载自定义 loader 的框架)确实需要执行运行时传入的代码。仓库 sandbox.md 给出了三种隔离手段,按资源隔离、崩溃隔离和信息隔离三个维度权衡:

  1. 独立子进程(child process):信息隔离见效快,但需要驯服子进程、限制执行时间并处理错误恢复;
  2. 云 Serverless 框架(FaaS):满足沙箱的全部要求,但动态部署与调用 FaaS 函数并不轻松;
  3. 沙箱 npm 库(如 sandbox、vm2):一行代码即可在隔离环境执行代码,简单取胜但防护能力有限。

以 sandbox 库为例,它能拦截语法错误、屏蔽敏感全局对象并对死循环做超时处理:

const Sandbox = require('sandbox');
const s = new Sandbox();

s.run('lol)hai', (output) => {
  console.log(output); // output='Syntax error'(语法错误被捕获)
});

// 受限代码:沙箱内无法访问 process.platform
s.run('process.platform', (output) => {
  console.log(output); // output=Null
});

// 无限循环:触发超时而非拖垮主进程
s.run('while (true) {}', (output) => {
  console.log(output); // output='Timeout'
});

注意原文档的明确立场:"作为经验法则,只运行你自己的 JavaScript 文件"。沙箱只是把伤害控制在最小范围("希望把损害降到最低,甚至让流程成功终止")的兜底方案,绝不是放心执行任意第三方代码的通行证。

方案四:入口处显式校验输入,缩小攻击面

validation.md 一节从"缩小攻击面"的角度补充了关键一环:对应用愿意接受的载荷做显式声明,输入一旦偏离预期就快速失败(fail fast)。这能有效防止攻击者用任意结构、值、长度的载荷反复试探——代码注入类攻击依赖的正是"输入可以不受约束地进入执行路径"。实践中可以用 JSON Schema、joi 等校验库,并尽量在请求进入路由处理之前(例如 Express 中间件)尽早完成校验。

专家论断:为什么 eval 是 JavaScript 中最令人诟病的部分

原文档引用了 Liran Tal 所著《Essential Node.js Security》一书的原话,这段论断至今仍是社区共识:

从安全的角度看,eval() 函数或许是 JavaScript 中最令人诟病的部分。它把 JavaScript 字符串当作文本解析,然后像 JavaScript 代码一样执行它。把这一点与可能流入 eval() 的不可信用户输入混在一起,就是一场灾难的配方,最终以服务器沦陷收场。

这段话点出了本质:eval 让"数据"与"代码"的边界彻底消失,而安全防御的全部前提恰恰是严格区分数据和代码。任何"用户可控字符串 + 代码执行语义"的组合,都是在亲手拆除这条边界。

落地清单:把"避免 eval"写进你的防御体系

结合本文全部内容,给出可直接落地的检查清单:

  1. 代码搜索:全局检索 eval(、new Function(,以及 setTimeout(/setInterval( 中的字符串首参(可用正则 setTimeout\s*\(\s*['"] 排查),逐一替换为函数引用或安全 API;
  2. 规则固化:在 ESLint 配置中启用 eslint-plugin-security 的 detect-eval-with-expression 及 no-eval、no-implied-eval,接入 CI 与 git hooks;
  3. 路径管控:检查所有 require()/import()/fs 文件访问的参数是否可能来自用户输入,一律改为字面量路径或白名单;
  4. 入口校验:对请求体、查询参数、头部等所有外部输入实施显式校验,异常输入快速失败;
  5. 兜底隔离:确需执行动态代码的业务,使用子进程或 sandbox/vm2 类沙箱隔离,并限制执行时间与资源;
  6. 最小权限:以非 root 用户运行 Node.js 进程(参考仓库安全章节关于非 root 用户与进程权限的实践),即使注入发生也限制其破坏半径。

这条清单源自 nodebestpractices 仓库 sections/security 目录下的系列最佳实践,其中 avoideval.md 是源头与主线,其余章节(lintrules、childprocesses、safemoduleloading、sandbox、validation)共同构成完整防线。记住那条核心原则:永远不要让用户输入成为可执行代码的一部分——无论是通过 eval,还是通过任何其他通向"代码执行"的路径。

登录后查看全文
nodebestpractices