CSP写了仍挡不住XSS?先检查nonce是否每次请求都更新

|作者: QUASA 编辑团队|2 分钟阅读
CSP写了仍挡不住XSS?先检查nonce是否每次请求都更新

配置基于nonce的严格CSP时,应为每次HTTP响应生成新的、不可预测的随机值,并把同一个值写入CSP响应头和获准执行的script元素。OWASP的CSP速查表要求nonce按响应生成,并提醒开发者只在可信模板中的脚本上填入该值。把nonce存成环境变量、构建常量或全站共用的模板值,都不符合这一要求。

直接查看浏览器实际收到的响应:核对script-src中的nonce与HTML脚本标签是否一致,再连续请求同一页面,比较不同响应的值。若页面是无法逐次改写的静态HTML,就应考虑为固定脚本配置hash;发布前先用Report-Only观察候选策略会拦截哪些资源。

先确认nonce有没有被复用

nonce的防护依赖攻击者无法预知当前响应接受的值。如果某个值被反复用于不同响应,而攻击者又能把脚本插入接受该值的页面,就可能给注入的script标签附上已知nonce。仅在服务启动时随机生成一次、随后长期复用,并没有解决这个问题。

一项CSP nonce实测研究在使用nonce策略的2271个站点中,发现598个站点在多个响应间复用了同一值;研究认为,这种情况可能削弱CSP对XSS的防护。这是该研究样本中的部署问题,并不表示这些站点都已遭到攻击。

复用也可能发生在源站之外:源站每次生成新值,CDN或反向代理却把包含旧CSP头与旧HTML的完整响应再次交付。检查时应分别观察源站和用户实际经过的缓存路径,并核对头部与HTML是否来自同一份响应。若必须缓存HTML,就需要让交付层可靠地同步更新策略和脚本标签,或改用适合静态内容的hash;只替换其中一处会使正常脚本失效。

动态页面:在响应阶段生成并注入

动态HTTP响应中的CSP头与获准脚本使用相同nonce,下一次响应换用新值。

以下是一个假设应用已有Express app的Node.js示例。路由每处理一份响应,就用加密安全随机源生成nonce,并把它同时交给策略和预先确定的脚本标签。示例先使用Report-Only头,便于观察候选策略的影响。

const crypto = require('node:crypto');

app.get('/page', (req, res) => { const nonce = crypto.randomBytes(16).toString('base64'); const policy = `script-src 'nonce-${nonce}'; object-src 'none'; base-uri 'none'; report-uri /csp-report`; res.setHeader('Content-Security-Policy-Report-Only', policy); res.type('html').send(`<!doctype html><script nonce="${nonce}" src="/app.js"></script>`); });

实际应用应由服务端模板只在可信脚本位置填入nonce,不能对已经生成的HTML全局搜索script标签并补写属性:若攻击者控制的内容已经进入页面,后处理也可能替注入的标签补上许可值。同一响应中的多个获准脚本可以共用该响应的nonce;下一份响应必须重新生成。示例中的report-uri路径只是占位符,部署时需要提供真正接收报告的端点。

示例策略中的object-src 'none'禁止对象嵌入,base-uri 'none'限制base元素改变相对地址的基准。样式、图片和网络连接等资源仍需按页面需求配置相应指令;脚本能够运行,并不代表整页策略已经完成。

静态页面:给固定脚本计算hash

静态页面的脚本内容与hash相配,内容变化后原有hash不再匹配。

无法按请求同步改写HTML和响应头的静态站点,可以在发布时为固定脚本计算摘要。MDN的CSP实施指南建议动态生成内容时选nonce、需要提供静态内容时选hash,并在正式执行策略前先用Report-Only测试。

对获准的内联脚本,应按其实际内容计算SHA-256摘要,将Base64结果写入script-src的'sha256-摘要值'。浏览器依据脚本内容核对摘要,HTML无需为每位访问者改写。若用hash放行外部script,仍须按相应策略为该元素提供匹配的integrity属性;给一段内联代码计算hash,不会自动放行其他外部依赖。

hash与脚本内容绑定,连空白字符变化都可能使旧值失配。因此,应从最终发布的脚本内容计算摘要,并让HTML、脚本与CSP作为一组产物发布。若脚本随用户或请求改变,持续维护对应hash会很困难;可以改用动态响应生成nonce,或把变化的数据与固定脚本分开。

第三方脚本与内联代码怎么处理

严格CSP先放行带有正确nonce或hash的入口脚本。如果入口脚本还会动态创建其他script元素,可以评估在script-src中加入'strict-dynamic',让它加载的脚本继承信任。采用这种方式前,要审查入口脚本会不会根据不可信输入拼接脚本地址;信任传递会一并放大入口脚本自身的问题。

迁移时应逐项处理onclick等内联事件属性、javascript: URL和依赖eval的代码。事件处理逻辑可移入获准脚本,再由addEventListener绑定;确实需要执行的内联脚本则应明确配置nonce或hash。若为消除违规而加入'unsafe-inline',脚本执行边界会变宽;针对这类遗留代码,可按移除unsafe-inline的步骤逐项处理。

从Report-Only迁移到正式执行

先通过Content-Security-Policy-Report-Only下发候选策略,并配置实际可用的报告端点。浏览器会报告该策略下的违规,但候选策略本身不会阻止脚本执行;这一阶段用于找出受影响的正常功能,也用于确认策略覆盖了预期页面。

  1. 列出主要页面类型与关键操作,包括登录、表单、支付流程以及加载第三方脚本的页面;逐页核对响应头和HTML中的nonce。
  2. 收集违规报告,按页面、指令和被拦资源分类。对接收端点限流,并审查报告中可能包含的敏感URL参数。
  3. 修正真实依赖或遗留代码后重测。除观察报告数量,还要验证关键操作,以及未获授权的脚本是否会被候选策略识别为违规。
  4. 确认兼容性后,改用Content-Security-Policy正式执行头;上线后继续检查违规报告及经过缓存、代理交付的响应。

如果使用report-to,需要在Reporting-Endpoints头中定义对应端点。为兼顾浏览器支持情况,可以同时配置report-uri;无论选择哪种方式,都要确认接收端点确实收到报告,而不能只看浏览器控制台。

把nonce复用检查加入自动化

部署检查可以连续请求同一个动态页面,从每份响应的CSP头提取nonce;缺失或重复就让检查失败。下面的示意代码优先读取Report-Only头,切换到正式执行后仍可读取CSP头。测试地址应指向实际返回动态HTML的页面。

const seen = new Set(); for (let i = 0; i < 5; i++) { const r = await fetch('https://example.test/page', { cache: 'no-store' }); const h = r.headers.get('content-security-policy-report-only') ?? r.headers.get('content-security-policy') ?? ''; const n = h.match(/'nonce-([^']+)'/)?.[1]; if (!n || seen.has(n)) throw new Error('nonce缺失或复用'); seen.add(n); }

这段检查只比较响应头,还应读取原始HTML,确认获准脚本标签使用本次响应的同一个值。检查范围要包含经过CDN的实际访问路径,并比较同一会话和不同会话的响应;客户端设置cache: 'no-store'并不能代替对服务器或CDN缓存行为的检查。正式执行后,还应验证未带正确nonce的测试脚本被拦截,同时继续保留输出编码和输入处理等XSS防线。

相关阅读:

分享:

订阅我们的新闻通讯

将最新 Web3、AI 和加密货币新闻直接发送到您的邮箱。

0