返回

解决 Rails CSP unsafe-eval:安全处理 jQuery AJAX 与 js.erb

javascript

告别 'unsafe-eval':在 Rails 和 jQuery AJAX 中安全处理 .js.erb 与 CSP

在开发 Rails 7.1 应用时,你可能碰到一个内容安全策略 (CSP) 的拦路虎。具体场景是:当你在 .js.erb 文件里,想用 jQuery 的 get() 方法去请求另一个 .js.erb 响应的路径时,浏览器控制台可能会无情地甩给你一个错误:

application-....js:83759 Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'unsafe-inline' 'nonce-abcdefg12345=='".

触发这个问题的代码可能长这样,比如在一个 create.js.erb 文件里:

// create.js.erb
get("<%= action_button_index_path(object_type: @object_type, format: :js) %>");

同时,你的 config/initializers/content_security_policy.rb (或者 config/application.rb) 文件里的 CSP 配置大概是这样:

Rails.application.configure do
  config.content_security_policy do |policy|
    # ... 其他策略 ...
    policy.script_src :self, :unsafe_inline # 可能有 :self
    # 注意:这里没有 'unsafe-eval'
    # ... 其他策略 ...
  end

  # 启用了 Nonce 来增强安全性
  config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(32) }
  config.content_security_policy_nonce_directives = %w[script-src]
  config.content_security_policy_report_only = false # 通常是 false 来强制执行策略
end

咱们的目标很明确:干掉这个 unsafe-eval 错误,而且不能为了省事就在 CSP 里加上 'unsafe-eval' 这个“后门”。毕竟,这会大大削弱应用抵抗跨站脚本攻击 (XSS) 的能力。那么,怎么改造代码,才能既加载并执行 action_button_index_path 返回的 JavaScript,又符合我们严格的、包含 nonce 的 CSP 策略呢?

别急,下面咱们就来拆解这个问题,看看有啥好法子。

问题根源:为啥 get().js.erb 会触发 unsafe-eval

问题的核心在于 jQuery 的 get() 方法(以及其他类似的 AJAX 方法如 $.ajax$.post 等)的一个“贴心”行为。

当你请求一个 URL,并且期望(或显式指定)返回类型是 JavaScript 时(比如请求路径以 .js 结尾,或者明确设置了 dataType: 'script',或者像例子中那样 format: :js 导致 Rails 返回 text/javascript MIME 类型),jQuery 会:

  1. 获取响应: 它会收到服务器返回的 JavaScript 代码字符串。
  2. 执行代码: 为了方便,jQuery 会自动帮你执行 这段字符串。它内部使用的机制,本质上等同于 eval() 或者 new Function()

麻烦就出在第二步。eval() 这类函数会把字符串当作代码来执行,这正是 CSP 中 'unsafe-eval' 试图阻止的行为。你的 CSP 策略里配置了 script-src,但没有包含 'unsafe-eval',所以浏览器严格遵守规定,拒绝执行,抛出错误。

即使你配置了 nonce,它也帮不上忙。Nonce 是用来验证 <script> 标签的来源是否可信,防止恶意内联脚本或外部脚本注入。但它管不了 eval() 这种字符串到代码 的执行方式。

解决方案:绕开 eval 的花式玩法

既然直接执行字符串不行,咱们就得换个思路。主要方向有两个:要么让服务器别返回需要 eval 的原始 JS 字符串,要么改变客户端处理响应的方式。

方案一:服务器不返回 JS,返回 HTML 片段

这是最常见也通常推荐的做法。与其让服务器返回一堆需要立即执行的 JS 指令来操作 DOM,不如让服务器直接渲染好需要更新的那部分 HTML。

原理:

  1. 服务器端 (Rails):
    • 控制器里的 Action(比如 action_button_index)不再渲染 .js.erb
    • 它改成渲染一个只包含所需 HTML 的局部视图 (partial)。
    • 响应的 Content-Type 变成 text/html(或者由客户端指定接受类型)。
  2. 客户端 (JavaScript/jQuery):
    • 使用 get()$.ajax() 请求数据。
    • 获取到 HTML 字符串后,使用 jQuery 的 DOM 操作方法(如 .html(), .append(), .replaceWith())把它安全地插入到页面的指定位置。

实现步骤:

  1. 修改 Rails 控制器 (action_button_controller.rb 或类似文件):
