观察、决策、行动三个方框,旁边强调记住 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
三个相连的方块,依次标注观察、决策与行动。

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

安全响应头让支持它们的浏览器执行具体规则,价值来自这些规则改变了什么行为,而不是检查清单有多长。即使是静态站点,也涉及文档加载、脚本或样式、页面嵌入、导航和传输等边界。应根据真实页面及交付路径选择策略,同时验证预期限制和合法功能。不能把响应里出现一串熟悉字段,当成已经证明页面完整、安全且不会破坏正常集成。 内容安全策略可以限制资源加载与脚本执行,嵌入祖先指令规定哪些祖先能够包含受保护文档,引用来源策略控制适用请求携带多少源地址信息,权限策略则约束选定的浏览器功能。CSP 的 frame-ancestors 必须通过 HTTP 响应头交付;写在 meta 元素里不会建立这个嵌入策略。这些控制相互关联,但没有哪一个只凭字段存在,就能建立经过认证的业务权限。适合某类响应的规则,放到另一类响应上可能毫无意义,也可能妨碍页面原本需要的行为。 理解每个响应头负责的边界 严格传输安全策略告诉浏览器,对适用的主机使用 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 关闭服务。 ...

九月 30, 2026 · 2 分钟 · XB Field Notes
用于检查凭据过期是否强制生效的观察、决策和执行面板。

凭据到期必须由验证方执行

