比較及驗證網路來源和網站

網頁功能: 來源

雖然將 event.origin 與固定、通訊埠正規化的字串常值 (例如 event.origin === 'https://app.example.com') 進行比較,適用於簡單的許可清單,但手動字串比較和規則運算式在驗證任意網址、DOM 連結元素、跨子網域重新導向或沙箱訊息時會失效:

  • 前置字串和子字串假冒:檢查 url.startsWith('https://app.example.com') 時,系統會略過 'https://app.example.com.attacker.example'。
  • 未正規化的網址:如果完整網址包含路徑、查詢字串或明確的預設通訊埠 ('https://app.example.com:443/path'),在未先剖析及正規化的情況下,不會與 'https://app.example.com' 視為相同字串。
  • 公開尾碼清單錯誤:在 . 上分割主機名稱,以檢查相同網站關係,但無法在多標籤公開尾碼 (例如 .co.uk 或共用代管網域,例如 .github.io) 上執行,並忽略配置不符 (http: 與 https:)。
  • 不透明 "null" 來源衝突:沙箱 iframe (<iframe sandbox="allow-scripts">) 和 data: 網址會將 event.origin 序列化為常值字串 "null"。使用 originA === originB 比較兩個序列化執行階段來源,會誤將兩個不相關的不透明結構定義 ("null" === "null") 視為相同。

Origin API (Origin.from()、isSameOrigin()、isSameSite() 和 opaque) 提供結構化、符合規格的來源和結構化同網站比較,由瀏覽器的網址剖析器和公用後置字串清單支援。

