Comparar e validar origens da web e sites

Recursos da Web: Origem

Embora a comparação de event.origin com um literal de string fixo e normalizado por porta (como event.origin === 'https://app.example.com') funcione para listas de permissões simples, as comparações manuais de strings e as expressões regulares falham ao validar URLs arbitrários, elementos de link do DOM, redirecionamentos entre subdomínios ou mensagens em sandbox:

  • Spoofing de prefixo e substring: a verificação de url.startsWith('https://app.example.com') é ignorada por 'https://app.example.com.attacker.example'.
  • URLs não normalizados: URLs completos com caminhos, strings de consulta ou portas padrão explícitas ('https://app.example.com:443/path') não correspondem a 'https://app.example.com' em igualdade de string sem primeiro analisar e normalizar.
  • Erros na lista de sufixos públicos: a divisão de nomes de host em . para verificar relações no mesmo site falha em sufixos públicos com vários rótulos (como .co.uk ou domínios hospedados como .github.io) e ignora incompatibilidades de esquema (http: x https:).
  • Colisões de origem "null" opacas: iframes em sandbox (<iframe sandbox="allow-scripts">) e URLs data: serializam event.origin para a string literal "null". Comparar duas origens de ambiente de execução serializadas com originA === originB iguala falsamente dois contextos opacos não relacionados ("null" === "null").

A API Origin (Origin.from(), isSameOrigin(), isSameSite() e opaque) oferece comparações estruturadas de origem e de mesmo site com esquema, em conformidade com as especificações, apoiadas pelo analisador de URL do navegador e pela lista de sufixos públicos.

Como escolher a comparação certa para seu cenário

  • Use isSameOrigin() para limites de privilégio estritos: exija uma correspondência exata de (scheme, host, port) ao processar comandos postMessage entre janelas (como um pop-up de finalização de compra ou autenticação incorporado) ou ao decidir se as credenciais serão anexadas. Até mesmo um subdomínio diferente (https://blog.example.com em vez de https://app.example.com) é uma origem diferente e precisa ser rejeitado para que uma vulnerabilidade em um subdomínio não controle outro.
  • Use isSameSite() para navegação entre subdomínios e classificação de links: permita que qualquer subdomínio ou porta compartilhe o mesmo esquema e domínio registrável ao validar URLs de redirecionamento pós-login (?returnTo=https://billing.example.co.uk/orders) ou classificar links como internos ou externos. O isSameSite() rejeita downgrades do esquema http: e locatários irmãos em sufixos públicos compartilhados (.co.uk, .github.io).
  • Use origin.opaque e Origin.from(event) para contextos opacos ou em sandbox: rejeite URLs data: para redirecionamentos (origin.opaque === true) ou fixe o Origin opaco extraído via Origin.from(event) ao se comunicar com um widget <iframe sandbox="allow-scripts"> específico cujo event.origin serializado é "null".

1. Verificar origens exatas para ações privilegiadas (Origin.from() e isSameOrigin())

Use Origin.from() para extrair um objeto Origin e compará-lo com um Origin confiável usando isSameOrigin():

  • Os dois lados precisam ser instâncias de Origin: isSameOrigin(other) e isSameSite(other) só aceitam um objeto Origin. Transmitir uma string bruta gera um TypeError.
  • Entradas de Origin.from() compatíveis:
    • Strings de URL serializadas (por exemplo, 'https://app.example.com:443/path'). Qualquer string de URL inválida, como 'example' ou a string 'null' serializada por event.origin para origens opacas, faz com que Origin.from() gere um TypeError.
    • Objetos de plataforma que definem etapas de extração de origem na especificação HTML: Origin, URL, HTMLAnchorElement (<a>), HTMLAreaElement (<area>), Window ou WorkerGlobalScope (Origin.from(self)) de mesma origem e instâncias de MessageEvent (Origin.from(event)) enviadas pelo navegador.
    • Objetos como Location, WorkerLocation, Document, Request e Response não definem etapas de extração de origem e geram um TypeError se transmitidos diretamente para Origin.from(). Transmita self (para o contexto atual) ou request.url / response.url.
  • Processar instâncias MessageEvent sintéticas e enviadas pelo navegador: os eventos postMessage reais do navegador carregam uma origem interna extraída por Origin.from(event), enquanto os eventos new MessageEvent('message', { origin }) sintéticos criados em testes de unidade preenchem apenas a string event.origin e são gerados em Origin.from(event). O uso de Origin.from(event.origin) dentro de um try...catch lida com ambos de forma limpa, rejeitando strings inválidas ou "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
});

