返回

优化CSP:告别 new Function 与 unsafe-eval 的安全方案

javascript

移除 unsafe-eval 隐患:new Function() 的安全替代方案

遇到 new Function()unsafe-eval 难题

在一些前端项目中,为了符合更严格的内容安全策略(Content Security Policy, CSP),需要移除 'unsafe-eval' 这个指令。这时候,代码里如果使用了 new Function() 构造函数,就会遇到麻烦。因为它和 eval() 一样,会触发 unsafe-eval 相关的安全限制。

比如,你可能会在代码里看到类似这样的用法:

var functionBody = "with(context){with(data){return{'css':function(){return $root.css.root }}}}"
// ... 其他逻辑 ...
var dynamicFunction = new Function("context", "element", functionBody);
// 调用 dynamicFunction(ctx, el)...

上面这段代码尝试从一个字符串动态创建一个函数。问题在于 new Function(...) 这一行,它天生就带有执行任意字符串代码的能力,这正是 CSP 中 'unsafe-eval' 所要限制的行为。项目构建或者运行时,CSP 检测工具就会报警,或者浏览器直接拒绝执行。

很多人知道 eval() 有安全风险,容易导致跨站脚本攻击(XSS),new Function() 其实是同胞兄弟,风险类似。那么,有没有更安全的替代方案呢?

为啥 new Function() 会触发 unsafe-eval

简单来说,new Function()eval() 都允许你把一串文本字符串当作 JavaScript 代码来执行。

new Function('arg1', 'arg2', 'console.log(arg1 + arg2);') 这行代码,实际上是在运行时编译并创建了一个新的匿名函数。浏览器和 CSP 策略无法在编译时静态分析这个字符串 functionBody 里的内容是否安全。如果这个字符串能被外部输入(比如用户提交的数据、URL 参数等)所控制,攻击者就可能注入恶意代码,然后在你的页面上执行,窃取信息或者搞破坏。

CSP 通过禁用 'unsafe-eval',就是想从根本上堵住这种运行时动态执行字符串代码的口子,提升应用的安全性。所以,一旦你想去掉 'unsafe-eval',就必须找到 new Function() 的替代方案。

如何安全地替代 new Function()

没有一个所谓的“安全版 new Function()”可以直接替换掉它,同时还能执行任意代码字符串。因为“安全”和“执行任意字符串”本身就是矛盾的。正确的思路是:分析原来用 new Function() 到底是为了解决什么问题,然后用更安全、更具体的方式去实现同样的目标,彻底避免动态执行来历不明的代码字符串。

下面是几种常见的替代思路和方案:

方案一:重构代码,避免动态生成函数

这是最推荐,也是最根本的解决方案。很多时候,动态生成函数并非必需,可以通过代码设计来避免。

原理和作用:
回顾一下使用 new Function() 的场景。它通常用于:

  1. 根据不同的条件执行不同的逻辑片段。
  2. 基于模板或配置动态生成某些处理函数。
  3. 处理一些序列化或反序列化的特定逻辑。

这些场景大多可以通过预定义的函数、配置对象、查找表(Lookup Table)或者设计模式(如策略模式、命令模式)来解决。目标是把动态生成的“代码逻辑”转变成静态的“数据配置”或“逻辑分支”。

示例:
假设原来是这样:

// 不推荐的写法
function createCalculationFunction(operation) {
  let body = '';
  if (operation === 'add') {
    body = 'return a + b;';
  } else if (operation === 'subtract') {
    body = 'return a - b;';
  }
  // ... 其他操作
  else {
    body = 'return 0;';
  }
  // new Function() 导致 unsafe-eval
  return new Function('a', 'b', body);
}

const addFunc = createCalculationFunction('add');
console.log(addFunc(5, 3)); // 输出 8

可以重构成这样:

// 推荐的重构方式
function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

// ... 其他操作对应的函数

const operations = {
  'add': add,
  'subtract': subtract
  // ... 其他操作
};

function getCalculationFunction(operation) {
  // 通过查找表获取预定义函数,如果没有则返回默认处理
  return operations[operation] || function() { return 0; };
}

const addFunc = getCalculationFunction('add');
console.log(addFunc(5, 3)); // 输出 8