# app/controllers/action_button_controller.rb
class ActionButtonController < ApplicationController
  def index
    @object_type = params[:object_type]
    # 假设你有一些逻辑来获取 @buttons 数据
    @buttons = find_buttons_for(@object_type)

    # 不再寻找 action_button/index.js.erb
    # 而是渲染一个 HTML partial
    # 注意:这里不需要指定 format: :js 了,或者客户端明确请求 html
    render partial: 'action_button/buttons', locals: { buttons: @buttons }, layout: false
  end

  private

  def find_buttons_for(object_type)
    # ... 获取按钮数据的逻辑 ...
    ["Button A", "Button B"] # 示例数据
  end
end
  1. 创建或修改 HTML Partial (_buttons.html.erb):
<%# app/views/action_button/_buttons.html.erb %>
<% buttons.each do |button_text| %>
  <button class="btn btn-secondary m-1"><%= button_text %></button>
<% end %>
<%# 这里可以包含任何你需要的 HTML 结构 %>
  1. 修改发起请求的 JavaScript (原来的 create.js.erb 内容需要调整,或者放在其他 JS 文件里):

假设 create 操作成功后,你想更新页面上某个 ID 为 button-container 的区域。

// 假设这个逻辑现在放在 create 操作成功后的回调里,
// 或者如果 create.js.erb 仍然存在,其内容调整为如下:

// 使用 $.ajax 提供更多控制权,或者继续用 get 也可以
$.ajax({
  url: "<%= action_button_index_path(object_type: @object_type) %>", // 不再需要 format: :js
  method: 'GET',
  dataType: 'html', // 明确告诉 jQuery 我们期待 HTML
  success: function(htmlResponse) {
    // 找到目标容器,用返回的 HTML 替换其内容
    $('#button-container').html(htmlResponse);
    console.log("按钮区域已更新!");

    // 如果需要,在这里绑定事件监听器到新加载的按钮上
    $('#button-container button').on('click', function() {
      console.log($(this).text() + " 被点击了!");
      // ... 其他处理逻辑 ...
    });
  },
  error: function(xhr, status, error) {
    console.error("加载按钮失败:", status, error);
    // 处理错误情况
  }
});

或者,如果你想把这段 JS 放在视图的 <script> 标签里(比如 create.turbo_stream.erb 或者直接在 create.html.erb 里更新),别忘了加上 nonce

<%# 示例:在 create.html.erb 或其他 ERB 视图中 %>
<div id="button-container">
  <%# 初始内容或占位符 %>
</div>

<script nonce="<%= content_security_policy_nonce %>">
  // 页面加载后或特定事件触发时执行
  $(document).ready(function() { // 或者其他触发时机
    $.ajax({
      url: "<%= action_button_index_path(object_type: @object_type) %>",
      method: 'GET',
      dataType: 'html',
      success: function(htmlResponse) {
        $('#button-container').html(htmlResponse);
        // 事件绑定等...
      },
      error: function(xhr, status, error) {
        console.error("加载按钮失败:", status, error);
      }
    });
  });
</script>

优点:

  • 完全避免 unsafe-eval
  • 服务器端逻辑更清晰(只负责渲染 HTML)。
  • 通常性能更好,因为浏览器解析和插入 HTML 比执行 JS 更快。
  • 符合关注点分离原则。

安全建议:

  • 虽然比 unsafe-eval 安全得多,但插入外部获取的 HTML 时,仍需确保来源可信。由于这里是请求自己的 Rails 应用,风险较低。如果 HTML 内容包含用户输入,确保在服务器端做了适当的转义(Rails 默认会做)。

方案二:服务器返回 JSON 数据,客户端处理

如果服务器返回的不是 UI 片段,而是一些数据,让客户端根据这些数据来决定如何更新界面或执行逻辑,那么 JSON 是个好选择。

原理:

  1. 服务器端 (Rails):
    • 控制器 Action 返回 JSON 格式的数据。
    • 这个 JSON 包含了客户端进行下一步操作所需的所有信息。
  2. 客户端 (JavaScript/jQuery):
    • 使用 get()$.ajax() 请求数据,设置 dataType: 'json'
    • success 回调中,解析 JSON 数据。
    • 根据数据内容,使用 JavaScript/jQuery 操作 DOM、显示消息或执行其他逻辑。

实现步骤:

  1. 修改 Rails 控制器 (action_button_controller.rb):
