Express 中为每个请求生成 CSP nonce 并配合 strict-dynamic

举报
yd_240403232 发表于 2026/10/30 00:03:30 2026/10/30
【摘要】 本文给出在 Express 中为每个响应生成随机 CSP nonce 的最小实现:中间件写入 Content-Security-Policy 头,EJS 模板把 nonce 注入 script 标签,并说明 strict-dynamic 的行为、缓存与兼容性边界。

在 Express 中为每个请求生成 CSP nonce

问题场景

服务端渲染页面里常有内联脚本,例如初始化数据或绑定事件。若 CSP 只写 script-src 'self',内联脚本会被浏览器拒绝;若为了省事加上 'unsafe-inline',又会让注入的内联脚本获得执行机会。nonce 的思路是:每次返回 HTML 时生成一个不可预测的随机值,服务端在 CSP 响应头中声明这个值,模板只给可信的 script 标签加上同一个值。浏览器只执行 nonce 匹配的脚本。

strict-dynamic 进一步规定:带 nonce 的脚本可以动态创建并加载后续脚本,而主机白名单(如 https:)和 'unsafe-inline' 在支持它的浏览器中不再作为放行依据。这样可以把信任从“来源域名”收敛到“本次响应中被信任的脚本”。

关键约束:

  • nonce 必须每个响应重新生成,不能是模块级常量;
  • 响应头里的 nonce 与模板中 script 标签的 nonce 必须完全一致;
  • 带 nonce 的 HTML 不能被共享缓存复用。

最小可运行示例

目录结构:

csp-nonce-demo/
  app.js
  views/index.ejs
  public/app.js

app.js

const express = require('express');
const crypto = require('crypto');
const path = require('path');

const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// 静态资源放在 CSP 中间件之前,避免为每个静态响应生成 nonce
app.use('/static', express.static(path.join(__dirname, 'public')));

app.use((req, res, next) => {
  // 128 位随机数;每个响应都必须重新生成
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;

  const csp = [
    `default-src 'self'`,
    // https: 与 'unsafe-inline' 仅作为旧浏览器的回退;
    // 支持 strict-dynamic 的浏览器会忽略它们。
    `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
    `object-src 'none'`,
    `base-uri 'none'`,
    `frame-ancestors 'none'`
  ].join('; ');

  res.setHeader('Content-Security-Policy', csp);
  // 带 nonce 的 HTML 不能被共享缓存复用
  res.setHeader('Cache-Control', 'no-store');
  next();
});

app.get('/', (req, res) => {
  res.render('index', { title: 'CSP nonce 示例' });
});

app.listen(3000, () => {
  console.log('open http://localhost:3000');
});

说明:crypto.randomBytes(16) 生成 128 位随机值,再转成 base64。res.locals.nonce 会进入 EJS 的渲染上下文。CSP 中间件放在静态资源之后,避免给静态文件也生成 nonce;实际项目里也可以只对返回 HTML 的路由挂载。

views/index.ejs

<!doctype html>
<html lang='zh-CN'>
<head>
  <meta charset='utf-8'>
  <title><%= title %></title>
</head>
<body>
  <h1>CSP nonce 示例</h1>
  <button id='btn'>点击</button>
  <p id='out'></p>

  <script nonce='<%= nonce %>'>
    document.getElementById('btn').addEventListener('click', function () {
      document.getElementById('out').textContent = '内联脚本已执行';
    });
  </script>

  <script src='/static/app.js' nonce='<%= nonce %>'></script>
</body>
</html>

两个 script 都带同一个 nonce。内联脚本用 addEventListener 绑定事件,而不是 onclick 属性,因为 nonce 只作用于 script 元素,不能放行内联事件处理器。

public/app.js

console.log('外部脚本已加载');

运行与验证

npm init -y
npm i express ejs
node app.js

访问 http://localhost:3000。用 curl 检查响应头:

curl -sI http://localhost:3000/ | grep -i content-security-policy

连续请求两次,观察 nonce 不同;再查看返回 HTML 中的 script 标签,nonce 应与响应头一致。浏览器开发者工具的 Console 中不应出现 CSP 拒绝信息。可以临时在模板里加一个不带 nonce 的 <script>console.log('no nonce')</script>,确认它被拒绝后再删除。

strict-dynamic 的行为

strict-dynamic 的作用是让信任沿着脚本加载链传递:带 nonce 的脚本通过 document.createElement('script') 创建并插入的脚本,可以被执行;不需要再为后续脚本逐个配置域名白名单。与此同时,在支持该指令的浏览器中,https: 这类主机白名单和 'unsafe-inline' 不再作为放行依据,因此攻击者即使注入 <script src='https://evil.example/x.js'> 也无法直接执行。

这也意味着信任边界发生了变化:一旦某个第三方脚本带上了 nonce,它加载的任何脚本都会继承信任。引入第三方脚本时要评估其行为,避免把不可控脚本放进 nonce 名单。

边界与限制

  • 不要复用 nonce:不要在模块顶层生成一次 nonce 后反复使用;也不要把带 nonce 的 HTML 放进 CDN 或反向代理的共享缓存。示例用 Cache-Control: no-store 避免缓存复用,静态资源仍可正常缓存。
  • 不覆盖内联事件属性:onclick='...'、javascript: URL 等不是 script 元素,nonce 不能放行;应改为事件监听器。
  • 旧浏览器回退:示例保留 https: 与 'unsafe-inline' 作为旧浏览器回退,这会削弱旧环境的安全性;如果目标浏览器都支持 nonce 与 strict-dynamic,可以去掉这两个回退项。上线前可先用 Content-Security-Policy-Report-Only 观察拦截情况。
  • CSP 不是转义替代品:nonce 降低内联脚本被执行的风险,但服务端仍需对输出做上下文转义,防止 HTML 注入。
  • 模板与缓存:EJS 模板编译缓存不会影响 nonce,只要每次渲染都从 res.locals 取当前请求的值;模板中使用 <%= nonce %> 让 EJS 转义,不要把非受控数据拼进 nonce 属性。
  • nonce 的格式:base64 可能包含 +、/、=,在 CSP 头和带引号的 HTML 属性中都是合法的,不需要额外 URL 编码。
【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。