Compara y valida los orígenes y sitios web

Funciones web: Origen

Si bien comparar event.origin con un literal de cadena fijo y normalizado por puerto (como event.origin === 'https://app.example.com') funciona para listas de entidades permitidas simples, las comparaciones de cadenas manuales y las expresiones regulares fallan cuando se validan URLs arbitrarias, elementos de vínculo del DOM, redireccionamientos entre subdominios o mensajes de zona de pruebas:

  • Falsificación de identidad de prefijos y subcadenas: La verificación de url.startsWith('https://app.example.com') se omite con 'https://app.example.com.attacker.example'.
  • URLs no normalizadas: Las URLs completas con rutas de acceso, cadenas de consulta o puertos predeterminados explícitos ('https://app.example.com:443/path') no coinciden con 'https://app.example.com' en la igualdad de cadenas sin antes analizar y normalizar.
  • Errores en la lista de sufijos públicos: Dividir los nombres de host en . para verificar las relaciones del mismo sitio falla en los sufijos públicos de varias etiquetas (como .co.uk o dominios de alojamiento compartido como .github.io) y pasa por alto las discrepancias de esquemas (http: frente a https:).
  • Colisiones de origen "null" opaco: Los iframes de zona de pruebas (<iframe sandbox="allow-scripts">) y las URLs de data: serializan event.origin en la cadena literal "null". Comparar dos orígenes de entorno de ejecución serializados con originA === originB equipara de forma falsa dos contextos opacos no relacionados ("null" === "null").

La API de Origin (Origin.from(), isSameOrigin(), isSameSite() y opaque) proporciona comparaciones estructuradas de origen y de mismo sitio con esquema que cumplen con las especificaciones, respaldadas por el analizador de URLs del navegador y la lista de sufijos públicos.

Elige la comparación adecuada para tu situación

  • Usa isSameOrigin() para límites de privilegios estrictos: Exige una coincidencia exacta de (scheme, host, port) cuando manejes comandos postMessage entre ventanas (como un pago incorporado o una ventana emergente de autenticación) o decidas si adjuntar credenciales. Incluso un subdominio diferente (https://blog.example.com frente a https://app.example.com) es un origen diferente y se debe rechazar para que una vulnerabilidad en un subdominio no pueda controlar otro.
  • Usa isSameSite() para la navegación entre subdominios y la clasificación de vínculos: Permite cualquier subdominio o puerto que comparta el mismo esquema y dominio registrable cuando se validen las URLs de redireccionamiento posteriores al acceso (?returnTo=https://billing.example.co.uk/orders) o se clasifiquen los vínculos como internos o externos. isSameSite() rechaza las versiones anteriores del esquema http: y los usuarios hermanos en sufijos públicos compartidos (.co.uk, .github.io).
  • Usa origin.opaque y Origin.from(event) para contextos opacos o de zona de pruebas: Rechaza las URLs data: para los redireccionamientos (origin.opaque === true) o fija el Origin opaco extraído a través de Origin.from(event) cuando te comuniques con un widget <iframe sandbox="allow-scripts"> específico cuyo event.origin serializado sea "null".

1. Verifica los orígenes exactos para las acciones con privilegios (Origin.from() y isSameOrigin())

Usa Origin.from() para extraer un objeto Origin y compararlo con un Origin de confianza usando isSameOrigin():

  • Ambos lados deben ser instancias de Origin: isSameOrigin(other) y isSameSite(other) solo aceptan un objeto Origin; pasar una cadena sin procesar arroja un TypeError.
  • Entradas de Origin.from() compatibles:
    • Son cadenas de URL serializadas (por ejemplo, 'https://app.example.com:443/path'). Ten en cuenta que cualquier cadena de URL no válida, por ejemplo, 'example' o la cadena 'null' serializada por event.origin para orígenes opacos, hace que Origin.from() arroje un TypeError.
    • Objetos de plataforma que definen pasos de extracción de origen en la especificación de HTML: Origin, URL, HTMLAnchorElement (<a>), HTMLAreaElement (<area>), Window o WorkerGlobalScope del mismo origen (Origin.from(self)) e instancias de MessageEvent enviadas por el navegador (Origin.from(event)).
    • Los objetos como Location, WorkerLocation, Document, Request y Response no definen pasos de extracción de origen y arrojan un TypeError si se pasan directamente a Origin.from(). En su lugar, pasa self (para el contexto actual) o request.url/response.url.
  • Controla las instancias de MessageEvent sintéticas y enviadas por el navegador: Los eventos postMessage reales del navegador llevan un origen interno extraído por Origin.from(event), mientras que los eventos new MessageEvent('message', { origin }) sintéticos creados en pruebas de unidades solo propagan la cadena event.origin y arrojan un error en Origin.from(event). Si recurres a Origin.from(event.origin) dentro de un try...catch, se controlan ambos de forma limpia, mientras que se rechazan las cadenas no válidas o "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
});