Ao validar destinos de redirecionamento (como parâmetros ?returnTo=) ou links internos em subdomínios do mesmo domínio registrável (por exemplo, https://app.example.co.uk e https://billing.example.co.uk:8443), use isSameSite():

  • isSameSite() realiza uma comparação de mesmo site com esquema: https://sub.example.com e http://sub.example.com não são o mesmo site porque os esquemas são diferentes.
  • O isSameSite() usa a lista de sufixos públicos integrada do navegador. Assim, locatários distintos em sufixos públicos compartilhados (como https://tenant-a.github.io e https://tenant-b.github.io ou https://a.co.uk e https://b.co.uk) são avaliados como 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. Distinguir e fixar origens opacas (origin.opaque e Origin.from(event))

Iframes em sandbox (<iframe sandbox="allow-scripts">), URLs data: e new Origin() produzem origens opacas (origin.opaque === true). Embora a serialização de string seja sempre "null", Origin.from(event) extrai a origem opaca real do remetente de um MessageEvent enviado pelo navegador:

  • Duas mensagens enviadas do mesmo iframe em sandbox geram objetos Origin que são isSameOrigin() entre si (true).
  • As mensagens enviadas de iframes em sandbox diferentes ou chamadas Origin.from('data:...') / new Origin() separadas geram origens opacas distintas que avaliam isSameOrigin() como false, impedindo a falsificação de "null" === "null" em contextos opacos não relacionados.
  • new Origin() cria uma origem opaca nova e exclusiva que não corresponde a nada, exceto a si mesma, o que é útil como uma sentinela padrão de negação total antes que uma origem de peering esperada seja fixada uma vez na inicialização.
// 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;
  }
}

Práticas recomendadas

  • RECOMENDAMOS converter os dois lados de uma comparação em instâncias Origin usando Origin.from() antes de chamar isSameOrigin(other) ou isSameSite(other).
  • É RECOMENDÁVEL transmitir um MessageEvent enviado pelo navegador diretamente para Origin.from(event) quando você precisar preservar a identidade de origem opaca e usar Origin.from(self) (em vez de Location ou Document, que geram TypeError) para receber o Origin do contexto de execução atual.
  • FAÇA o encapsulamento de chamadas Origin.from() em entradas não confiáveis ou de ambiente de execução em um bloco try...catch para que URLs inválidos, strings "null" ou objetos não compatíveis retornem false com segurança em vez de gerar um TypeError não capturado.
  • NÃO compare duas strings de ambiente de execução event.origin ou url.origin diretamente sem antes rejeitar "null" (origin !== 'null'), já que duas origens opacas não relacionadas são serializadas para "null".
  • NÃO valide origens ou sites com startsWith(), includes(), endsWith() ou fatiamento simples de nome do host .split('.').

Estratégia de fallback

Se o destino do Baseline incluir navegadores que ainda não oferecem suporte à interface Origin, use a detecção de recursos para 'Origin' in globalThis e use new URL() como substituto para analisar URLs:

  • Substituição de mesma origem (origens de tupla): extraia a string de URL (usando event.origin para objetos MessageEvent ou candidate.href para elementos de link), analise os URLs candidatos e confiáveis com new URL(), rejeite origens opacas "null" (candidateUrl.origin !== 'null') e compare candidateUrl.origin === trustedUrl.origin. Como uma origem opaca nunca é de mesma origem com uma origem de tupla como 'https://app.example.com', rejeitar imediatamente 'null' corresponde a Origin.isSameOrigin().
  • Substituto do mesmo site (ressalva): a interface URL não expõe a lista de sufixos públicos do navegador. Em navegadores sem Origin, faça a correspondência de sufixo apenas com um domínio registrável particular explícito e conhecido que você controla (verificando protocol e uma correspondência exata ou com prefixo de ponto hostname) ou use uma biblioteca de lista de sufixos públicos mantida.
  • Fallback de iframe opaco / em sandbox: navegadores sem Origin não expõem identidades de origem opacas distintas em MessageEvent (event.origin é sempre "null"). Se você aceitar intencionalmente mensagens de um <iframe sandbox="allow-scripts"> em sandbox conhecido, verifique event.source === expectedWidgetWindow (ou se comunique por um MessageChannel MessagePort dedicado transferido para o iframe na inicialização) em vez de comparar 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;
  }
}