const unknownFunc = getCalculationFunction('multiply'); // 假设没有定义 multiply
console.log(unknownFunc(5, 3)); // 输出 0

对于文章开头提到的例子:
那个包含 with 语句的例子看起来更像是在搞某种数据绑定或模板渲染。with 语句本身也是不推荐使用的(原因见后文)。重构的关键是理解 functionBody 字符串 真正想干什么。它似乎想在 contextdata 的作用域下,返回一个包含特定 css 函数的对象。

更安全的做法是:

  1. 明确 context, data, $root 这些变量的来源和结构。
  2. 直接编写一个常规的 JavaScript 函数,接收这些变量作为参数,然后按逻辑访问它们的属性,返回需要的对象结构。
// 假设 context, data, rootData 是明确的对象
function generateMyObject(context, data, rootData) {
  // 直接访问属性,避免使用 with
  // 假设 $root 对应 rootData
  const cssValue = rootData.css.root; // 根据实际结构调整

  return {
    'css': function() {
      // 这里可能还需要访问 context 或 data 的某些属性
      // 例如: return context.someValue + data.anotherValue + cssValue;
      return cssValue;
    }
    // ... 可能还有其他属性
  };
}

// 使用时:
// var myObject = generateMyObject(context, elementData, someRootData);
// var cssResult = myObject.css();

安全建议:
这种方法通过消除动态代码执行,从根本上移除了 unsafe-eval 的风险,本身就是最安全的。

进阶使用技巧:
对于复杂的逻辑分发,可以考虑使用更高级的设计模式,比如:

  • 策略模式(Strategy Pattern): 将不同的算法(原来 new Function 生成的函数体)封装在独立的策略对象中,根据需要切换策略。
  • 命令模式(Command Pattern): 将请求封装成对象,允许参数化客户端、队列或日志请求,支持撤销操作。

方案二:使用安全的模板引擎

如果 new Function() 主要用于基于数据生成 HTML 结构或者特定的文本输出(比如文章开头的例子可能就是为了生成绑定了特定数据的 CSS 类名或其他属性),那么使用成熟的模板引擎是更好的选择。

原理和作用:
Handlebars, Mustache, Lodash Templates, EJS 等模板引擎,它们设计的目的就是安全地将数据和模板结合起来生成文本输出。它们通常自带解析器,将模板字符串解析成中间表示(比如 AST),然后通过遍历和数据绑定来生成结果,这个过程不涉及直接执行任意代码字符串,因此不会违反 unsafe-eval

示例 (使用 Handlebars):
假设需要根据数据动态生成 HTML:

<!-- template.hbs -->
<div class="{{ $root.css.root }}">
  <h1>{{ title }}</h1>
  <p>{{ description }}</p>
</div>
// 安装: npm install handlebars
import Handlebars from 'handlebars'; // 或者通过 <script> 引入

// 编译模板(通常在构建时或初始化时完成一次)
const source = document.getElementById('template-hbs').innerHTML; // 或者直接是字符串
const template = Handlebars.compile(source);

// 准备数据 (模拟原始例子中的 context, data, $root)
const context = { /* ... */ };
const data = {
  title: 'Hello World',
  description: 'This is rendered safely.'
};
const rootData = {
  css: {
    root: 'my-component-class'
  }
};

// 渲染模板,传入所有需要的数据
// Handlebars 的上下文处理方式与 new Function + with 不同,需要整合数据
const combinedData = { ...context, ...data, $root: rootData };
const resultHtml = template(combinedData);

// 将结果插入 DOM
document.getElementById('output').innerHTML = resultHtml;

安全建议:

  1. 选择信誉良好的模板引擎库: 确保库本身没有已知的安全漏洞,并保持更新。
  2. 注意模板本身的逻辑: 虽然引擎本身是安全的(不 eval),但模板中的逻辑(如条件判断、循环)如果写得过于复杂或者依赖了未经验证的数据,也可能产生非预期的输出。要对传入模板的数据进行必要的清理和验证。
  3. 小心自定义助手(Helpers): 很多模板引擎允许你注册自定义的辅助函数。要确保这些函数本身是安全的,不会意外地引入执行外部代码的能力。

