Skip to content
Anonymous

外部連結安全最佳實踐:target='_blank' 與 rel='noopener'

了解為什麼外部連結需要特殊處理,以及如何正確使用 target='_blank'、rel='noopener' 和 rel='noreferrer' 來保護使用者安全

#security #html #links #best-practices

每個網站都會有外部連結,但你知道不正確的外部連結可能帶來安全風險嗎?本文將深入探討如何安全地處理外部連結。

目錄

  1. 問題背景
  2. Tabnabbing 攻擊
  3. 解決方案
  4. rel 屬性詳解
  5. 實作範例
  6. 自動化檢查

問題背景

常見的外部連結寫法

<!-- 許多開發者這樣寫外部連結 -->
<a href="https://github.com/your-repo" target="_blank">
  View on GitHub
</a>

看起來很正常,對吧?但這裡隱藏著一個安全漏洞。

target=“_blank” 的原始行為

當使用 target="_blank" 時:

  1. 連結在新分頁開啟
  2. 新分頁可以透過 window.opener 存取原始頁面
  3. 新分頁可以使用 window.opener.location 將原始頁面導向到任何 URL

Tabnabbing 攻擊

攻擊流程

1. 使用者在 yoursite.com 點擊連結

2. 連結開啟 evil-site.com(看起來無害的網站)

3. evil-site.com 執行:
   window.opener.location = 'https://phishing-yoursite.com'

4. 使用者的原始分頁被悄悄導向到釣魚網站

5. 使用者切回「原始」分頁,看到登入頁面

6. 使用者以為 session 過期,輸入帳密

7. 帳密被盜 💀

實際案例

這個攻擊被稱為 Reverse TabnabbingTab Nabbing

雖然現代瀏覽器已經開始預設阻擋這個行為(Chrome 88+、Firefox 79+),但:

  1. 舊版瀏覽器可能仍有風險
  2. 防禦應該在多層進行
  3. 明確設定比依賴瀏覽器預設更好

解決方案

加入 rel=“noopener noreferrer”

<!-- ✅ 安全的外部連結 -->
<a 
  href="https://github.com/your-repo" 
  target="_blank"
  rel="noopener noreferrer"
>
  View on GitHub
</a>

每個屬性的作用

屬性作用
target="_blank"在新分頁開啟
rel="noopener"阻止新窗口存取 window.opener
rel="noreferrer"不發送 Referer header

rel 屬性詳解

noopener

<a href="..." target="_blank" rel="noopener">

效果

  • 新窗口的 window.opener 值為 null
  • 新窗口無法使用 opener.location 影響原始頁面
  • 這是安全性考量

瀏覽器支援

  • Chrome 49+
  • Firefox 52+
  • Safari 10.1+
  • Edge 79+

noreferrer

<a href="..." target="_blank" rel="noreferrer">

效果

  • 新窗口的 window.opener 值為 null(包含 noopener 的效果)
  • 不發送 HTTP Referer header
  • 目標網站不知道使用者從哪裡來

何時使用

  • 不想讓目標網站追蹤流量來源
  • 隱私考量
  • 注意:這會影響目標網站的分析資料

兩者的關係

noreferrer ⊇ noopener

noreferrer 隱含 noopener 的功能,但為了向後相容和明確性,通常兩個都寫:

rel="noopener noreferrer"

nofollow

<a href="..." rel="nofollow">

效果

  • 告訴搜尋引擎不要追蹤這個連結
  • 不傳遞 PageRank

何時使用

  • 使用者生成的內容(評論區)
  • 贊助連結
  • 不信任的連結

這與安全性無關,而是 SEO 考量。

external

<a href="..." rel="external">

效果

  • 純語義化,表示這是外部連結
  • 沒有實際功能
  • 可用於 CSS 選擇器
a[rel="external"]::after {
  content: " ↗";
}

實作範例

基本外部連結

<a 
  href="https://github.com/username/repo"
  target="_blank"
  rel="noopener noreferrer"
  class="external-link"
>
  View Source Code
</a>

Astro 組件

---
interface Props {
  href: string;
  children: any;
  class?: string;
}

const { href, class: className } = Astro.props;
const isExternal = href.startsWith('http');
---

<a 
  href={href}
  class={className}
  {...isExternal && {
    target: "_blank",
    rel: "noopener noreferrer"
  }}