只有负责决定是否允许访问的系统检查了适用截止时间,凭据才真的会到期。在资产清单里写一个日期,不会自动改变数据库或接口接受什么。较短有效期可以压缩被盗凭据继续建立新访问的时间窗口,但具体效果取决于认证机制,以及此前已经建立的会话、签发的其他凭据如何处理。秘密不是因为自身附带一行日期,就能独立让所有访问失效。 坏掉的样子 到期不会撤销已经发生的行为。复制出去的数据仍在外面,截止时间之前建立的会话也可能继续工作。攻击者还可能在原本有效的权限下建立另一条访问路径。因此,有效期只是限制后续使用的一种手段,还要结合权限范围、撤销机制、会话管理和对实际影响的调查。它并不能为一次泄漏造成的全部损失设定通用上限,也不能把过去的访问自动变成没有发生过。 PostgreSQL 的 VALID UNTIL 控制角色密码的有效时间,不控制角色是否存在,也不覆盖所有认证方式。使用非密码方式建立连接,就不会获得同样的截止检查。如果通过本地信任规则来测试密码到期,便可能误以为到期功能失效。判断结果之前,应先确认客户端实际命中了哪条认证路径,不能只看两次连接用了同一个用户名。 轮换与续期是两个不同变化。在 PostgreSQL 中,仅修改 PASSWORD 会保留 VALID UNTIL,仅修改 VALID UNTIL 也会保留原密码。更新流程如果只修改其中一项,可能导致应用依然无法重新连接,也可能让原值继续有效。一个连接池里的旧连接查询成功,不足以证明新凭据能够完成新的认证,因为它可能根本没有重新交出密码接受检查。 快速验证 准备隔离的 Linux 实验环境,让 PostgreSQL 18 程序位于 PATH 中,以非 root 用户运行,并确认回环端口 55477 未被使用。将下列内容保存为脚本,用 sh 执行。它初始化一次性数据库集群,只使用明确虚构的密码,TCP 仅监听回环地址,退出时删除临时目录。本地信任认证只为展示另一条认证路径而启用,不应照搬到真实服务: #!/bin/sh set -eu lab_root=$(mktemp -d /tmp/credential-expiry.XXXXXX) cleanup() { if test -f "$lab_root/data/postmaster.pid"; then pg_ctl -D "$lab_root/data" -m immediate -w stop >/dev/null fi rm -rf "$lab_root" } trap cleanup EXIT initdb -D "$lab_root/data" --locale=C --auth-local=trust --auth-host=scram-sha-256 >/dev/null pg_ctl -D "$lab_root/data" -l "$lab_root/server.log" \ -o "-k $lab_root -p 55477 -c listen_addresses=127.0.0.1" -w start >/dev/null admin() { psql -X -qAt -v ON_ERROR_STOP=1 -h "$lab_root" -p 55477 -d postgres "$@" } # Both passwords below are invented, disposable lab fixtures. admin <<'SQL' SET password_encryption='scram-sha-256'; CREATE ROLE article_expiry LOGIN PASSWORD 'LAB-ONLY-OLD-PASSWORD'; SQL mkfifo "$lab_root/input" PGPASSWORD=LAB-ONLY-OLD-PASSWORD psql -X -qAt -v ON_ERROR_STOP=1 \ -h 127.0.0.1 -p 55477 -U article_expiry -d postgres \ <"$lab_root/input" >"$lab_root/held.out" 2>"$lab_root/held.err" & held_pid=$! exec 3>"$lab_root/input" printf "SELECT 'ready';\n" >&3 attempt=0 until grep -qx ready "$lab_root/held.out"; do attempt=$((attempt + 1)) test "$attempt" -lt 100 sleep 0.05 done admin <<'SQL' SELECT format('ALTER ROLE article_expiry VALID UNTIL %L', clock_timestamp()+interval '1 second') \gexec SELECT pg_sleep(1.2); SELECT 'deadline passed='||(rolvaliduntil<clock_timestamp()) FROM pg_authid WHERE rolname='article_expiry'; SQL if PGPASSWORD=LAB-ONLY-OLD-PASSWORD psql -X -qAt -h 127.0.0.1 -p 55477 \ -U article_expiry -d postgres -c 'SELECT 1' >"$lab_root/new.out" 2>"$lab_root/new.err"; then exit 1 fi grep -q 'password authentication failed' "$lab_root/new.err" printf '%s\n' 'fresh SCRAM login rejected after expiry' printf "SELECT 'held after expiry='||current_user;\n" >&3 psql -X -qAt -v ON_ERROR_STOP=1 -h "$lab_root" -p 55477 \ -U article_expiry -d postgres -c "SELECT 'local trust login='||current_user" admin <<'SQL' SET password_encryption='scram-sha-256'; ALTER ROLE article_expiry PASSWORD 'LAB-ONLY-NEW-PASSWORD'; SQL if PGPASSWORD=LAB-ONLY-NEW-PASSWORD psql -X -qAt -h 127.0.0.1 -p 55477 \ -U article_expiry -d postgres -c 'SELECT 1' >"$lab_root/rotated.out" 2>"$lab_root/rotated.err"; then exit 1 fi grep -q 'password authentication failed' "$lab_root/rotated.err" printf '%s\n' 'rotating password alone preserves expired deadline' admin <<'SQL' SELECT format('ALTER ROLE article_expiry VALID UNTIL %L', clock_timestamp()+interval '1 hour') \gexec SQL if PGPASSWORD=LAB-ONLY-OLD-PASSWORD psql -X -qAt -h 127.0.0.1 -p 55477 \ -U article_expiry -d postgres -c 'SELECT 1' >"$lab_root/old.out" 2>"$lab_root/old.err"; then exit 1 fi grep -q 'password authentication failed' "$lab_root/old.err" printf '%s\n' 'old password rejected after rotation' PGPASSWORD=LAB-ONLY-NEW-PASSWORD psql -X -qAt -v ON_ERROR_STOP=1 \ -h 127.0.0.1 -p 55477 -U article_expiry -d postgres \ -c "SELECT 'new password login='||current_user" printf "SELECT 'held after rotation='||current_user;\n" >&3 printf '\\q\n' >&3 exec 3>&- wait "$held_pid" grep -qx 'held after expiry=article_expiry' "$lab_root/held.out" grep -qx 'held after rotation=article_expiry' "$lab_root/held.out" cat "$lab_root/held.out" 此前 PostgreSQL 18.6 记录中的输出确认截止时间已经过去;本轮没有重跑认证实验。脚本把期限设为数据库当前时间的一秒之后,然后等待数据库时钟越过它,所以这里实际经历了到期,而不是只拿一个编造日期做字符串比较。随后,通过 SCRAM 密码认证建立的新连接被拒绝,验证了真正决定认证结果的服务端规则。 ...

九月 27, 2026 · 3 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调设置 TLS 下限并验证正反两侧。

设定 TLS 版本下限并验证边界

TLS 版本策略决定一个端点愿意接受哪些握手。应明确最低版本,而不是假定库每次升级后的默认值都符合自己的要求。最高版本是另一项独立约束,没有必要地把它固定死,可能阻止以后采用库已经支持的新协议。无论设置了哪种上下限,都不能替代证书身份验证、合适的算法以及持续的安全更新。 最低版本与最高版本是两回事 RFC 8996 已废弃 TLS 1.0 和 1.1;RFC 9325 同时排除 SSLv2、SSLv3,建议支持 TLS 1.3 并在实现支持时优先协商它,也给出了继续使用 TLS 1.2 时应满足的安全要求。对于客户端群体明确的服务,可以评估兼容性后只允许 TLS 1.3;但不能因为存在更严格的配置,就把所有 TLS 1.2 连接一律说成不安全。 双方都支持 TLS 1.3 时,服务器同时允许 TLS 1.2,不等于攻击者可以随意强迫它们选择最弱的版本。TLS 有经过认证的协商过程,也有针对降级攻击的保护。不过,真实的旧版客户端仍然可能合法选择服务器允许的较旧版本,不安全的重试逻辑也可能削弱策略。最低版本应在每条新连接上执行,不能把“最后总算连上”当作唯一验收标准。 抓包里的版本数字还必须结合字段含义解读。TLS 1.3 为兼容性把 hello 中的旧版本字段设为 0x0303,实际版本则通过 supported_versions 协商。因此,单独看到这个旧字段,不能断定连接正在使用 TLS 1.2。有条件时应读取 TLS 库提供的最终协商状态,而不是从某一个兼容字段猜测协议。 ...

