一、同一个入口,两种完全不同的信任
你手机上的那份 PDF 预览、在线简历里的头像裁剪、微信里自动播放的 H5 活动页,它们的共同点不是功能,而是都在浏览器里执行别人的代码。既然要在同一台设备上执行代码,就要回答一个问题:这个页面能拿到什么?
标准答案来自同源策略:页面只能读取和自己同源(协议、域名、端口三者全同)的资源,跨源能发请求但不能读响应,能加载图片但不能读像素——除非对方显式用 CORS 授权。同源策略是浏览器安全的底座,但它有一个历史遗留的裂缝:iframe 的「加载 + 执行」这一半几乎是自由的。你可以把任何第三方页面嵌进你的页面里,它就会在你的域名下、你的浏览器标签里运行,并继承一批本该属于你的能力:弹窗、跳转顶层窗口、自动播放、请求摄像头权限、触发表单提交。攻击者要的从来不是「读你页面里的文字」,而是「用你的页面身份做一件事」。
所以治理 iframe 有两层,很多人只做了第一层。第一层是沙箱:把嵌入内容的能力削到最小,用 sandbox 属性一件一件地放开。第二层是隔离:处理「两个不同来源的页面不该出现在同一个渲染进程里」这类更隐蔽的问题,靠的是响应头而不是属性。这两层解决的不是同一类风险,混着讲就会犯「给广告 iframe 加了 sandbox,结果广告全白了」这种事故。
二、sandbox 默认是全关,不是全开
最容易被误解的一点:sandbox 属性不带任何值时,是最严格的模式,不是最宽松的模式。它的效果接近把内容放进一个独特的、不透明来源(opaque origin)里:
- 脚本被完全禁止执行,除非显式写
allow-scripts; - 表单不能提交,除非
allow-forms; - 不能打开弹窗,除非
allow-popups; - 不能再打开子 iframe,除非
allow-same-origin(它管的是来源,不是嵌套,这是最常见的误读之一); - 不能使用 localStorage、cookie、IndexedDB,也不能发起带凭证的请求,因为它的来源是空的;
- 不能自动播放、不能全屏、不能使用指针锁定、不能弹下载。
这条设计是对的:默认拒绝,然后按需放开。麻烦在于「按需」这两个字,绝大多数人第一次配的时候是按控制台报错一个一个试出来的,最后往往多放开了几个。
一个可以直接抄的保守模板,用于嵌入你自己的静态内容(比如文档预览、HTML 邮件预览):
<iframe src="/preview/2026/report.html"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
loading="lazy"
style="width:100%;height:720px;border:0"></iframe>注意这里故意没有写 allow-same-origin。只要不写它,里面的脚本就跑在一个不透明来源里,读不到你站点的 cookie,也拿不到 localStorage——即使内容是用户上传的,最坏情况也只是在他自己的沙箱里搞花样。
三、allow-scripts 加 allow-same-origin:等于没有沙箱
这是最典型、也最难自己发现的错误配置:
<iframe src="/uploads/user-page.html"
sandbox="allow-scripts allow-same-origin"></iframe>看起来两样都很克制,实际效果是:内容保持了自己原本的来源(同源),又能执行脚本。于是沙箱里那套限制——读 cookie、读 localStorage、发同源请求、访问父页面——全部失效了,它几乎等于一个没写 sandbox 的普通 iframe。规范明确提醒过这一点,但它在语法上完全合法,浏览器不会报警。
这条规则的唯一合理例外,是嵌入你完全信任、并且位于另一个你自己控制的源的内容;即使如此,通常也不需要 allow-same-origin,因为你要的往往是「让它能跑脚本」,而不是「让它带着你的 cookie 跑脚本」。判断方法很简单:如果一份内容需要靠自己的来源身份来做鉴权,那它就不该出现在沙箱里。
对于用户上传的 HTML 预览,我的做法是根本不渲染 HTML,只渲染纯文本或转义过的代码;只有在确实要预览时,才用上面那个只带 allow-scripts 的模板,并且把文件放在一个独立的预览域名下,那个域名不设任何登录 cookie。
四、不能省的那两个属性
sandbox 管的是能力,管不了两件事:跳转和信息泄露。
第一件是顶层跳转。一个嵌进来的页面可以用 window.top.location 把你的整个标签带到它想去的地方,也可以伪造一个「点击下载」,让用户以为自己下载的是你的文件。白名单之外的来源(尤其是广告、统计、评论、第三方分享组件),应该一律加 allow-top-navigation-by-user-activation 而不是 allow-top-navigation:前者只在用户真的点了一下时才允许跳转,后者是随时可以跳。
第二件是 Referer。iframe 发起请求时会带上你页面的地址,等于把「用户在我的哪个页面里点开了这个链接」告诉第三方。对敏感页面(后台、带 token 的预览页)要显式写 referrerpolicy="no-referrer";如果只是普通内容页,用 strict-origin-when-cross-origin 也够。
再补一条容易被忽略的:allow-popups 只允许开新窗口,如果内容需要在新窗口里继续跳转,还要 allow-popups-to-escape-sandbox。但一旦放开这一条,新窗口就是没有沙箱的普通页面了,等于把限制停在新窗口的第一跳。第三方内容慎用。
<iframe src="https://player.example.net/embed/9f3a"
sandbox="allow-scripts allow-same-origin allow-presentation"
allow="fullscreen; encrypted-media"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
loading="lazy"></iframe>上面这个模板里出现了 allow-same-origin,因为它嵌的是视频平台的播放器,而播放器要靠自己的来源读写配置、校验域名授权。这类「官方给的嵌码」只能照抄,能做的补救是把选择权交给自己:只用一两个确定的域,别让它随手换成别的第三方。
五、iframe 嵌套本身是一门攻击术
点击劫持是 iframe 最经典的用法:把银行或后台的登录页用一层透明的 iframe 盖在「领取红包」按钮下面,用户点到的其实是你后台的「确认转账」。这类攻击不依赖 XSS,也不需要拿到任何口令,只需要你的页面允许被嵌入。
现代浏览器的响应头方案有两个,按优先级从高到低:
# 首选,语义清楚,支持按来源白名单
Content-Security-Policy: frame-ancestors 'self'
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com
# 兼容老浏览器,只支持全禁或同源
X-Frame-Options: SAMEORIGIN几个实操要点:
frame-ancestors不支持 URL 通配路径,只到源(协议 + 域名 + 端口)。想放行某个子域,就写完整子域,不要写*.example.com之外的妄想。- 它只对被嵌时生效,不影响你自己嵌别人。有人把它当成 iframe 的白名单来理解,结果配完发现自己的页面还是能嵌任意第三方,以为是配置失效。
- 两个头同时存在时,
frame-ancestors优先。我在迁移期会同时下发,稳定后再把 X-Frame-Options 留着不删——老客户端还在用,删了没有收益。 - Nginx 里下发时注意别只加在
location /上,静态资源、错误页、后台接口路径如果被单独 location 覆盖了add_header,那些路径就没有这个头。这是一个很常见的「主页有、后台没有」的漏配。
六、嵌进来和嵌出去,要分开测
这两件事经常被混成一句「iframe 有问题」,排查时应该拆成两条独立的检查。
测「我能不能被嵌」:写一个测试页,把目标地址放进 iframe,用浏览器打开,看控制台是否报 Refused to display ... in a frame because it set 'frame-ancestors'。更省事的做法是直接看头:
curl -sI https://www.example.com/ | grep -iE 'frame-ancestors|x-frame-options'测「我嵌的东西是不是安全」:把页面上所有 iframe 的来源列出来,逐个核对是否需要、是否加了 sandbox、sandbox 里是否多了 allow-same-origin。一条 grep 就能出一张表:
grep -rhoE '<iframe[^>]*' ./templates/ ./content/ | \
grep -oE 'src="[^"]+"|sandbox="[^"]*"' | sort | uniq -c | sort -rn输出会立刻暴露两类问题:某些第三方来源嵌了七八处却一个字都没加 sandbox;某些自家页面加了 sandbox 却顺手带上了 allow-same-origin。这类审计一次半小时,比出事之后再翻模板便宜得多。
七、页面之间真的隔离了:COOP、COEP 与跨源窗口
沙箱管的是一个 iframe 内部的权限,还有一件更隐蔽的事:一个恶意的页面能不能通过 window.open 拿到你的窗口引用,进而做「标签页劫持」式的攻击。这类问题靠 Cross-Origin-Opener-Policy 解决:
Cross-Origin-Opener-Policy: same-origin加上它之后,你打开的跨源窗口与原页面之间不再共享浏览上下文组,对方拿到的 window.opener 变成空,也就没法把你的页面导航走。代价是会影响 OAuth 弹窗登录、第三方支付回调这些「靠 opener 通信」的老流程。我的做法是先在测试环境开 same-origin-allow-popups(允许自己开的弹窗保留联系),观察一周登录流程没问题,再决定是否收紧到 same-origin。
同一个家族的还有 Cross-Origin-Embedder-Policy 与 Cross-Origin-Resource-Policy。前者一旦设成 require-corp,页面上所有未显式授权的跨源资源都会被拦——图片、字体、脚本、iframe 全包括,这是给需要 SharedArrayBuffer 的高性能页面准备的,普通内容站开了大概率是自找麻烦。后者 same-origin 保护的是另一侧:别让别人的页面把你的图片、视频、文档嵌进去当免费 CDN,这一点对流量敏感的小站其实很实用。
八、和 CDN、CDN 回源一起踩的四个坑
在自己机器上配好不等于线上生效,下面四个是我踩过的,按出现频率排:
坑一:CDN 覆盖头。 边缘节点常常自带一套安全头,回源响应里你的 frame-ancestors 可能被同名的边缘规则覆盖,或者两份同时出现。验证时务必走真实域名,不要只 curl 源站 IP,否则你会得到「本地正常、线上照样被嵌」的困惑。
坑二:沙箱把广告/统计组件打空。 给第三方广告 iframe 加 sandbox="allow-scripts" 之后,它拿不到自己的 cookie,展示统计和频次控制全部归零,广告主看到的是「有曝光没转化」。第三方嵌入要按官方文档给的最小权限配置,改动前先在测试页跑一天,对比上报量。
坑三:报错页没有头。 Nginx 的 error_page 内部跳转会走新的 location,如果那里也写了 add_header,前面的安全头就会丢。这是「安全头扫描工具报高危、但访问首页明明有」的最常见原因——扫描器扫的是 404 页。
坑四:只测首页。 后台、上传预览页、嵌入型落地页才是真正需要 frame-ancestors 的地方,而这些路径往往在单独的 server 块或子域名里。建议用一份固定的 URL 清单每周扫一次,而不是每次都从首页开始想。
九、落到流程里的四条纪律
技术方案写进文档不执行等于没有。我现在给自己定的规则只有四条,都能在一个下午内全部执行完:
- 凡是新加的 iframe,PR 或变更说明里必须写清「来源域、用途、sandbox 值」三项,缺一项不合入。
- 全站默认
frame-ancestors 'self',需要被嵌入的页面单独列出白名单,白名单条目一个季度复核一次。 - 用户上传的内容一律不放主域名下渲染,预览一律走独立域名 + 无 cookie + 只带
allow-scripts的沙箱。 - 每月一次头审计:目标 URL 清单 + 一条 curl 循环,输出写成表格存档,和上个月对比看有没有悄悄丢掉的头。
这些规则加起来不到五十行配置,但它们把「一个第三方嵌入组件就能拖垮整站安全」这类风险压到了很低的水平。
十、小结
iframe 的安全模型可以压缩成三句话:沙箱默认全关,按需放开;allow-same-origin 和 allow-scripts 同时出现等于没沙箱;谁能嵌你由响应头决定,和 iframe 属性无关。再加上 COOP 这类页面级隔离来收拾窗口引用问题,绝大多数「嵌入导致的安全事件」要么不会发生,要么发生了也伤不到根上。真正难的从来不是记住这些属性,而是在每次匆忙上线时仍然肯花十分钟确认一下:这个 iframe 到底需要哪些能力,以及它是不是还被别人随便嵌着。