Cuando valides destinos de redireccionamiento (como los parámetros ?returnTo=) o vínculos internos en subdominios del mismo dominio registrable (por ejemplo, https://app.example.co.uk y https://billing.example.co.uk:8443), usa isSameSite():

  • isSameSite() realiza una comparación de mismo sitio con esquema: https://sub.example.com y http://sub.example.com no son del mismo sitio porque sus esquemas difieren.
  • isSameSite() usa la lista de sufijos públicos integrada del navegador, por lo que los distintos usuarios en sufijos públicos compartidos (como https://tenant-a.github.io y https://tenant-b.github.io, o https://a.co.uk y https://b.co.uk) se evalúan 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 y fijar orígenes opacos (origin.opaque y Origin.from(event))

Los iframes de zona de pruebas (<iframe sandbox="allow-scripts">), las URLs de data: y new Origin() producen orígenes opacos (origin.opaque === true). Si bien su serialización de cadenas siempre es "null", Origin.from(event) extrae el origen opaco subyacente real del remitente de un MessageEvent enviado por el navegador:

  • Dos mensajes enviados desde el mismo iframe de zona de pruebas producen objetos Origin que son isSameOrigin() entre sí (true).
  • Los mensajes enviados desde diferentes iframes de zona de pruebas, o llamadas Origin.from('data:...')/new Origin() independientes, producen orígenes opacos distintos que evalúan isSameOrigin() como false, lo que evita la falsificación de "null" === "null" en contextos opacos no relacionados.
  • new Origin() crea un origen opaco nuevo y único que no coincide con nada, excepto consigo mismo, lo que es útil como un centinela predeterminado de denegación total antes de que se fije un origen de par esperado una vez en la inicialización.
// 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ácticas recomendadas

  • SÍ convierte ambos lados de una comparación en instancias de Origin a través de Origin.from() antes de llamar a isSameOrigin(other) o isSameSite(other).
  • SÍ pases un MessageEvent enviado por el navegador directamente a Origin.from(event) cuando necesites conservar la identidad de origen opaca y usa Origin.from(self) (en lugar de Location o Document, que arrojan TypeError) para obtener el Origin del contexto de ejecución actual.
  • SÍ une las llamadas de Origin.from() en entradas no confiables o de tiempo de ejecución en un bloque try...catch para que las URLs no válidas, las cadenas "null" o los objetos no admitidos devuelvan false de forma segura en lugar de arrojar un TypeError no detectado.
  • NO compares dos cadenas event.origin o url.origin de entorno de ejecución directamente sin rechazar primero "null" (origin !== 'null'), ya que dos orígenes opacos no relacionados se serializan en "null".
  • NO valides orígenes ni sitios con startsWith(), includes(), endsWith() ni con una división ingenua del nombre de host .split('.').

Estrategia de resguardo

Si tu objetivo de Baseline incluye navegadores que aún no admiten la interfaz Origin, detecta la función 'Origin' in globalThis y recurre al análisis de URLs con new URL():

  • Resguardo del mismo origen (orígenes de tupla): Extrae la cadena de la URL (con event.origin para los objetos MessageEvent o candidate.href para los elementos de vínculo), analiza las URLs candidatas y de confianza con new URL(), rechaza los orígenes opacos de "null" (candidateUrl.origin !== 'null') y compara candidateUrl.origin === trustedUrl.origin. Debido a que un origen opaco nunca es del mismo origen que un origen de tupla como 'https://app.example.com', rechazar de inmediato 'null' coincide con Origin.isSameOrigin().
  • Resguardo del mismo sitio (advertencia): La interfaz URL no expone la lista de sufijos públicos del navegador. En los navegadores sin Origin, solo realiza la coincidencia de sufijos con un dominio registrable privado explícito y conocido que controles (verificando tanto protocol como una coincidencia exacta o con prefijo de punto hostname) o usa una biblioteca de lista de sufijos públicos mantenida.
  • Resguardo de iframe opaco o de zona de pruebas: Los navegadores sin Origin no exponen identidades de origen opacas distintas en MessageEvent (event.origin siempre es "null"). Si aceptas intencionalmente mensajes de un <iframe sandbox="allow-scripts"> de zona de pruebas conocido, verifica event.source === expectedWidgetWindow (o comunícate a través de un MessageChannel MessagePort dedicado transferido al iframe durante la inicialización) en lugar 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;
  }
}