一个声明了媒体类型的响应,让浏览器按正确方式解析它。

Content-Type 告诉读它的人这是什么

Content-Type 描述发送方打算提供的表示,但它不会检查正文、转换字节,也不保证所有接收方都采取相同的处理方式。可靠的接口需要让序列化结果、媒体类型声明和消费者的预期相互吻合。这个要求不仅适用于成功响应,也适用于认证失败、代理报错和应用异常等不同的返回路径。 描述表示,而不是文件名 对于携带正文的普通响应,application/json 表示 JSON 数据,text/html 表示 HTML。类型描述的是解除内容编码之后的数据格式。因此,一份经过 gzip 压缩的 JSON 响应仍然声明 Content-Type: application/json,同时用 Content-Encoding: gzip 说明压缩。内容编码属于表示的属性,不等于 HTTP 消息如何分帧;例如分块传输编码处理的是另一层问题。修改文件扩展名或响应头都不会自动完成这些转换。 媒体类型由主类型、子类型以及可选参数构成。主类型和子类型不区分大小写,参数是否有意义则取决于对应媒体类型的定义。消费者应使用已有的头字段解析接口,不要把完整字段与一个固定字符串直接比较。同时必须单独检查字段是否存在,因为部分解析器会给缺失类型提供默认值,掩盖服务端漏发元数据的事实。多个相互冲突的类型字段也不是合法的候选列表,而是需要在源站或代理处修正的缺陷。 对于 UTF-8 编码的 HTML,text/html; charset=utf-8 同时描述格式与字符编码。但不能据此给每一种文本格式统一追加字符集参数。RFC 8259 要求非封闭生态系统之间交换的 JSON 使用 UTF-8,而 application/json 的媒体类型注册没有定义 charset 参数。接收方不能靠另加一个参数,就把不符合该交换约定的字节变成合规 JSON。类型参数必须服从格式本身的规定,而不是套用全站通用字符串。 坏掉的样子 常见故障是反向代理把 API 响应替换成 HTML 登录页,却仍返回成功状态码。客户端一上来就调用 JSON 解析器,只能看到语法错误,反而隐藏了“收到另一种表示”这个更有价值的线索。另一种情况恰好相反:应用错误处理器输出 HTML,却沿用了成功路径的 JSON 响应头。这时仅检查声明也会放行无效正文。所以,状态码、响应元数据和实际字节是三个需要一起检查的证据。 ...

十月 10, 2026 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调记住 HTTPS 并明确策略范围。

HSTS 让浏览器记住 HTTPS

HTTP 严格传输安全 HSTS 告诉支持它的客户端:以后访问记住的主机名时使用 HTTPS。它可以消除重定向之前的那次明文 HTTP 请求,但前提是客户端已经通过此前可信响应或受支持的预加载机制知道策略。只在服务器添加一个 Header,不能追溯保护未知客户端第一次发出的 HTTP 请求。 客户端记住的是主机名 客户端通过没有安全错误的 HTTPS 收到有效 Strict-Transport-Security 后,才建立或刷新策略。max-age 是从接收时刻起计算的秒数。明文 HTTP 上的同名 Header 不能建立这种信任;后来某次响应没有 Header,也不会删除尚未过期的记录。状态由各个客户端分别维护,浏览器之间不存在自动共享的统一缓存。 策略范围是 DNS 主机名,不是包含协议和端口的 HTTP origin。includeSubDomains 向下覆盖子域名,不覆盖父域或同级名称。升级 URL 时,显式的 80 端口会改为 443,其他显式端口保持不变。因此,原本在另一个端口上提供的 HTTP-only 服务,也可能在客户端学习策略后变得不可用。 这条规则改变的是客户端处理 URL 的方式,不会自动把服务器的明文监听器变成 TLS 监听器;升级后的目标必须事先具备 HTTPS 服务。下面的实验故意在 HTTP 响应中发送 HSTS,只作为反向测试,并不是部署建议。两个测试服务都不返回重定向,因此从 HTTP URL 进入 TLS handler,证明的是客户端主动升级,而非成功跟随了一串服务端跳转。 ...

十月 8, 2026 · 4 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