>
  <slot />
  {isExternal && <span aria-hidden="true"> ↗</span>}
</a>

React 組件

interface ExternalLinkProps {
  href: string;
  children: React.ReactNode;
  className?: string;
}

export function ExternalLink({ href, children, className }: ExternalLinkProps) {
  const isExternal = href.startsWith('http');
  
  return (
    <a 
      href={href}
      className={className}
      {...(isExternal && {
        target: "_blank",
        rel: "noopener noreferrer"
      })}
    >
      {children}
      {isExternal && <span aria-hidden="true"> ↗</span>}
    </a>
  );
}

CSS 視覺提示

/* 外部連結加上箭頭圖示 */
a[target="_blank"]::after {
  content: " ↗";
  font-size: 0.8em;
}

/* 或使用 SVG 圖示 */
a[target="_blank"]::after {
  content: "";
  display: inline-block;
  width: 1em;
  height: 1em;
  margin-left: 0.2em;
  background-image: url("data:image/svg+xml,...");
  background-size: contain;
}

自動化檢查

ESLint 規則

使用 eslint-plugin-jsx-a11y

// .eslintrc.js
module.exports = {
  plugins: ['jsx-a11y'],
  rules: {
    'jsx-a11y/anchor-is-valid': 'warn',
  },
};

HTML Linter

使用 htmlhint

// .htmlhintrc
{
  "attr-unsafe-chars": true
}

自定義腳本檢查

// scripts/check-external-links.js
const fs = require('fs');
const path = require('path');
const glob = require('glob');

const files = glob.sync('src/**/*.{astro,html,tsx}');

files.forEach(file => {
  const content = fs.readFileSync(file, 'utf8');
  
  // 找出所有外部連結
  const regex = /href=["']https?:\/\/[^"']+["'][^>]*>/g;
  const matches = content.match(regex) || [];
  
  matches.forEach(match => {
    if (match.includes('target="_blank"') && 
        !match.includes('rel="noopener')) {
      console.warn(`⚠️  ${file}: Missing rel="noopener" on external link`);
      console.warn(`   ${match.substring(0, 80)}...`);
    }
  });
});

Git Hook(pre-commit)

#!/bin/sh
# .husky/pre-commit

# 檢查是否有不安全的外部連結
grep -rn 'target="_blank"' src/ | grep -v 'noopener' && {
  echo "❌ Found target=\"_blank\" without rel=\"noopener\""
  exit 1
}

exit 0

進階考量

什麼時候不需要這些屬性

內部連結

<!-- 內部連結不需要 noopener -->
<a href="/about" target="_blank">
  About Us
</a>

因為 window.opener 指向的是你自己的網站,沒有安全風險。

但是,一般來說內部連結也不應該用 target="_blank"

同源連結

如果連結是同一個網域(same-origin),瀏覽器的同源策略已經提供保護。

什麼時候應該保留 referrer

<!-- 合作夥伴可能需要追蹤流量來源 -->
<a 
  href="https://partner-site.com?ref=yoursite"
  target="_blank"
  rel="noopener"  <!-- 只用 noopener,不用 noreferrer -->
>
  Partner Site
</a>

效能考量

target="_blank" 還有另一個問題:預設情況下,新分頁與原始分頁共享同一個渲染進程

這可能導致:

  • 新分頁的 JavaScript 影響原始頁面的效能
  • 記憶體使用增加

rel="noopener" 會讓新分頁在獨立的進程中運行,這也是效能優化。


總結

最佳實踐清單

  1. ✅ 所有外部連結加上 target="_blank"(如果需要新分頁)
  2. ✅ 所有 target="_blank" 連結加上 rel="noopener noreferrer"
  3. ✅ 考慮加上視覺提示(圖示)表明這是外部連結
  4. ✅ 使用自動化工具檢查遺漏
  5. ✅ 內部連結避免使用 target="_blank"

快速模板

<!-- 外部連結標準模板 -->
<a 
  href="https://example.com"
  target="_blank"
  rel="noopener noreferrer"
>
  Link Text
</a>

相關安全標頭

除了 HTML 屬性,也可以使用 HTTP 標頭:

# 強制所有連結使用 noopener
Cross-Origin-Opener-Policy: same-origin

但這是更進階的主題。


參考資源