虽然将 event.origin 与固定的、端口标准化的字符串字面量(例如 event.origin === 'https://app.example.com')进行比较适用于简单的许可清单,但在验证任意网址、DOM 链接元素、跨子网域重定向或沙盒化消息时,手动字符串比较和正则表达式会失效:
- 前缀和子字符串仿冒:
'https://app.example.com.attacker.example'会绕过对url.startsWith('https://app.example.com')的检查。 - 非规范化网址:包含路径、查询字符串或显式默认端口 (
'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">widget 通信时,固定通过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())
在验证同一可注册网域的子网域(例如 https://app.example.co.uk 和 https://billing.example.co.uk:8443)之间的重定向目标(例如 ?returnTo= 参数)或内部链接时,请使用 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()(true) 的Origin对象。 - 从不同沙盒化 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;
}
}
最佳做法
- 务必在调用
isSameOrigin(other)或isSameSite(other)之前,通过Origin.from()将比较双方都转换为Origin实例。 - 务必在需要保留不透明源身份时,将浏览器分派的
MessageEvent直接传递给Origin.from(event),并使用Origin.from(self)(而不是会抛出TypeError的Location或Document)来获取当前执行上下文的Origin。 - 务必将针对不受信任的输入或运行时输入的
Origin.from()调用封装在try...catch代码块中,以便无效网址、"null"字符串或不受支持的对象安全地返回false,而不是抛出未捕获的TypeError。 - 请勿在未先拒绝
"null"(origin !== 'null') 的情况下直接比较两个运行时event.origin或url.origin字符串,因为两个不相关的不透明源都会序列化为"null"。 - 请勿使用
startsWith()、includes()、endsWith()或简单的.split('.')主机名切片来验证来源或网站。
后备策略
如果您的基准目标包括尚不支持 Origin 接口的浏览器,请检测 'Origin' in globalThis 功能,并回退到使用 new URL() 解析网址:
- 同源回退(元组源):提取网址字符串(对
MessageEvent对象使用event.origin,对 link 元素使用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;
}
}