服务器全程返回200、订单已入库,用户眼前那块区域自始至终是空白
本文目录
- 一个页面能拿到200,还能一个字都不显示吗?
- 16种组合跑下来的总账
- 被拦时浏览器控制台说了什么
- 服务器那边到底看见了什么?
- 你的监控为什么全是绿的
- 唯一能在服务端分辨成败的信号
- 同一个响应头里的指令,为什么有的拦在请求发出前、有的拦在响应到达后?
- 第三方服务器的到达记录
- 两派指令的分界线
- 为什么是这样设计的
- 明明配了策略,为什么还是被人整页嵌走?
- 第一个陷阱:default-src不管这一格
- 第二个陷阱:写在meta里等于没写
- 第三个陷阱:通配符不匹配裸域
- 现网里那份白名单都写了些什么
- X-Frame-Options和frame-ancestors同时写,到底谁说了算?
- 两个方向都测了
- 现网里有多少站的两个头在互相打架
- 那老头还要不要留
- nginx里加一行无关的响应头,为什么整站的CSP就没了?
- 拿一个测试站验一遍
- always决定错误页带不带策略
- 两条CSP头不会互补,只会互相收紧
- 配了违规上报,为什么一条报告都收不到?
- 两种机制,四组对照
- 三个结论,一个比一个难受
- 两种格式的字段名完全不一样
- 报告里那个字段,等于官方承认
- 现网里配了上报的站,有几个真能收到
- 被拦的那个页面,Google那边算什么?
- 三种被拦法,对索引的后果完全不同
- 越靠近交易,策略越不是你写的
- 排查这类故障,该按什么顺序问问题?
- 先分清楚是哪一派
- 然后按这张表逐项对
- 三个能立刻上手的动作
- 要不要现在就配
- 常见问题解答
- 我的站被跨站嵌入了,会不会影响排名?
- Report-Only模式能不能长期开着?
- 为什么我用curl看响应头,看到的和浏览器里的不一样?
- 把CSP写在CDN那一层,会不会更省事?
- 移动端App里的WebView也吃这一套吗?
- 违规报告应该发到哪儿,自己搭还是用现成的?
- 我怎么知道站上现在到底有多少个第三方界面被嵌在页面里?
- 如果第三方服务商的域名变了,我怎么第一时间知道?
- 权威参考资料
摘要:有一类故障,服务器日志里干干净净,状态码全是200,订单也确实落库了,用户和爬虫却只看到一块空白。原因是拦下这个页面的判决压根不在服务器上执行——它写在响应头里,由浏览器读完之后自己动手。更麻烦的是,同一个响应头里的不同指令,动手的时机还不一样:一类在请求离开浏览器之前就掐断,另一类等服务器把内容全部送到了才拒绝渲染。这两类给排查带来的信号正好相反。
一个页面能拿到200,还能一个字都不显示吗?
能,而且这种情况比想象中常见。保哥在排查一个跨境独立站的支付回跳问题时撞见过:后台订单表里躺着一笔成功的付款,网关的回调记录也正常,服务器访问日志里那次请求写着200,可用户截图发过来,屏幕中间那块区域从头到尾是白的。
把问题拆到最小,它长这样:浏览器把请求发出去了,服务器把响应回来了,浏览器把响应头读完了,然后决定不把内容给用户看。整个过程里,服务器没有任何一个环节会知道最后那一步发生了什么。
为了把这件事的边界摸清楚,我搭了一套最小的双域实验台:一个域放落地页(相当于商户站),另一个注册域放父页面(相当于把落地页嵌进来的网关或挑战窗口)。落地页按16种不同的响应头组合下发,父页面用iframe去嵌它。每一次请求都在落地页这一侧落盘。
16种组合跑下来的总账
| 观测层 | 渲染成功 | 被拦下 |
|---|---|---|
| 组合数 | 7种 | 9种 |
| 浏览器把请求发出去了 | 7/7 | 9/9 |
| 服务端日志里有这次请求 | 7/7 | 9/9 |
| 响应状态码 | 200 | 200 |
父页面的load事件触发了 | 7/7 | 9/9 |
父页面的error事件触发了 | 0/7 | 0/9 |
| 页面内脚本回打的信标到达 | 6/7 | 0/9 |
这张表里最刺眼的是倒数第二、第三行。无论页面有没有被拦,父页面拿到的都是load事件,一次error都没有。也就是说,前端同学最自然的那种兜底写法——给iframe挂个onerror,出问题就换个提示——永远不会跑。它等的那个事件不会来。
被拦时浏览器控制台说了什么
Framing 'https://…' violates the following Content Security Policy directive: “frame-ancestors 'self'”. The request has been blocked.
网络面板那一行标的是net::ERR_BLOCKED_BY_RESPONSE。这个错误码的措辞其实很老实:被响应阻断,不是被网络阻断,也不是被服务器阻断。响应本身好端端地到了,是它带来的那句话让浏览器停了手。
服务器那边到底看见了什么?
这是本文里最值得单独看一眼的一段日志。下面五条来自五个不同的被拦组合,是落地页服务端自己记下来的:
{"method":"GET","uri":"/…?v=self", "sfd":"iframe","sfm":"navigate","sfs":"cross-site","ref":"https://…/"}
{"method":"GET","uri":"/…?v=xfodeny", "sfd":"iframe","sfm":"navigate","sfs":"cross-site","ref":"https://…/"}
{"method":"GET","uri":"/…?v=double", "sfd":"iframe","sfm":"navigate","sfs":"cross-site","ref":"https://…/"}
{"method":"GET","uri":"/…?v=report", "sfd":"iframe","sfm":"navigate","sfs":"cross-site","ref":"https://…/"}
{"method":"GET","uri":"/…?v=wild", "sfd":"iframe","sfm":"navigate","sfs":"cross-site","ref":"https://…/"}请求方法、路径、来源页、三个Sec-Fetch字段,一样不缺。这些请求全都被浏览器拒绝渲染了,可服务端这边看起来一切正常。如果这个落地页是个会写库的接口,它已经写完了;如果它是个会发确认邮件的页面,邮件已经发出去了。
你的监控为什么全是绿的
把常见的几种可观测手段挨个过一遍,就明白为什么这类故障能安安静静躺很久:
- 访问日志:有记录,状态码200,一切正常。
- 可用性拨测:拨测通常直接请求URL,不会把它嵌进另一个域的页面里,所以永远测不到。
- 错误率看板:分母分子都没变化,曲线是平的。
- 业务数据:订单还在涨,因为下单那一步早就完成了。
- 客服工单:会有,但描述是“页面打不开”“卡住了”,路由到前端或网络那边,绕一大圈。
这套组合拳的效果就是:按掉量幅度设阈值的那套SEO监控告警体系抓不到它,因为流量没掉;从服务器日志反推抓取行为的那套方法也抓不到它,因为日志里根本没有异常特征。这跟当年记事本存文件带出BOM导致的白屏有点像——现象都是白,但那一类至少在源码里留了痕迹,这一类连痕迹都在别人机器上。
唯一能在服务端分辨成败的信号
我在落地页里埋了一枚“渲染信标”:页面加载完,脚本回打一次同源请求。结果是7个渲染成功的组合里6个信标到达,9个被拦的组合信标全是0。
剩下那1个没到的,恰好带出一个更值得记的教训,下面会讲到。这里先把结论摆出来:信标到了,一定说明页面渲染了;信标没到,不一定说明没渲染。它是充分条件,不是必要条件。用它做告警要按这个方向设计,反过来会误报。
同一个响应头里的指令,为什么有的拦在请求发出前、有的拦在响应到达后?
这是整件事里最反直觉、也最有用的一条。同一个Content-Security-Policy头,里面写的不同指令,执行时机分成两派。
为了把这条钉死,我换了个实验台:落地页向另一个域要三类资源——外部脚本、fetch请求、图片,而那个域的服务端每收到一次就记一笔。谁的日志里空着,就说明请求根本没离开浏览器。
第三方服务器的到达记录
| 落地页下发的策略 | 外部脚本 | fetch | 图片 |
|---|---|---|---|
| 不发任何策略(对照组) | 到达 | 到达 | 到达 |
default-src 'self' | 0 | 0 | 0 |
script-src 'self' | 0 | 到达 | 到达 |
connect-src 'self' | 到达 | 0 | 到达 |
img-src 'self' | 到达 | 到达 | 0 |
| Report-Only模式 | 到达 | 到达 | 到达 |
看清楚这几个0的含义:被这几条指令拦下的请求,第三方服务器一条日志都没有。它不知道有人来过,也不知道自己被拒了。请求在浏览器内部就被掐断,连一个字节都没发出去。
两派指令的分界线
把两个实验并排放,分界线就清楚了:
请求发出前拦(script-src这一派) | 响应到达后拦(frame-ancestors) | |
|---|---|---|
| 对方服务器收到请求了吗 | 没有 | 收到了 |
| 对方服务器有日志吗 | 没有 | 有,而且是200 |
| 对方的业务逻辑跑了吗 | 没跑 | 跑完了 |
| 你去问对方“你收到我请求了吗” | 答“没有” | 答“收到了,我还回了200” |
| 排查时你会往哪个方向查 | 网络、DNS、防火墙 | 对方系统、参数、幂等 |
最后一行才是真正的坑。两派故障都会把排查引到错误的方向去,而且是相反的两个错误方向。第一类让你以为是网络不通,其实网络好得很;第二类让你以为对方处理得没问题,其实用户根本没看到结果。
这条差别在做客户端渲染与服务端渲染对抓取影响的对照测试时同样成立:一个被策略掐掉的第三方脚本,在服务端视角是“从未发生”,在渲染结果里却是“内容缺了一大块”。
为什么是这样设计的
其实很合理。script-src管的是“这个页面能不能加载它”,判断只需要一个地址,不需要看内容,所以能提前决定。frame-ancestors管的是“谁能把我嵌进去”,这句话写在被嵌那一方的响应头里,浏览器不把响应拿到手,压根不知道对方是什么态度。
所以顺序上必须是:先请求,先响应,再判决。这不是实现上的偷懒,是这条指令的语义决定的。它注定要在服务端已经干完活之后才生效。
明明配了策略,为什么还是被人整页嵌走?
现网里这一格的空缺率高得惊人。我抓了一批电商站、独立站与支付基建的响应头,可达的151个站里:
| 状态 | 站点数 | 占比 |
|---|---|---|
| 配了CSP | 53 | 35.1% |
其中写了frame-ancestors | 35 | 23.2% |
配了CSP却没写frame-ancestors | 18 | 占已配CSP的34.0% |
只有X-Frame-Options、没有frame-ancestors | 45 | 29.8% |
| 两样都没有,任何人都能整页嵌走 | 71 | 47.0% |
第一个陷阱:default-src不管这一格
那18个“配了CSP却漏了嵌入”的站里,有15个是写了default-src的。写了default-src的人通常认为它是个兜底——脚本、图片、连接都归它管,嵌入应该也归它管。
实验里的对照组直接给了答案:下发default-src 'self'的那个组合,被另一个域整页嵌进去,渲染完全成功。MDN那句话说得更狠:
A policy that declares
default-src 'none'still allows the resource to be embedded by anyone.
连'none'都不管用。在一份把所有东西都锁死的策略里,“谁能嵌我”这一格默认是敞开的,而且你不写它就永远敞着。
第二个陷阱:写在meta里等于没写
不少建站框架和插件把CSP注入到HTML的<meta>标签里,因为那样不用碰服务器配置。这条路对大部分指令有效,唯独对frame-ancestors无效。
实验里那个只在meta里写了frame-ancestors 'none'的组合,被跨站嵌入后正常渲染,控制台还留了一句:
The Content Security Policy directive 'frame-ancestors' is ignored when delivered via a <meta> element.
规范里对应的原文更简短,把三条指令一起排除在外:report-uri、frame-ancestors、sandbox。普查里有12个站把CSP写在meta里,其中1个恰好在里面写了frame-ancestors——那一整段配置在浏览器里一个字都不算数。它是活样本,不是我编的教学案例。
第三个陷阱:通配符不匹配裸域
实验里我写过一条frame-ancestors https://*.abc-demo.com,然后从abc-demo.com(没有子域前缀)去嵌,结果被拦。星号代表的是“某个子域”,不包括那个域名本身。
这个坑在多品牌、多站点架构里特别容易踩:你以为放行了整个域族,实际上主域被排除在外了。写白名单时两条都得写。
现网里那份白名单都写了些什么
把35个写了frame-ancestors的站按取值分类,结果和很多人的想象不一样:
| 取值形态 | 站点数 |
|---|---|
'self'再加一串白名单域 | 22 |
'none'(谁都不许) | 5 |
| 只写具体域名 | 4 |
'self'(只许自己) | 4 |
三分之二的站是“自己加一份名单”。名单里都是些什么?内容平台的预览域、支付服务商的域、页面搭建工具的域、谷歌支付的域。换句话说,这条指令在现实中不是一个开关,是一份要跟着业务变的花名册。每接一个新的第三方界面,就得往里加一行;忘了加,那个界面就白屏。
有一个支付服务商的白名单里,至今躺着一条http://localhost:9999。开发环境的调试项被一路带到了生产。它不会造成安全问题,但它是这份名单从来没人复核过的铁证——毕竟没人会去嵌一个别人本机的9999端口。
X-Frame-Options和frame-ancestors同时写,到底谁说了算?
这两个头一新一旧,很多站两个都配,想着“新浏览器认新的,老浏览器认老的,双保险”。实验结果是:只要CSP里出现了frame-ancestors,那个老头就一个字都不算数。
两个方向都测了
| 下发的组合 | 老头的意思 | 新指令的意思 | 实际结果 |
|---|---|---|---|
X-Frame-Options: ALLOWALL+frame-ancestors 'none' | 随便嵌 | 谁都不许 | 被拦 |
X-Frame-Options: DENY+frame-ancestors * | 谁都不许 | 随便嵌 | 嵌入成功 |
第二行才是真正要命的:老头写着最严的DENY,页面照样被跨站整页嵌走。如果你的安全基线检查表只grep一下有没有X-Frame-Options: DENY,这个站会满分通过,实际防线却是敞开的。
现网里有多少站的两个头在互相打架
普查里两个头都配了的站有21个。我按“老头说的”和“新指令说的”是否同一个口径逐个比对,结果是14个不一致,占66.7%。挑几个典型:
- 一个前端托管平台:老头写
DENY,frame-ancestors却放行了自己加三个内容平台的域。看老头以为谁都不许,实际是一份白名单。 - 一个健康品牌站:老头写
SAMEORIGIN,frame-ancestors的值是*。老头说只许同源,实际全世界都能嵌。 - 多个支付服务商:老头统一
SAMEORIGIN,新指令各自放行了自己的关联域与页面搭建工具域。
还有一个更古老的样本:某协作工具的响应头里写的是X-Frame-Options: ALLOW-FROM https://…。这个值早就作废了,MDN的措辞是现代浏览器会完全忽略这个头——不是忽略这个值,是整条头都不看了。所以这个站在嵌入这一格上,等于什么都没配。
那老头还要不要留
要留,但要认清它的定位:它是给那些不认识frame-ancestors的老客户端准备的,不是给你做双保险的。只要现代浏览器读到了新指令,老头就出局。因此两条的口径必须写成一致,不一致的时候永远是新指令赢。
保哥的经验是把这两行写在配置文件的相邻两行,中间不要隔任何东西。它们分开写的那一刻,就注定有一天只有一行会被改。
这跟当年用.htaccess加X-Frame-Options挡点击劫持那套做法是一脉相承的,只是执行权已经交给了新指令。响应头这一层同时还管着抓取指令与缓存协商,改动时得整体看,不能只盯一条。
nginx里加一行无关的响应头,为什么整站的CSP就没了?
说到这儿该往下走一层了。这些头是谁下发的?绝大多数站是nginx用add_header发的。而这个指令有一条继承规则,官方文档写得非常直白:
These directives are inherited from the previous configuration level if and only if there are no
add_headerdirectives defined on the current level.
拿一个测试站验一遍
我在server层配了三条头,然后在不同的location里做对照:
| 请求路径 | 该location里写了什么 | 实际收到的头 |
|---|---|---|
| /inherit/ | 什么都没写 | server层三条全在 |
| /child/ | 只加了一条无关的自定义头 | 只剩那一条,CSP消失了 |
| /childcsp/ | 写了自己的CSP | 只剩自己那条,server层的被顶掉 |
第二行就是那个坑。某人为了给一个目录加个缓存头,在那个location里写了一行add_header,整站的安全策略在这个路径下就静默消失了。没有报错,nginx -t通过,reload成功,只有那一小片路径裸奔。
更常见的触发场景是给静态资源目录单独加Cache-Control,或者给某个接口加跨域头。这类改动通常由另一个人在另一个时间做,两边都不知道对方存在。反向代理那一套配置里的斜杠、重写与头部转发细节本来就够绕了,再叠上这条继承规则,出问题的时候很难一眼看出来。
always决定错误页带不带策略
官方文档的另一句话:默认情况下,add_header只在响应码等于200、201、204、206、301、302、303、304、307、308时才添加;写了always才不管状态码。
我在404响应上验了一遍:带always的两条头都在,没带的那条不见了。这意味着一个没写always的站,它的404页、500页在安全策略上是裸的。对做SEO的人来说还有一层:状态码怎么选本来就是一道决策题,现在它还顺带决定了那个响应带不带策略。
两条CSP头不会互补,只会互相收紧
最后一个实验:让应用层(PHP)发一条CSP,nginx层再add_header一条。响应里真的出现了两行Content-Security-Policy。那浏览器听谁的?
我在浏览器侧做了两组对照:一条写frame-ancestors *、另一条写frame-ancestors 'none',两种顺序各跑一次。两次都被拦。
结论很明确:多条策略是取交集,最严的那条赢,跟顺序无关。这条规则本身是合理的——它保证了“再加一条策略只会更安全”。但落到运维现场,它的含义是:
- CDN或WAF替你加了一条宽松的策略,源站也加了一条,你以为是覆盖,其实是叠加。
- 用
curl -I只看到第一条的人,会以为策略就是那样。 - 普查里我确实抓到了一个站同时发两条CSP,另一个站把同一条
X-Frame-Options发了三遍。这不是配置写错,是多个环节各加各的,谁都没看见全貌。
如果你的站前面挂着CDN的缓存与回源规则,或者用了在边缘改写响应的那类做法,这个叠加风险会再高一档,因为边缘那层的改动往往不在代码仓库里。拦爬虫与限速的规则不误伤正经爬虫是同一个道理:每一层都以为自己是最后一层。
配了违规上报,为什么一条报告都收不到?
看到这里,正常反应是:那我配个违规上报不就行了,出事让浏览器告诉我。这个思路对,但现在的上报机制正处在一个尴尬的新旧交替期,配错了等于没配。
两种机制,四组对照
老机制叫report-uri,已经被标记为废弃;新机制叫report-to,要配合一个单独的响应头指定端点。我用同一个接收端点,跑了四组:
| 策略写法 | 违规类型 | 收到的报告数 |
|---|---|---|
只写report-uri | 嵌入被拒 | 2 / 2 |
只写report-to | 嵌入被拒 | 0 / 2 |
| 两个都写 | 嵌入被拒 | 0 / 2 |
只写report-uri | 脚本被拒 | 2 / 2 |
只写report-to | 脚本被拒 | 1 / 2 |
| 两个都写 | 脚本被拒 | 新机制1条,老机制0条 |
每一组都在两个不同版本的浏览器上各跑一次,所以分母是2。老机制作为阳性锚点,六次机会到了四次,说明接收端点本身是好的——这一点很重要,否则我没法区分“机制不工作”和“我的探针坏了”。
三个结论,一个比一个难受
第一,两个都写的时候,老机制会被停用。这不是玄学,MDN写得清清楚楚:
The
report-todirective is intended to replacereport-uri, and in browsers that supportreport-to, thereport-uridirective is ignored.
第二,嵌入被拒这一类违规,新机制在我的测试里一条都没送出来。脚本被拒能送出来,嵌入被拒送不出来。一个说得通的解释是:嵌入被拒时那个文档根本没被创建出来,而新机制是挂在文档上的延迟队列——没有文档,就没有队列。老机制则是当场直接发一个POST,不依赖这些。
第三,把这两条合起来看:按“老的废弃了,改用新的,保留老的做兼容”这个标准迁移动作改完配置,恰好把嵌入违规的上报变成了零。而且是静默的零——没有报错,配置看着很规范,仪表盘上那条曲线一直是平的,你会以为一切太平。
两种格式的字段名完全不一样
就算报告送到了,接收端也未必解析得了。同一次违规,两种机制发来的是这样:
老机制 Content-Type: application/csp-report
{"csp-report":{"document-uri":"…","violated-directive":"script-src-elem",
"blocked-uri":"…","status-code":200}}
新机制 Content-Type: application/reports+json
[{"age":0,"type":"csp-violation","body":{"documentURL":"…",
"effectiveDirective":"script-src-elem","blockedURL":"…","statusCode":200}}]外层从对象变成了数组,包裹字段名换了,内部字段从短横线命名换成了驼峰命名,还多出age、type、user_agent。换上报机制不只是改一行响应头,接收端的解析代码要整个重写。照抄旧解析器的话,报告收到了也会被丢进异常分支。
顺带记一个小刺:我在策略里写的是script-src,报告里回来的却是script-src-elem。MDN提过这类差异,用的是样式指令举的例子。如果你的告警规则按指令名精确匹配,会漏掉一批。
报告里那个字段,等于官方承认
报告体里有个字段叫status-code,值是200。
这是浏览器自己写下的:我拦掉的这个响应,状态码是200。整篇文章的核心论点,被违规报告本身盖了个章。
现网里配了上报的站,有几个真能收到
普查里涉及新机制的站有10个。我逐个核对策略里写的端点组名和那个单独响应头里声明的组名对不对得上——只有1个对得上。其余的要么根本没发那个声明头,要么发了但里面是另一套用途的组名。
也就是说,这10个站里有9个的上报是配了个寂寞。而CSP这种机制有个残酷的特性:没人上报就等于什么都没发生。违规既不影响服务端,也不影响状态码,报告是唯一的证据链,报告断了,故障就彻底隐形。
被拦的那个页面,Google那边算什么?
做SEO的读到这里应该已经开始不安了,因为搜索引擎的抓取与渲染,正好卡在这条分界线上。Google官方文档里有两句话,单看都平平无奇,连起来就是问题所在:
Googlebot queues all pages with a
200HTTP status code for rendering, unless a robots meta tag or header tells Google not to index the page.Google also uses the rendered HTML to index the page.
排队的门槛是状态码200,索引用的是渲染后的HTML。而策略动手的位置,恰好就在这两句话中间。
三种被拦法,对索引的后果完全不同
| 被拦的东西 | 抓取阶段 | 渲染结果 | 索引到的内容 |
|---|---|---|---|
| 页面被拒绝嵌入 | 正常200 | 父页面那块区域是空的 | 页面本身照常索引,父页面少一块 |
| 注入正文的第三方脚本被拦 | 正常200 | 正文没被注入 | 索引到一个空壳 |
| 结构化数据由外部脚本写入 | 正常200 | 标记没生成 | 富媒体资格丢失 |
第二行是实验里亲眼看到的:策略拦掉外部脚本后,那块本该被脚本填满的正文区域,渲染完还是占位文字。服务端返回的是200,HTML也确实送到了,只是里面没有内容。这跟纯客户端渲染的页面抓不到内容是同一类后果,但成因藏得更深——那一类至少能在源码里看出是靠脚本填的,这一类是脚本本来能跑,被自己站上的一行配置挡住了。
越靠近交易,策略越不是你写的
我对电商与独立站样本做了分层取样,首页、购物车、结账、账户各取一次:
| 页面层 | 可达数 | 配了CSP | 写了frame-ancestors | 配了老头 | 两样都没有 |
|---|---|---|---|---|---|
| 首页 | 43 | 47% | 44% | 49% | 33% |
| 购物车 | 54 | 78% | 76% | 54% | 19% |
| 结账 | 52 | 75% | 73% | 25% | 15% |
| 账户 | 29 | 79% | 79% | 59% | 14% |
两个方向正好相反:越靠近交易,新指令的覆盖率越高(44%涨到73%),老头的覆盖率反而越低(49%掉到25%)。原因不难猜——结账那几页多半跑在电商SaaS上,策略是平台写的,平台只写新指令;首页在自己手里,配置是历史上运维加的,加的是老头。
把首页和结账页两层都拿到的24个站逐个比对,有3个站两层策略明显不一致。其中一个品牌站,首页是最严的'none'加DENY,结账页却是'self'加平台域、连老头都没有。同一个品牌,两层策略由两拨人写,中间没有任何一处会报冲突。
做结账放弃率归因的时候,这类“某个第三方组件在某些浏览器里白屏”的损耗几乎不可能从漏斗数据里看出来——用户不会填工单,他们只是走了。
排查这类故障,该按什么顺序问问题?
把上面所有实验的结论压成一套可执行的顺序。保哥自己现在遇到“用户说白屏、服务端说正常”的时候,就是按这个顺序走。
先分清楚是哪一派
第一个问题不是“服务器有没有收到”,而是“这块内容是被嵌进来的,还是页面自己去加载的”。
- 被嵌进来的(iframe、嵌入式结账、验证挑战窗口):查被嵌那一方的
frame-ancestors与老头。它的服务端一定有日志,别拿“对方说收到了”当排除依据。 - 页面自己去加载的(脚本、图片、接口):查当前页面的策略。第三方那边一定没有日志,别拿“对方说没收到”当网络故障处理。
然后按这张表逐项对
| 要确认的事 | 怎么确认 | 常见的错法 |
|---|---|---|
| 响应里到底有几条CSP | 看原始响应头的完整列表 | 只看解析后的第一条 |
| 策略是谁下发的 | 应用层、nginx、CDN逐层排查 | 只翻代码仓库 |
| 这个location有没有自己的add_header | 看配置里本级有没有写 | 以为server层的会继承下来 |
| 错误页带不带策略 | 请求一个404看头 | 只测200页面 |
| meta里写的那条算不算数 | 嵌入类指令一律不算 | 以为写哪儿都一样 |
| 白名单里有没有裸域 | 星号写法要额外补主域 | 以为通配符包含主域 |
| 上报有没有真的送达 | 翻接收端点的原始记录 | 看仪表盘没告警就当没事 |
三个能立刻上手的动作
第一,在服务端认出“我正在被嵌”。实验日志里那三个Sec-Fetch字段是现成的:Sec-Fetch-Dest: iframe加上Sec-Fetch-Site: cross-site,就是“有人正在跨站嵌我”。你可以在这个条件下返回一个降级页面,给用户一个“点此在新窗口继续”的入口,而不是让浏览器直接把整块区域变白。这一步不需要改策略,也不需要谈判,纯粹是把一个已经到手的信号用起来。
第二,埋一枚渲染信标。页面加载完回打一次同源请求,服务端拿“请求数减信标数”就能算出有多少次访问其实没被看到。但这里有个我自己踩的坑值得说:实验里有个组合渲染成功了,信标却没到——因为那份策略是default-src 'self',把页面自己的内联脚本也一并拦了。你加的观测手段本身也归同一份策略管。要么给信标脚本单独放行,要么把它放进独立文件里。
第三,别急着把老头删掉。它虽然在现代浏览器里出局,但删掉的成本是零收益,留着的成本也是零。真正该做的是把它和新指令的口径对齐——普查里那66.7%的不一致,绝大多数是因为一个人改了新的、没人回头看老的。
要不要现在就配
如果你的站属于那47%(两样都没配),先配最简单的一条:frame-ancestors 'self',加always,配在server层,然后把所有location挨个看一遍有没有自己的add_header。这一步比策略本身更容易出错。
如果你的站有嵌入式支付、客服组件、第三方评价插件,那就得先列一份名单再上策略,并且用Report-Only模式跑一段时间——它一个都不拦,只记录。实验里那个组合正是这个行为:三类资源全部到达,控制台照报违规。先看清自己拦了谁,再决定拦不拦。
顺带说一句,做这类改动最好跟前端那边的协作动作与建站第一年那批隐性失分项放在一张清单上过,因为它们踩的是同一类问题:配置改动本身不会报错,报错的是几周之后的数据。
常见问题解答
我的站被跨站嵌入了,会不会影响排名?
直接影响基本没有。被别人嵌走不构成重复内容问题,那个页面的地址还是你的,抓取和索引走的还是你自己的URL。真正的风险有两类:一类是别人拿你的页面套一层壳去做诱导,用户在那边受了损失,最后骂的是你的品牌;另一类是你的页面里有表单或登录,被嵌进别人的界面做点击劫持。这两类都属于安全问题,不属于排名问题。
反过来那一侧的风险反而更该防。如果把frame-ancestors配得过严,误伤了自己接的第三方界面,损失是实打实的转化,比排名影响大得多。所以顺序应该是先列白名单,再上策略,不要图省事一刀切成'none'。
Report-Only模式能不能长期开着?
能,而且很多站就是长期开着的。它的行为在实验里很明确:三类跨站资源全部正常到达,页面正常渲染,只是控制台记了违规、报告发去了指定端点。代价是它完全不提供保护,所以不能拿它当上线。
合理的用法是两条策略并行:一条正式的、宽松些的负责拦,一条Report-Only的、严格些的负责探路。等严格那条跑一段时间不再报新东西了,再把它提成正式的。要注意这两条会一起生效,正式那条该拦的照拦,别指望Report-Only能放松它。另外普查里151个站只有6个开了Report-Only,这个用法远没有它值得的那么普及。
为什么我用curl看响应头,看到的和浏览器里的不一样?
最常见的三个原因。第一,同名响应头有多条,很多工具默认只显示或只保留第一条,而浏览器是全部拿来取交集的,于是你看到的是宽松那条、执行的是严格那条。第二,你请求的是首页或者某个200页面,而出问题的路径在另一个location下,那个location里有自己的add_header,头的组合完全不同。第三,出问题的响应是个4xx或5xx,配置里又没写always,那些头在错误响应上根本不会加。
排查时最稳的做法是照着用户实际访问的那个完整地址去请求,并且把原始响应头一行不落地打出来,而不是用工具解析后的摘要。
把CSP写在CDN那一层,会不会更省事?
会更省事,但要接受两个后果。第一个是叠加:如果源站也在发CSP,两条会同时存在并取交集,最终执行的策略比任何一层单独看到的都严,而且没有任何一层的配置界面会告诉你这件事。第二个是可见性:边缘那层的改动通常不在代码仓库里,出问题的时候代码审查看不出来,新同事接手也不会知道有这么一条。如果确实要放在边缘,建议做两件事——把源站那一层的CSP彻底去掉,只留一个下发点;再在部署文档里明确写清楚这条策略归谁维护。做这类分层配置的时候,边缘改写与源站配置的边界要一次划清楚,事后再理会非常费劲。
移动端App里的WebView也吃这一套吗?
吃,而且更容易踩。WebView大多基于同一个内核,frame-ancestors的判定逻辑一致。但有两个额外的麻烦。一是WebView里没有控制台,被拦了以后页面就是白的,连那句提示都看不到,除非专门接了远程调试。
二是App里嵌自家H5的场景,那个H5页面的来源在浏览器眼里往往不是你想的那个域,有时候甚至是file://或者一个自定义scheme,这类来源在白名单里根本没法用常规写法匹配。所以做App内嵌页时,要么把这个页面的策略单独放宽,要么在服务端按Sec-Fetch-Dest判断后走另一套响应。
违规报告应该发到哪儿,自己搭还是用现成的?
量小的时候自己搭一个接收端点完全够用,几十行代码的事。但要提前想清楚三件事。第一是量:违规报告是浏览器主动发的,一个配错的策略在流量高峰能瞬间打出很大的量,接收端要能扛住,最好直接写队列不要同步落库。第二是格式:老机制和新机制的字段名完全不同,外层结构也不同,解析代码要同时认两种,否则迁移那天你会以为报告没来。第三是过滤:浏览器扩展注入的脚本会产生大量与你无关的违规,这类噪声在真实站点上能占到多数,不做过滤的话有用信息会被淹掉。如果不想处理这些,用现成的收集服务是更省心的选择。
我怎么知道站上现在到底有多少个第三方界面被嵌在页面里?
有个不用改代码的笨办法:打开生产环境的页面,在控制台里数一遍当前文档下所有iframe的来源域,把结果和白名单比一遍。要覆盖全的话,首页、商品页、购物车、结账、账户这几层都得跑一遍,因为不同层加载的第三方组件完全不一样——分层普查的数据显示,同一个站在首页和结账页的策略差异比很多人想象的大。更彻底的做法是先开Report-Only跑几天,让浏览器自己把名单报上来,这比人工数准得多,也能抓到那些只在特定条件下才加载的组件,比如只对某些国家用户显示的支付方式。
如果第三方服务商的域名变了,我怎么第一时间知道?
坦白说很难第一时间知道,这也是这套机制最难受的地方之一:决定你页面能不能显示的那份名单,写在你手里,但名单该有哪些条目由别人决定。服务商换域名、加新的边缘节点域、把某个功能拆到独立域上,都不会提前通知你。能做的只有三件:把违规上报真正打通,让第一次被拦就有记录;在接入任何第三方组件时把域名写进部署清单,跟着服务商的变更公告走;对关键路径上的组件做一次可用性检查,别只测接口通不通,要测它在真实页面里能不能渲染出来。第三条最容易被漏掉,因为接口拨测和渲染检查是两件事。
权威参考资料
本文标题:《服务器全程返回200、订单已入库,用户眼前那块区域自始至终是空白》
本文链接:https://zhangwenbao.com/csp-frame-ancestors-blocked-render-not-request.html
版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0