安全响应头需要可观察的浏览器行为

安全响应头让支持它们的浏览器执行具体规则,价值来自这些规则改变了什么行为,而不是检查清单有多长。即使是静态站点,也涉及文档加载、脚本或样式、页面嵌入、导航和传输等边界。应根据真实页面及交付路径选择策略,同时验证预期限制和合法功能。不能把响应里出现一串熟悉字段,当成已经证明页面完整、安全且不会破坏正常集成。 内容安全策略可以限制资源加载与脚本执行,嵌入祖先指令规定哪些祖先能够包含受保护文档,引用来源策略控制适用请求携带多少源地址信息,权限策略则约束选定的浏览器功能。这些控制相互关联,但没有哪一个只凭字段存在,就能建立经过认证的业务权限。适合某类响应的规则,放到另一类响应上可能毫无意义,也可能妨碍页面原本需要的行为。 理解每个响应头负责的边界 严格传输安全策略告诉浏览器,对适用的主机使用 HTTPS。浏览器从有效的 HTTPS 响应学习该策略,普通 HTTP 响应不能建立它。包含子域名的设置还会影响当前应用之外的主机,因此这些主机需要支持预期的安全传输方式。这个响应头不会签发证书,也不会修复已经过期的证书;在依赖浏览器长期记住的策略之前,传输配置本身必须已经可靠。 跨源开启者策略影响浏览上下文和开启者隔离,跨源资源策略则限制某些跨源资源加载。把二者统称为隔离所有窗口的通用开关,会混淆各自约定。CORS 响应暴露又是另一套机制。修改相关策略时,应验证依赖弹出窗口、嵌入资源或跨源访问的实际集成,而不是把能找到的所有响应头复制到同一条规则中,再期待它们自动组合成正确结果。 坏掉的样子 nosniff 让浏览器处理资源时尊重声明的内容类型。对于脚本和样式表目标,如果响应的 MIME 类型不适合该目标,浏览器会拒绝它。因此,一个把 JavaScript 当成纯文本交付的部署,可能在启用该响应头后停止工作。应修复实际交付的字节、类型和状态,不能只删除限制,直到现象消失。策略暴露了交付错误,并不说明策略本身就是错误的。 另一种常见问题,是脚本或样式路径实际返回了 HTML 错误页。正确策略可能让这个原本存在的路由错误更容易被发现。应检查经过重定向、缓存和路由之后真正收到的响应,包括状态与内容类型。只验证安全头是否存在,无法知道收到的是不是目标资源;首页请求成功,也不能证明错误响应和静态文件都遵循了各自的交付约定。 快速验证 保存代码为 nosniff.py,执行 python3 nosniff.py –serve。先打开打印出的地址并加上 ?mode=correct,再分别测试 ?mode=strict 和 ?mode=loose。本次使用 Python 3.14.4 与 Chrome 152。临时回环服务在三种情况下返回完全相同的经典脚本字节,只改变内容类型及 nosniff 响应头。页面报告脚本是否执行、加载是否失败,结束后按控制键加 C 关闭服务。 import json 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_GET(self): url = urlsplit(self.path) mode = parse_qs(url.query).get("mode", ["correct"])[0] assert mode in {"correct", "strict", "loose"} headers = {"Cache-Control": "no-store"} if url.path == "/payload.js": body = b"window.payloadExecuted = true;" headers["Content-Type"] = "application/javascript" if mode == "correct" else "text/plain" if mode != "loose": headers["X-Content-Type-Options"] = "nosniff" else: body = ("""<!doctype html><meta charset="utf-8"><title>MIME policy lab</title> <h1>Script MIME policy</h1><pre id="result">Running...</pre><script> const mode=""" + json.dumps(mode) + """; const script=document.createElement('script'); script.src='/payload.js?mode='+mode; function done(failed) { window.labResult={mode,executed:window.payloadExecuted===true,loadFailed:failed}; document.getElementById('result').textContent=JSON.stringify(window.labResult,null,2); } script.onload=()=>done(false);script.onerror=()=>done(true);document.head.append(script); </script>""").encode() headers["Content-Type"] = "text/html; charset=utf-8" self.send_response(200) self.send_header("Content-Length", str(len(body))) for key, value in headers.items(): self.send_header(key, value) 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: for mode in ("correct", "strict", "loose"): with urlopen(origin + "/payload.js?mode=" + mode, timeout=5) as response: assert response.read() == b"window.payloadExecuted = true;" assert response.headers["Content-Type"] == ("application/javascript" if mode == "correct" else "text/plain") assert response.headers.get("X-Content-Type-Options") == (None if mode == "loose" else "nosniff") print(json.dumps({"app_url": origin, "wire_mime_checks": "PASS"}), flush=True) if "--serve" in sys.argv: Event().wait() except KeyboardInterrupt: pass finally: server.shutdown() thread.join(timeout=5) server.server_close() 正确模式使用有效的 JavaScript MIME 类型并携带 nosniff,脚本应正常执行。严格模式把同样的字节标为纯文本,同时保留 nosniff,因此执行标志为假,加载错误标志为真。宽松模式去掉纯文本响应上的 nosniff,本次被测浏览器会执行该经典脚本。线上响应探针还分别检查了字节内容确实一致,以及相关响应字段按预期变化,避免把不同代码的执行结果误当成类型策略差异。 ...