# app/controllers/action_button_controller.rb
class ActionButtonController < ApplicationController
  def index
    @object_type = params[:object_type]
    @buttons_data = fetch_button_data_for(@object_type) # 返回结构化数据

    render json: { buttons: @buttons_data } # 直接渲染 JSON
  end

  private

  def fetch_button_data_for(object_type)
    # 示例:返回按钮文本和对应的操作 URL 或 ID
    [
      { text: "编辑", action_url: "/objects/#{@object_type}/edit" },
      { text: "删除", action_id: "delete-#{@object_type}" }
    ]
  end
end
  1. 修改发起请求的 JavaScript:
// 同样,这段代码可以放在 create 成功后的逻辑中

$.ajax({
  url: "<%= action_button_index_path(object_type: @object_type) %>", // 通常 API 端点无需 format 后缀
  method: 'GET',
  dataType: 'json', // 明确期待 JSON
  success: function(data) {
    console.log("收到按钮数据:", data);
    const container = $('#button-container');
    container.empty(); // 清空旧按钮

    if (data.buttons && data.buttons.length > 0) {
      data.buttons.forEach(function(buttonInfo) {
        const $button = $('<button>').addClass('btn btn-info m-1').text(buttonInfo.text);
        if (buttonInfo.action_url) {
          // 示例:点击按钮跳转
          $button.on('click', function() { window.location.href = buttonInfo.action_url; });
        } else if (buttonInfo.action_id) {
          // 示例:设置 ID 用于其他 JS 逻辑
          $button.attr('id', buttonInfo.action_id);
          $button.on('click', function() { console.log("按钮 " + buttonInfo.text + " 被点击"); });
        }
        container.append($button);
      });
    } else {
      container.text("没有可用的操作按钮。");
    }
  },
  error: function(xhr, status, error) {
    console.error("加载按钮数据失败:", status, error);
  }
});

优点:

  • 也完全避免了 unsafe-eval
  • 数据和表现分离,非常灵活。客户端可以根据数据做各种复杂处理。
  • 适用于 API 驱动的场景。

安全建议:

  • 主要是确保 JSON 数据来源可信。处理来自服务器的数据时,也要注意不要直接将用户输入的数据嵌入到危险的上下文中(比如直接用作 eval 的输入,虽然这里我们避免了 eval,但原则适用)。

方案三:获取 JS 文本,动态创建带 nonce<script> 标签

如果你非得执行从服务器获取的原始 JavaScript 代码字符串,也不是完全没辙。可以利用 nonce 来授权动态创建的 <script> 标签。

原理:

  1. fetch API 或 jQuery 的 $.ajax 把 JavaScript 当作纯文本dataType: 'text')下载下来,不让 jQuery 自动执行它。
  2. 创建一个新的 <script> 元素。
  3. 把获取到的 JavaScript 文本设置为这个新 <script> 元素的 textContent
  4. 关键一步: 给这个新的 <script> 元素添加 nonce 属性,其值必须是当前页面请求时 CSP 头提供的那个 nonce
  5. 把这个带有 nonce<script> 元素添加到文档的 <head><body> 中。浏览器看到这个脚本有合法的 nonce,就会执行它。

实现步骤:

  1. 确保你的 Rails 布局文件中输出了 nonce
    通常在 app/views/layouts/application.html.erb<head> 里,你需要访问 content_security_policy_nonce 辅助方法,可以将其存在 meta 标签或 gon 变量里,供 JS 读取。

    <%# app/views/layouts/application.html.erb %>
    <head>
      <%# ... 其他 head 内容 ... %>
      <%= csrf_meta_tags %>
      <%= csp_meta_tag %> <%# 这个会输出 nonce %>
    
      <%# 或者,如果需要显式在 JS 中访问,可以存起来 %>
      <meta name="csp-nonce" content="<%= content_security_policy_nonce %>">
    
      <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
      <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
    </head>
    
  2. 修改发起请求的 JavaScript (替代 get("...format: :js"))

// 读取 nonce 值
const nonce = document.querySelector("meta[name='csp-nonce']")?.content;

if (!nonce) {
  console.error("未能获取 CSP Nonce 值!无法动态加载脚本。");
  // 可能需要添加错误处理逻辑
} else {
  const scriptUrl = "<%= action_button_index_path(object_type: @object_type, format: :js) %>";

  fetch(scriptUrl)
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.text(); // 获取纯文本响应
    })
    .then(scriptText => {
      const scriptElement = document.createElement('script');
      scriptElement.setAttribute('nonce', nonce); // 设置 Nonce
      scriptElement.textContent = scriptText; // 放入脚本内容

      // 添加到 body 末尾(或 head)使其执行
      document.body.appendChild(scriptElement);
      console.log("动态脚本已加载并执行:", scriptUrl);

      // (可选)脚本执行后通常可以移除它,如果它只是一次性执行
      // document.body.removeChild(scriptElement);
    })
    .catch(error => {
      console.error("加载或执行动态脚本失败:", error);
    });
}

