内容安全策略告诉支持它的浏览器,一个文档允许哪些动作。获取资源类指令分别控制脚本、样式、图片等类别,其他指令则负责不同边界。策略通常通过文档响应头提供,为浏览器行为增加约束,但不会修复注入漏洞、认证应用用户,也不会保证所有可能的脚本执行方式都已消失。应把它理解为具有具体作用范围的一层防护,而不是只要字段存在就获得完整安全保证。

缺失某个指令并不统一表示禁止。一些获取资源类指令会回退到默认来源指令,而嵌入父级、基础地址和表单目标等边界需要分别考虑。只包含脚本限制的策略,并不会自动完整限制图片加载、页面嵌入和表单提交。审阅时要逐项理解指令范围,尤其不能把默认来源写成全部拒绝后,就假定那些不继承该默认值的功能也被关闭。

为真正需要运行的脚本授权

随机数策略为经过明确选择的脚本元素附加不可预测的值,并在响应策略中声明同一个值。每次响应应生成新的随机数,只把它写到服务端确实准备授权的脚本上。如果自动给发现的每一个脚本元素都补上随机数,被注入的脚本也可能同时得到许可。构建时生成一次、以后持续复用的固定值,也失去了这种设计所依赖的逐响应不可预测性。

同一条来源指令中的表达式通常表示多个可选条件。把允许的主机与随机数写在一起,并不意味着脚本必须同时满足这两个条件。对于内容固定的静态页面,可以使用经过批准脚本的内容哈希,脚本精确内容发生变化时同步更新策略。需要根据页面怎样生成来选择方案;随意加入宽泛的内联执行许可,可能直接撤销原本希望通过随机数建立的限制。

坏掉的样子

开始强制执行后,合法的内联脚本、事件处理器、动态生成内容或此前没有注意到的依赖,都可能突然失效。仅报告模式能够在允许动作继续发生的同时观察违规,所以页面在该模式下正常运行,并不能证明强制策略也会允许它。报告还需要按含义解释:某条图片违规不能证明目标内联脚本被拦截,报告数量少也不能替代对关键页面功能的实际检查。

多份强制策略会共同生效。代理新增一份较宽松的响应头,并不会放宽已经存在的严格策略,因为动作必须满足每一份适用的强制策略。这可以解释为什么一个看起来正确的随机数仍然无法让脚本执行。应该检查最终交付的全部策略头和浏览器报告的有效指令,再修正真正需要的边界或合法资源用法,不能假定最后出现的字段自然覆盖此前所有限制。

快速验证

保存代码为 csp-nonce.py,执行 python3 csp-nonce.py –serve,在打印出的地址后加上 ?mode=enforce 并打开。本次检查使用 Python 3.14.4 和 Chrome 152。服务端为每份文档生成新的随机数,并故意让一个脚本没有该值。本机回环报告接收端只接收并丢弃正文,页面展示的证据来自浏览器违规事件。结束后按控制键加 C 关闭服务,不需要连接任何外部报告平台。

import base64
import json
import secrets
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from urllib.parse import parse_qs, urlsplit
from urllib.request import urlopen


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_POST(self):
        self.rfile.read(int(self.headers.get("Content-Length", "0")))
        self.send_response(204)
        self.send_header("Content-Length", "0")
        self.end_headers()

    def do_GET(self):
        mode = parse_qs(urlsplit(self.path).query).get("mode", ["enforce"])[0]
        assert mode in {"enforce", "report", "intersect"}
        nonce = base64.b64encode(secrets.token_bytes(18)).decode()
        policy = ("default-src 'none'; script-src 'nonce-" + nonce + "'; "
                  "base-uri 'none'; object-src 'none'; report-uri /reports")
        body = ("""<!doctype html><meta charset="utf-8"><title>CSP nonce lab</title>
<h1>Nonce, report-only, and policy intersection</h1>
<pre id="result">If both scripts are blocked, this text stays unchanged.</pre>
<script nonce=""" + '"' + nonce + '"' + """>
window.allowed=true; window.violations=[];
document.addEventListener('securitypolicyviolation',event=>{
  window.violations.push({directive:event.effectiveDirective,disposition:event.disposition,blocked:event.blockedURI});
});
</script>
<script>window.unapproved=true;</script>
<script nonce=""" + '"' + nonce + '"' + """>
setTimeout(()=>{
  window.labResult={allowed:window.allowed===true,unapproved:window.unapproved===true,violations:window.violations};
  document.getElementById('result').textContent=JSON.stringify(window.labResult,null,2);
},100);
</script>""").encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.send_header("Content-Security-Policy-Report-Only" if mode == "report" else "Content-Security-Policy", policy)
        if mode == "intersect":
            self.send_header("Content-Security-Policy", "script-src 'none'")
        self.end_headers()
        self.wfile.write(body)


server = HTTPServer(("127.0.0.1", 0), Handler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
origin = f"http://127.0.0.1:{server.server_port}"
try:
    policies = []
    for mode in ("enforce", "enforce", "report", "intersect"):
        with urlopen(origin + "/?mode=" + mode, timeout=5) as response:
            key = "Content-Security-Policy-Report-Only" if mode == "report" else "Content-Security-Policy"
            values = response.headers.get_all(key)
            assert len(values) == (2 if mode == "intersect" else 1)
            assert "'nonce-" in values[0]
            policies.append(values[0])
            response.read()
    assert policies[0] != policies[1]
    print(json.dumps({"app_url": origin, "nonce_changes_per_response": True,
                      "wire_policy_checks": "PASS"}), flush=True)
    if "--serve" in sys.argv:
        Event().wait()
except KeyboardInterrupt:
    pass
finally:
    server.shutdown()
    thread.join(timeout=5)
    server.server_close()

强制模式中,获准脚本应把 allowed 设为真,unapproved 则保持为假。相关违规事件的有效指令是 script-src-elem,被阻止的对象是内联脚本,处置方式是强制执行。将地址改为 ?mode=report 后,两个脚本标志都变为真,而违规处置方式变成报告。浏览器还可能记录其他资源违规,因此应按事件字段定位目标问题,不应把报告总数固定为一条,再据此误判执行结果。

最后打开 ?mode=intersect,第二份强制响应头会声明禁止脚本,三个脚本元素全部被阻止,包括带有正确随机数的脚本。页面说明文字保持不变,浏览器检查确认两个全局标志都不存在。Python 探针另外验证了随机数确实变化,以及响应中确实存在两个策略字段。本例为本地接收端使用较早的 report-uri 报告传输方式,没有实现生产报告服务;它证明的是这些文档中的授权、报告与策略交集行为,实际应用改变强制策略前仍需验证有代表性的页面功能。

延伸阅读

来源