九月 30, 2026 · 2 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

CSP 明确浏览器中的执行规则

内容安全策略告诉支持它的浏览器,一个文档允许哪些动作。获取资源类指令分别控制脚本、样式、图片等类别,其他指令则负责不同边界。策略通常通过文档响应头提供,为浏览器行为增加约束,但不会修复注入漏洞、认证应用用户,也不会保证所有可能的脚本执行方式都已消失。应把它理解为具有具体作用范围的一层防护,而不是只要字段存在就获得完整安全保证。 缺失某个指令并不统一表示禁止。一些获取资源类指令会回退到默认来源指令,而嵌入父级、基础地址和表单目标等边界需要分别考虑。只包含脚本限制的策略,并不会自动完整限制图片加载、页面嵌入和表单提交。审阅时要逐项理解指令范围,尤其不能把默认来源写成全部拒绝后,就假定那些不继承该默认值的功能也被关闭。 为真正需要运行的脚本授权 随机数策略为经过明确选择的脚本元素附加不可预测的值,并在响应策略中声明同一个值。每次响应应生成新的随机数,只把它写到服务端确实准备授权的脚本上。如果自动给发现的每一个脚本元素都补上随机数,被注入的脚本也可能同时得到许可。构建时生成一次、以后持续复用的固定值,也失去了这种设计所依赖的逐响应不可预测性。 同一条来源指令中的表达式通常表示多个可选条件。把允许的主机与随机数写在一起,并不意味着脚本必须同时满足这两个条件。对于内容固定的静态页面,可以使用经过批准脚本的内容哈希,脚本精确内容发生变化时同步更新策略。需要根据页面怎样生成来选择方案;随意加入宽泛的内联执行许可,可能直接撤销原本希望通过随机数建立的限制。 坏掉的样子 开始强制执行后,合法的内联脚本、事件处理器、动态生成内容或此前没有注意到的依赖,都可能突然失效。仅报告模式能够在允许动作继续发生的同时观察违规,所以页面在该模式下正常运行,并不能证明强制策略也会允许它。报告还需要按含义解释:某条图片违规不能证明目标内联脚本被拦截,报告数量少也不能替代对关键页面功能的实际检查。 多份强制策略会共同生效。代理新增一份较宽松的响应头,并不会放宽已经存在的严格策略,因为动作必须满足每一份适用的强制策略。这可以解释为什么一个看起来正确的随机数仍然无法让脚本执行。应该检查最终交付的全部策略头和浏览器报告的有效指令,再修正真正需要的边界或合法资源用法,不能假定最后出现的字段自然覆盖此前所有限制。 快速验证 保存代码为 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 后,两个脚本标志都变为真,而违规处置方式变成报告。浏览器还可能记录其他资源违规,因此应按事件字段定位目标问题,不应把报告总数固定为一条,再据此误判执行结果。 ...

九月 10, 2026 · 2 分钟 · XB Field Notes