优点:

  • 能够执行来自服务器的 JS 字符串,同时符合 nonce 的 CSP 策略。
  • 相对直接,如果你确实需要这种动态执行 JS 的模式。

缺点:

  • 实现比前两种方案稍微复杂一点,需要手动处理 nonce
  • 动态添加和执行脚本可能不如直接操作 DOM 或处理数据那样清晰和易于调试。
  • 获取 nonce 的方式可能需要根据你的具体布局和 JS 环境调整。

安全建议:

  • 绝对信任来源: 这个方法的前提是你完全信任 scriptUrl 返回的内容。因为一旦脚本带上了合法的 nonce,浏览器就认为它是可信的,会无条件执行。绝不能用这个方法去加载第三方或不可信来源的脚本内容。
  • Nonce 管理: 确保 nonce 生成和传递的安全性,防止被窃取。Rails 的内置机制通常是安全的。

进阶技巧:

  • 错误处理: 完善 fetch.catch 块,处理网络错误或服务器返回非 2xx 状态码的情况。
  • 脚本移除: 如果脚本执行后不再需要(比如它只是设置了一些事件监听器或初始化变量),可以考虑在执行后将其从 DOM 中移除,保持 DOM 清洁。

方案四:拥抱 Turbo Streams (现代 Rails 方式)

如果你在使用 Rails 7+ 并且已经集成了 Turbo,那么处理这种动态更新通常更推荐使用 Turbo Streams。

原理:

Turbo Streams 允许服务器发送指令(包含 HTML 片段)来精确地更新页面的特定部分,而不需要写太多的自定义 JavaScript。Rails 会自动处理 AJAX 请求和响应,并将 <turbo-stream> 标签的内容应用到 DOM 上。它本身设计上就与 CSP (包括 nonce) 兼容良好。

实现步骤 (简要概述):

  1. 控制器返回 Turbo Stream 响应:
# app/controllers/your_controller.rb
def create
  # ... 创建逻辑 ...
  respond_to do |format|
    format.turbo_stream do
      # 渲染一个 .turbo_stream.erb 文件,或者直接构建 stream 响应
      # 例如,更新一个区域,并追加 action buttons
      render turbo_stream: [
        turbo_stream.replace("some_area_id", partial: "shared/some_update"),
        turbo_stream.append("button-container", partial: "action_button/buttons", locals: { buttons: @buttons })
      ]
    end
    format.html { redirect_to ..., notice: "创建成功" } # Fallback
  end
end

这里假设 action_button/index 的逻辑(获取按钮)被移到 create action 或一个共享的方法中,然后其对应的 partial (_buttons.html.erb) 被用在 turbo_stream.append 指令里。

  1. 创建 Turbo Stream 视图 (create.turbo_stream.erb,如果不用 render turbo_stream:):
<%# app/views/your_controller/create.turbo_stream.erb %>
<%= turbo_stream.replace "some_area_id" do %>
  <%# 更新区域的 HTML %>
  <%= render partial: "shared/some_update" %>
<% end %>

<%= turbo_stream.append "button-container" do %>
  <%# 追加按钮的 HTML %>
  <%= render partial: "action_button/buttons", locals: { buttons: @buttons } %>
<% end %>

优点:

  • Rails 推荐的方式,集成度高。
  • 通常只需要写 HTML (ERB) 和很少或不需要写 JavaScript。
  • 自动处理 AJAX 和 CSP nonce (对于 Turbo 插入的内联脚本片段)。
  • 避免了 unsafe-eval

选择哪种方案?

  • 优先考虑方案一(HTML 片段)或方案四(Turbo Streams)。 它们更符合 Web 开发的最佳实践,易于维护,且安全性好。Turbo Streams 是现代 Rails 应用的首选。
  • 方案二 (JSON 数据) 适用于数据驱动或需要更复杂客户端逻辑的场景。
  • 方案三 (动态 Script + Nonce) 作为最后的手段,仅在你确实需要动态执行从服务器获取的 JS 代码字符串,并且信任该代码来源时使用。

通过采用这些替代方案,你可以有效地避免在 CSP 中启用危险的 'unsafe-eval',同时保持应用的动态性和交互性,构建更安全的 Rails 应用。