根據情境選擇合適的比較項目

  • 使用 isSameOrigin() 嚴格限制權限:處理跨視窗 postMessage 指令 (例如嵌入式結帳或驗證彈出式視窗),或決定是否附加憑證時,必須完全比對 (scheme, host, port)。即使是不同的子網域 (https://blog.example.com 與 https://app.example.com),也屬於不同的來源,因此必須拒絕,以免某個子網域的漏洞控制另一個子網域。
  • 使用 isSameSite() 進行跨子網域導覽和連結分類:驗證登入後重新導向網址 (?returnTo=https://billing.example.co.uk/orders) 或將連結分類為內部或外部時,允許任何共用相同配置和可註冊網域的子網域或通訊埠。isSameSite() 會拒絕 http: 配置降級,以及共用公用後置字串 (.co.uk、.github.io) 的同層級租戶。
  • 針對不透明或沙箱化情境使用 origin.opaque 和 Origin.from(event):拒絕 data: 網址重新導向 (origin.opaque === true),或在與序列化 event.origin 為 "null" 的特定 <iframe sandbox="allow-scripts"> 小工具通訊時,固定透過 Origin.from(event) 擷取的不透明 Origin。

1. 驗證特殊權限動作的確切來源 (Origin.from() 和 isSameOrigin())

使用 Origin.from() 擷取 Origin 物件,並使用 isSameOrigin() 與信任的 Origin 進行比較:

  • 兩端都必須是 Origin 執行個體: isSameOrigin(other) 和 isSameSite(other) 只接受 Origin 物件,傳遞原始字串會擲回 TypeError。
  • 支援的 Origin.from() 輸入內容:
    • 序列化網址字串 (例如 'https://app.example.com:443/path')。請注意,任何無效的網址字串 (例如 'example',或 event.origin 針對不透明來源序列化的 'null' 字串),都會導致 Origin.from() 擲回 TypeError。
    • 在 HTML 規格中定義來源擷取步驟的平台物件:Origin、URL、HTMLAnchorElement (<a>)、HTMLAreaElement (<area>)、同源 Window 或 WorkerGlobalScope (Origin.from(self)),以及瀏覽器分派的 MessageEvent 執行個體 (Origin.from(event))。
    • Location、WorkerLocation、Document、Request 和 Response 等物件「不會」定義來源擷取步驟,如果直接傳遞至 Origin.from(),則會擲回 TypeError,請改為傳遞 self (適用於目前情境) 或 request.url/response.url。
  • 處理瀏覽器調度和合成 MessageEvent 執行個體:實際瀏覽器 postMessage 事件會攜帶由 Origin.from(event) 擷取的內部來源,而在單元測試中建立的合成 new MessageEvent('message', { origin }) 事件只會填入 event.origin 字串,並在 Origin.from(event) 上擲回。在 try...catch 內備用 Origin.from(event.origin) 可乾淨俐落地處理這兩種情況,同時仍會拒絕無效或 "null" 字串。
// Replace with your application's trusted origin(s)
const TRUSTED_ORIGIN = Origin.from('https://app.example.com');

function extractOrigin(candidate) {
  try {
    return Origin.from(candidate);
  } catch (err) {
    // Synthetic new MessageEvent('message', { origin }) objects in tests lack an
    // internal browser origin; fall back to parsing candidate.origin if it is a URL.
    if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
      return Origin.from(candidate.origin);
    }
    throw err;
  }
}

export function isTrustedSameOrigin(candidate) {
  try {
    return extractOrigin(candidate).isSameOrigin(TRUSTED_ORIGIN);
  } catch {
    // Rejects invalid URLs, "null" strings, or unsupported objects
    return false;
  }
}

window.addEventListener('message', (event) => {
  if (!isTrustedSameOrigin(event)) return;

  // Safe to process privileged commands from the verified origin
});

驗證重新導向目標 (例如 ?returnTo= 參數) 或同一可註冊網域的子網域 (例如 https://app.example.co.uk 和 https://billing.example.co.uk:8443) 之間的內部連結時,請使用 isSameSite():

  • isSameSite() 會執行依配置區分相同網站的比較:https://sub.example.com 和 http://sub.example.com 的配置不同,因此「不」屬於相同網站。
  • isSameSite() 使用瀏覽器內建的公用後置字串清單,因此共用公用後置字串的不同租戶 (例如 https://tenant-a.github.io 和 https://tenant-b.github.io,或 https://a.co.uk 和 https://b.co.uk) 會評估為 false。
// Replace with your application's primary origin
const APP_ORIGIN = Origin.from('https://app.example.co.uk');

export function isAllowedSameSiteUrl(candidateUrlOrElement) {
  try {
    return extractOrigin(candidateUrlOrElement).isSameSite(APP_ORIGIN);
  } catch {
    return false;
  }
}

// true: same scheme (https) and same registrable domain (example.co.uk)
isAllowedSameSiteUrl('https://billing.example.co.uk:8443/checkout');

// false: scheme downgrade (http vs. https) is rejected by schemeful same-site
isAllowedSameSiteUrl('http://billing.example.co.uk/checkout');

3. 區分及固定不透明來源 (origin.opaque 和 Origin.from(event))

沙箱 iframe (<iframe sandbox="allow-scripts">)、data: 網址和 new Origin() 會產生不透明來源 (origin.opaque === true)。雖然其字串序列化一律為 "null",但 Origin.from(event) 會從瀏覽器分派的 MessageEvent 中擷取傳送者的實際基礎不透明來源:

  • 從「相同」沙箱 iframe 傳送的兩則訊息會產生彼此 isSameOrigin() 的 Origin 物件 (true)。
  • 從「不同」沙箱 iframe 傳送的訊息,或個別的 Origin.from('data:...')/new Origin() 呼叫,會產生不同的不透明來源,isSameOrigin() 會評估為 false,防止 "null" === "null" 在不相關的不透明情境中遭到假冒。
  • new Origin() 會建立全新、不重複的不透明來源,除了本身以外,不與任何來源相符。在初始化時固定預期的對等來源之前,這項功能可做為預設的「全部拒絕」哨兵。
// Initialize with a unique opaque sentinel that matches no other origin
let pinnedSandboxOrigin = new Origin();
let isSandboxPinned = false;

export function handleSandboxedWidgetMessage(event, expectedWidgetWindow) {
  try {
    const senderOrigin = Origin.from(event);

    // Pin the opaque origin only once on initialization from the expected sandboxed iframe
    if (!isSandboxPinned && event.source === expectedWidgetWindow && event.data?.type === 'init') {
      pinnedSandboxOrigin = senderOrigin;
      isSandboxPinned = true;
    }

    // Subsequent messages from the SAME sandboxed iframe match pinnedSandboxOrigin;
    // messages from any other sandboxed iframe (also event.origin === "null") return false.
    return senderOrigin.isSameOrigin(pinnedSandboxOrigin);
  } catch {
    return false;
  }
}

最佳做法

  • 務必先透過 Origin.from() 將比較項目的兩端轉換為 Origin 例項,再呼叫 isSameOrigin(other) 或 isSameSite(other)。
  • 務必在需要保留不透明來源身分時,將瀏覽器分派的 MessageEvent 直接傳遞至 Origin.from(event),並使用 Origin.from(self) (而非會擲回 TypeError 的 Location 或 Document) 取得目前執行環境的 Origin。
  • 務必將 Origin.from() 呼叫包裝在 try...catch 區塊中,用於不受信任或執行階段輸入,這樣無效的網址、"null" 字串或不支援的物件就會安全地傳回 false,而不是擲回未擷取的 TypeError。
  • 請勿直接比較兩個執行階段 event.origin 或 url.origin 字串,而不先拒絕 "null" (origin !== 'null'),因為兩個不相關的不透明來源都會序列化為 "null"。
  • 請勿使用 startsWith()、includes()、endsWith() 或簡易 .split('.') 主機名稱切片驗證來源或網站。

備用策略

如果基準目標包含尚未支援 Origin 介面的瀏覽器,請偵測 'Origin' in globalThis 功能,並改用 new URL() 剖析網址:

  • 同源備用 (元組來源):擷取網址字串 (MessageEvent 物件使用 event.origin,連結元素使用 candidate.href),使用 new URL() 剖析候選網址和信任網址,拒絕 "null" 不透明來源 (candidateUrl.origin !== 'null'),並比較 candidateUrl.origin === trustedUrl.origin。由於不透明來源與元組來源 (例如 'https://app.example.com') 絕非相同來源,因此立即拒絕 'null' 符合 Origin.isSameOrigin()。
  • 同站備用 (注意事項):URL 介面不會公開瀏覽器的公開字尾清單。在不支援 Origin 的瀏覽器中,只對您控制的明確、已知私人可註冊網域進行尾碼比對 (驗證 protocol 和完全相符或以點開頭的 hostname),或使用維護的公開尾碼清單程式庫。
  • 不透明/沙箱 iframe 備用方案:沒有 Origin 的瀏覽器不會在 MessageEvent 上公開不同的不透明來源身分 (event.origin 一律為 "null")。如果您刻意接受來自已知沙箱 <iframe sandbox="allow-scripts"> 的訊息,請驗證 event.source === expectedWidgetWindow (或透過在初始化時轉移至 iframe 的專屬 MessageChannel MessagePort 進行通訊),而非比較 event.origin。
// Replace with your trusted reference URL and known registrable domain
const TRUSTED_URL = 'https://app.example.com';
const TRUSTED_SITE_DOMAIN = 'example.com';

function extractUrlString(candidate) {
  if (typeof candidate === 'string') return candidate;
  if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
    return candidate.origin;
  }
  if (candidate === globalThis) {
    return globalThis.location?.href ?? '';
  }
  return candidate?.href ?? '';
}

function extractOrigin(candidate) {
  try {
    return Origin.from(candidate);
  } catch (err) {
    if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
      return Origin.from(candidate.origin);
    }
    throw err;
  }
}

export function checkSameOriginWithFallback(candidate) {
  try {
    if ('Origin' in globalThis) {
      return extractOrigin(candidate).isSameOrigin(Origin.from(TRUSTED_URL));
    }
    const candidateUrl = new URL(extractUrlString(candidate));
    const trustedUrl = new URL(TRUSTED_URL);
    return candidateUrl.origin !== 'null' && candidateUrl.origin === trustedUrl.origin;
  } catch {
    return false;
  }
}

export function checkSameSiteWithFallback(candidate) {
  try {
    if ('Origin' in globalThis) {
      return extractOrigin(candidate).isSameSite(Origin.from(TRUSTED_URL));
    }
    const candidateUrl = new URL(extractUrlString(candidate));
    const trustedUrl = new URL(TRUSTED_URL);
    return (
      candidateUrl.origin !== 'null' &&
      candidateUrl.protocol === trustedUrl.protocol &&
      (candidateUrl.hostname === TRUSTED_SITE_DOMAIN ||
        candidateUrl.hostname.endsWith(`.${TRUSTED_SITE_DOMAIN}`))
    );
  } catch {
    return false;
  }
}