30-seconds-of-code HTML 安全指南:用 rel="noopener noreferrer" 杜绝 target="_blank" 的 tabnabbing 攻击
30-seconds-of-code HTML 安全指南:用 rel="noopener noreferrer" 杜绝 target="_blank" 的 tabnabbing 攻击
在网页开发中,target="_blank" 是打开外部链接最常用的方式,但它同时携带一个容易被忽略的安全缺陷:新标签页可以通过 Window.opener 反向访问并篡改原页面。本篇基于 30-seconds-of-code 仓库中的 target-blank 片段,系统讲解该漏洞(tabnabbing)的成因、触发场景,并给出在项目里可立即落地的标准修复方案 rel="noopener noreferrer"。读完本文,你将掌握为所有新标签页链接加固的正确写法,并能结合仓库内已有的代码与工具完成批量审计。
背景:target="_blank" 的便利与隐患
当我们在页面中链接到外部资源时,target="_blank" 会让链接页面在新标签页(或新窗口)中打开,从而保留当前页面的浏览上下文,避免用户离开我们的站点:
<a href="https://externalresource.com/some-page" target="_blank">
External resource
</a>
这种写法本身没有语法错误,但隐藏着一个安全风险:浏览器会为新打开的标签页建立一条指向"发起链接的页面"的反向引用。也就是说,新标签页里的脚本可以拿到我们页面的 Window.opener 引用,一旦它不可信,就能反过来做坏事。
风险原理:Window.opener 与 tabnabbing 攻击链
原文档明确指出,新标签页会通过 Window.opener 获得对链接来源页面的受限访问权限(limited access),并可以进一步通过 Window.opener.location 改写来源页面的 URL。这一攻击手法在业界被称为 tabnabbing(标签页劫持/钓鱼)。
完整的攻击链大致如下:
- 用户在我们的页面上点击一个
target="_blank"的外部链接,新标签页打开恶意站点; - 恶意站点通过
window.opener拿到我们页面的引用; - 恶意脚本将
window.opener.location改写成钓鱼页面地址(如伪造的登录页); - 用户切回原来的标签页时,看到的已是一个伪装成我们站点的钓鱼页面,可能在毫无防备的情况下输入账号密码。
由于用户在"新标签页里看内容"和"切回原标签页"之间存在时间差,这种替换往往难以察觉,这正是 tabnabbing 危险的地方。
哪些场景真正值得警惕
原文档特别强调:无论第三方资源看起来多么可信,我们都无法保证它永远安全。以下是需要格外谨慎的典型场景:
- 外部资源本身不可信(来历不明的第三方站点);
- 外部站点曾经被入侵或挂马(页面被植入恶意脚本);
- 域名在多年间发生过所有权变更(老域名可能落入新主人之手);
- 外部站点上存在用户上传内容(可能被注入脚本)。
换言之,安全性不能建立在"对方暂时可信"的假设上。作为开发者,我们必须默认外部链接是不可控的,并在代码层面把风险封死。
标准修复方案:rel="noopener noreferrer"
原文档给出的修复方案非常简单:为所有 target="_blank" 链接补上 rel="noopener noreferrer" 属性。完整示例对比如下:
<!-- Bad: susceptible to tabnabbing -->
<a href="https://externalresource.com/some-page" target="_blank">
External resource
</a>
<!-- Good: new tab cannot cause problems -->
<a
href="https://externalresource.com/some-page"
target="_blank"
rel="noopener noreferrer"
>
External resource
</a>
这段代码可以直接复制使用:只需在原有的 target="_blank" 链接上增加 rel="noopener noreferrer",新标签页便不再具备干扰来源页面的能力。
逐项拆解 rel 属性值
为便于理解并用于项目中的注释,这里把两个关键字的作用分开说明:
| 关键字 | 作用 | 说明 |
|---|---|---|
noopener |
使新标签页的 window.opener 为 null |
切断新页面到来源页面的反向引用,从根源上阻止 tabnabbing;这是防御核心 |
noreferrer |
不再向目标站点发送 Referer 请求头 |
隐藏来源页面 URL,同时隐式包含 noopener 的效果 |
两点值得注意的细节:
noreferrer在语义上已覆盖noopener的能力,但同时书写两者仍是社区与规范推荐的做法,既明确表达意图,也能在个别解析不完整的浏览器/中间环节中兜底;- 使用
noreferrer会影响目标站点统计中的来源分析(Referer丢失),这是隐私保护与数据统计之间的取舍,需要结合业务判断。
另外可以观察到,近年来主流浏览器的默认行为已逐渐把 target="_blank" 视为隐式携带 noopener 语义;但考虑到旧版本浏览器、代理环境和"显式优于隐式"的工程习惯,显式书写 rel="noopener noreferrer" 依然是当前最稳妥、可读性最高的做法。
仓库内的一致实践与交叉印证
这条规范不仅是文档层面的建议,30-seconds-of-code 仓库自身的代码也在严格执行:
- 在 content/collections/js/regexp.yaml、content/collections/js/json.yaml 与 content/collections/js/complex-objects.yaml 中,所有指向外部资源(如 MDN、GitHub 代码引用)的链接均采用
target="_blank" rel="noopener noreferrer"的写法,与本篇片段的要求完全一致,可作为团队代码规范的现成范例。
此外,仓库中还有两篇与本文强关联的片段,可以配套阅读与使用:
- open-all-links-in-new-tab 片段:教你用
<base target="_blank">让文档中所有链接统一在新标签页打开。该片段末尾明确警告了这种做法的安全风险,并直接链接到本篇 target-blank 片段。需要注意,<base>元素只能统一设置target和href,并不能为每个链接注入rel属性,因此在采用<base target="_blank">的场景下,逐个链接补写rel="noopener noreferrer"反而更加必要; - query-selector-shorthand 片段:展示了用
$$('a[target="_blank"]')一次性选中所有新标签页链接的写法。在做安全审计时,可以借助它快速枚举全站未加rel防护的target="_blank"链接,逐一补齐修复。
落地审计建议:一个可执行的检查清单
将原文档结论落到实际项目中,建议按以下顺序排查与加固:
- 静态排查:搜索代码中所有
target="_blank"出现的位置,确认是否都带有rel="noopener noreferrer";可使用仓库内$$('a[target="_blank"]')这类选择器在运行时复查页面中实际渲染的链接; - 补齐属性:对缺失
rel的链接统一补写rel="noopener noreferrer",保持"新标签页打开"与"安全防护"同时生效; - 覆盖
<base>场景:如果文档头部使用了<base target="_blank">,确认每个<a>标签仍显式携带rel防护,而不是依赖<base>统一处理; - 纳入评审规范:把"所有
target="_blank"必须带rel="noopener noreferrer""写入代码评审清单,避免后续新链接回归风险。
小结
target="_blank" 带来的 tabnabbing 风险并不复杂,却实实在在威胁着用户的账户与隐私安全。它的修复同样简单——只需为链接追加 rel="noopener noreferrer",即可切断 Window.opener 反向引用,让新标签页"打不开来源页面的大门"。这条规则成本极低、收益明确,是每个前端项目都应当无条件落实的基础安全实践,也是 30-seconds-of-code 仓库自身在每一处外链上身体力行的标准。