九月 23, 2026 · 3 分钟 · XB Field Notes
两行标题旁边的三个相连方块,依次标注 Observe、Decide 与 Act。

凭据应留在仓库历史之外

凭据一旦提交到 Git,就成为版本化对象关系的一部分,协作者和自动化系统都有可能复制它。从最新目录树删除文件,并不会删除先前包含它的提交。实际应建立的边界,是让凭据值不进入源码历史,再通过获得授权的运行时机制供应。仓库配置可以说明怎样取得秘密,而不需要把秘密本身写进可复制的源文件。 坏掉的样子 一个看起来干净的检出,可能掩盖历史版本中已经暴露的值。新克隆通常会收到可达历史,包括已删除文件的旧版本。不能说 Git 在任何清理策略下都会永远保留所有对象,但“删掉今天的文件”确实不等于清理历史。即使重写一个仓库,也无法召回已经独立分发出去的克隆、分叉、缓存和构建产物,清理某一位置不能代表所有副本都已消失。 面对真实泄漏,应先撤销或替换相关凭据,并确认哪些系统会接受它。删除一行改变的是发布内容,不是认证状态。历史重写应单独协调,因为它会改变提交标识,也可能打断协作者的工作。说明事件时,不能再把原值复制到问题单、提交说明、扫描报告或事故笔记里,否则处理过程本身又制造了新的传播位置。 运行时注入也不会自动保密。环境值可能被子进程继承或被诊断工具采集;文件可能被具有足够访问权限的身份读取。应限制消费服务的权限,控制交付通道,避免把真实值写入日志和生成结果。即使文件位于仓库外,也仍然需要明确的拥有者与生命周期,不能把“没有提交到 Git”当作全部秘密管理工作已经完成。 快速验证 在安装了 Git 的 POSIX 系统上运行下列 Python 脚本。它使用明确的虚构标记,绝不使用任何有效凭据;两个仓库和运行时文件都位于同一个临时目录,退出后一起清理。夹具排除继承的 GIT_* 覆盖值、系统及全局配置和模板钩子,记录中的 Git 版本为 2.50.1: from pathlib import Path from tempfile import TemporaryDirectory import io,os,stat,subprocess,sys,tarfile env={key:value for key,value in os.environ.items() if not key.startswith('GIT_')} env.update(GIT_CONFIG_NOSYSTEM='1',GIT_CONFIG_GLOBAL=os.devnull) with TemporaryDirectory(prefix='credential-repo-lab-') as directory: base=Path(directory);repo=base/'source';repo.mkdir() def git(*args,cwd=repo,binary=False): result=subprocess.run(['git',*args],cwd=cwd,env=env,capture_output=True,text=not binary) assert result.returncode==0,result.stderr return result.stdout git('init','-q','--template=');git('config','user.name','Credential Lab') git('config','user.email','credentials@example.invalid') git('config','commit.gpgsign','false') dummy='EXAMPLE-ONLY-NOT-A-REAL-SECRET\n' (repo/'fixture.env').write_text(dummy) (repo/'app.txt').write_text('read the configured credential file at runtime\n') git('add','--','fixture.env','app.txt');git('commit','-qm','Synthetic exposure fixture') original=git('rev-parse','HEAD').strip() git('rm','--','fixture.env');git('commit','-qm','Remove fixture from current tree') assert not (repo/'fixture.env').exists() assert git('show',original+':fixture.env')==dummy clone=base/'clone';git('clone','-q','--no-local',str(repo),str(clone)) assert not (clone/'fixture.env').exists() assert git('show',original+':fixture.env',cwd=clone)==dummy print('current file absent; historical fixture readable in source and clone') archive=git('archive','--format=tar','HEAD',binary=True) with tarfile.open(fileobj=io.BytesIO(archive),mode='r:') as bundle: assert bundle.getnames()==['app.txt'] print('current-tree archive excludes fixture; Git history still contains it') runtime=base/'runtime';runtime.mkdir(mode=0o700) secret_file=runtime/'credential';secret_file.touch(mode=0o600) secret_file.write_text(dummy) assert stat.S_IMODE(secret_file.stat().st_mode)==0o600 child_env=dict(env,CREDENTIAL_FILE=str(secret_file)) child="from pathlib import Path; import os; p=Path(os.environ['CREDENTIAL_FILE']); assert p.read_text(); print('credential loaded without printing its value')" result=subprocess.run([sys.executable,'-c',child],cwd=clone,env=child_env,text=True,capture_output=True) assert result.returncode==0,result.stderr assert dummy.strip() not in result.stdout+result.stderr print(result.stdout.strip()) 第一项确认,两个当前工作区都没有样本文件,但都能通过早先的提交读取它的原始内容。克隆使用普通 Git 传输路径,关闭本地硬链接优化,因此验证的是另一个实际建立起来的对象数据库,不只是两个路径恰巧指向同一份文件。脚本在内部比较虚构标记,结果只说明是否保留,没有打印它的值,保留了检查与泄漏输出之间的区别。 ...

