雖然將 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
});
2. 驗證跨子網域重新導向和連結 (isSameSite())
驗證重新導向目標 (例如 ?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 的專屬MessageChannelMessagePort進行通訊),而非比較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;
}
}