进阶使用技巧:

  • 预编译模板: 为了性能,可以在项目构建阶段就将 .hbs 或其他模板文件预编译成 JavaScript 函数,运行时直接调用,省去客户端的编译开销。
  • Logic-less vs. Logic-full: Mustache 是典型的“logic-less”引擎,模板中几乎没有逻辑。Handlebars 则允许更复杂的逻辑(如 #if, #each)。根据需要选择。

方案三:利用表达式解析库

有时候,动态执行的需求仅仅是计算一个简单的数学表达式或逻辑判断,而不是完整的函数体。这时可以使用专门的表达式解析库。

原理和作用:
expr-eval, mathjs (侧重数学计算), jsep (通用 JS 表达式解析) 这样的库,可以安全地解析和执行一个受限的表达式字符串。它们通常提供一个安全的执行环境(沙箱),只允许使用预定义的操作符、函数和变量,不允许访问全局对象 (window)、执行任意语句或调用危险的内置函数。

示例 (使用 expr-eval):

// 安装: npm install expr-eval
import { Parser } from 'expr-eval';

const parser = new Parser();

// 定义允许在表达式中使用的变量
const variables = {
  x: 5,
  y: 10,
  status: 'active'
};

// 解析并计算表达式
try {
  const expression1 = 'x * y + 2';
  const result1 = parser.evaluate(expression1, variables);
  console.log(`${expression1} =`, result1); // 输出 "x * y + 2 = 52"

  const expression2 = "status === 'active' ? x : y";
  const result2 = parser.evaluate(expression2, variables);
  console.log(`${expression2} =`, result2); // 输出 "status === 'active' ? x : y = 5"

  // 尝试执行不安全的操作(通常会被库阻止或报错)
  // const unsafeExpr = "window.alert('Hacked!')";
  // parser.evaluate(unsafeExpr, variables); // 可能会抛出错误,或返回特定值

} catch (error) {
  console.error('Expression evaluation failed:', error);
}

安全建议:

  1. 仔细选择库: 确认库有良好的社区支持和安全记录。
  2. 理解其限制: 明确该库允许执行的表达式范围。它不能替代 new Function 执行任意复杂逻辑。
  3. 净化输入: 提供给表达式的数据 (上面例子中的 variables) 应该是可信的,或者经过适当的净化处理。
  4. 注意性能和资源消耗: 解析和执行复杂表达式可能比原生代码慢,且有潜在的拒绝服务(DoS)风险(例如,执行一个计算量极大的表达式)。可以考虑设置超时或限制表达式复杂度。

进阶使用技巧:

  • 自定义函数: 许多表达式解析库允许你向解析器注册自定义的安全函数,扩展其能力。
  • 性能优化: 如果同一表达式需要多次计算,可以先解析(parse)表达式得到一个可执行对象,然后多次调用其 evaluate() 方法,传入不同的变量上下文。

方案四:服务器端生成或代码拆分

如果动态生成的函数逻辑依赖于某些只有服务器知道或者需要在构建时确定的信息,可以考虑把生成过程放到服务器端或者构建环节。

原理和作用:

  • 服务器端生成 (Server-Side Generation, SSG/SSR): 服务器根据请求或其他条件,直接生成包含所需特定逻辑的 JavaScript 代码片段或完整的 JS 文件,然后发送给客户端。客户端收到的就是静态的、可以直接执行的代码,无需在客户端使用 new Function
  • 代码拆分 (Code Splitting) 与动态导入 (Dynamic Imports): 使用现代前端构建工具(如 Webpack, Rollup),可以将代码库拆分成多个小的代码块(chunks)。当需要某段特定逻辑时,可以使用 import() 动态加载对应的代码块。这避免了将所有逻辑捆绑在一起,也避免了运行时从字符串生成代码。

示例 (思路):

  • 服务器端:
    一个 Node.js 服务器可以根据请求参数 /getUserHandler?type=admin 生成如下 JS 响应:

    // 响应内容,直接发送给客户端
    function handleUser(user) {
        console.log('Admin user action for:', user.name);
        // ... admin specific logic ...
    }
    // 客户端可以直接使用这个 handleUser 函数
    
  • 代码拆分 + 动态导入:
    假设有两个不同的处理逻辑 handlerA.jshandlerB.js

    // main.js
    async function loadAndRunHandler(type, data) {
      let handlerModule;
      if (type === 'A') {
        // Webpack/Rollup 会将 './handlers/handlerA' 打包成独立 chunk
        handlerModule = await import('./handlers/handlerA');
      } else if (type === 'B') {
        handlerModule = await import('./handlers/handlerB');
      } else {
        // 加载默认或错误处理
        handlerModule = await import('./handlers/defaultHandler');
      }
      handlerModule.default(data); // 调用模块导出的默认函数
    }
    
    // 调用: loadAndRunHandler('A', { id: 123 });
    

安全建议:

  • 服务器端生成: 如果服务器生成的代码逻辑受到用户输入的影响,必须在服务器端做好严格的输入验证和过滤,防止注入攻击(虽然不是客户端 XSS,但可能是服务器端代码注入或逻辑篡改)。确保生成代码的接口是安全的。
  • 代码拆分: 动态导入本身是安全的机制。

进阶使用技巧:

  • 构建工具优化: 配置 Webpack 或 Rollup 的代码拆分策略,如基于路由的拆分、按需加载公共库等,可以优化加载性能。
  • 魔法注释(Magic Comments):import() 中使用特殊注释(如 /* webpackChunkName: "handlerA" */)可以控制生成 chunk 的文件名,方便调试和管理。

处理遗留代码中的 with 语句

值得一提的是,文章开头给出的例子中使用了 with 语句:with(context){with(data){...}}

强烈建议在任何重构过程中移除 with 语句。 原因如下:

  1. 性能问题: JavaScript 引擎难以优化 with 块内部的代码,因为它改变了作用域链查找规则,可能导致执行效率降低。
  2. 代码歧义和维护困难: with 使得代码难以理解。块内的变量引用到底是来自 with 指定的对象属性,还是来自外部作用域?这很容易造成混淆和 bug。
  3. 严格模式(Strict Mode)禁用: 在 JavaScript 的严格模式 ('use strict') 下,with 语句是完全禁止使用的。现代 JavaScript 开发通常都推荐或强制使用严格模式。

替代 with 的方法很简单: 直接、明确地访问对象的属性。

例如,with(obj) { a = b; } 应该改成:

obj.a = obj.b; // 或者根据上下文,可能是 obj.a = someOtherVar;

对于嵌套的 with,比如 with(context){ with(data){ ... } },你需要分析代码块内访问的变量是属于 context 的,还是属于 data 的,或者是外部作用域的,然后相应地修改为 context.propertyNamedata.propertyName 或直接使用外部变量。

如何选择合适的方案?

面对 new Function() 的替换需求,选择哪种方案取决于具体情况:

  1. 代码的核心目的是什么?

    • 如果是根据条件执行不同逻辑块 -> 优先重构代码 (方案一)
    • 如果是生成 HTML 或基于数据的文本 -> 使用模板引擎 (方案二)
    • 如果是计算简单的、数据驱动的表达式 -> 考虑表达式解析库 (方案三)
    • 如果逻辑与服务器数据紧密相关或非常庞大/不常用 -> 服务器生成或代码拆分 (方案四)
  2. 可控性与安全性要求?

    • 重构代码提供最大控制力和固有安全性。
    • 模板引擎和表达式解析库相对安全,但需关注库本身和输入数据。
    • 服务器生成需确保服务器端安全。
  3. 项目复杂度和维护成本?

    • 简单重构可能最快。
    • 引入新库(模板引擎、解析器)会增加依赖,但可能提升开发效率。
    • 服务器端/构建方案可能需要调整架构。

通常,彻底重构以避免动态代码生成(方案一)是最理想的选择 ,因为它不仅解决了 unsafe-eval 的问题,还能提升代码质量、可读性和性能。其他方案则是在特定场景下的有效补充。

移除 new Function()'unsafe-eval' 不仅仅是为了通过 CSP 检查,更是提升应用整体安全性的重要一步。选择合适的替代方案,编写更安全、更清晰的代码。