九月 14, 2026 · 1 分钟 · XB Field Notes
两行标题旁边的三个相连方块,依次标注 Observe、Decide 与 Act。

CORS 决定脚本能否读取响应

跨源资源共享允许服务端选择是否向其他来源的脚本暴露响应。一个来源由协议、主机和端口组成,因此即使在同一台机器上,端口不同的两个服务也可以属于不同来源。浏览器会结合发起页面的来源检查 CORS 响应头。这套机制不会认证当前用户,也不会把接口变成只能从私有网络访问的服务。它回答的是特定页面的脚本能否获得响应内容,而不是业务操作本身是否获准。同站 Cookie 规则与同源响应访问也不是同一条边界,不能只看主机名相似,就推断两种机制都会采用相同结果。 对于允许访问的页面,接口可以在 Access-Control-Allow-Origin 中返回该页面序列化后的来源,浏览器再检查其他相关条件,决定是否暴露响应。某些公开供无凭据跨源读取的资源可以使用通配符,但通配符不能授权凭据模式为 include 的请求。服务端应根据明确规则选择允许的来源,不能把客户端任意传入的来源原样回显,就当成已经完成了访问策略判断。 分清请求送达与响应可读 一部分通常称为简单请求的操作不需要 CORS 预检。例如使用纯文本内容类型、且脚本只设置安全列表内请求头的 POST,可以满足相关条件。这里的安全列表是浏览器协议术语,并不意味着接口只读,也不意味着请求不会修改数据。普通 HTML 表单早已能够跨来源发送相似请求,而无须事先取得读取目标响应的权限,因此不能把预检有无理解成业务副作用大小。 浏览器完全可能先发送请求,收到二百状态响应,然后因为响应暴露没有获准而让脚本的 fetch 失败。此时服务端可能已经执行完操作。普通非浏览器 HTTP 客户端也不会执行这项页面来源规则。认证、授权以及防止非预期跨站操作,需要独立的服务端设计;不能依靠是否返回某个 CORS 响应头,替代对请求身份、权限和业务意图的检查。 坏掉的样子 常见故障从“浏览器已经拦截,所以服务端什么都没做”这个误判开始。用户重试一个会改变状态的请求,结果因为第一次已经成功执行而造成重复操作。重试之前应检查服务端证据,并明确业务操作自己的重复执行语义。对于依靠 Cookie 认证的状态修改,还要采用合适的跨站请求伪造防护。仅仅不返回允许来源头,不能代替判断这次修改是否应该被接受。 另一种错误,是把所有 fetch 类型错误都当成 CORS 配置问题。网络失败也会导致请求承诺被拒绝,因此要结合浏览器诊断信息、实际收到的响应头和服务端请求记录判断。把请求改成 no-cors,也不会让应用取得普通可读响应;不透明响应的限制决定了它不能成为需要消费接口数据时的通用修复方法。应修正真实的协议或权限配置,而不是只让某条错误提示消失。 快速验证 将代码保存为 cors-simple.py,运行 python3 cors-simple.py –serve,再打开打印出的本机回环页面地址。本次使用 Python 3.14.4 和 Chrome 152 检查。脚本在不同临时端口启动页面服务与接口服务,唯一的状态修改是往内存列表追加合成事件。结束后按控制键加 C 关闭两个服务。实验不涉及账号、Cookie 或生产接口,两个来源只是为了直接观察浏览器真实执行的跨源规则。 ...

九月 13, 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
一个没有脚本通道的浏览器窗口,CSP 列出 script-src 'none'。

零 JavaScript 静态站是可辩护的选择

显式的 script-src ’none’ 策略是一道有可测量契约的边界。

八月 13, 2026 · 1 分钟 · XB Field Notes