Express 中为每个请求生成 CSP nonce 并配合 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 编码。
- 点赞
- 收藏
- 关注作者
评论(0)