比较和验证网站源和网站

Web 功能: 源

虽然将 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
});

在验证同一可注册网域的子网域(例如 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 的专用 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;
  }
}