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.uko dominios de alojamiento compartido como.github.io) y pasa por alto las discrepancias de esquemas (http:frente ahttps:). - Colisiones de origen
"null"opaco: Los iframes de zona de pruebas (<iframe sandbox="allow-scripts">) y las URLs dedata:serializanevent.originen la cadena literal"null". Comparar dos orígenes de entorno de ejecución serializados conoriginA === originBequipara 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 comandospostMessageentre ventanas (como un pago incorporado o una ventana emergente de autenticación) o decidas si adjuntar credenciales. Incluso un subdominio diferente (https://blog.example.comfrente ahttps://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 esquemahttp:y los usuarios hermanos en sufijos públicos compartidos (.co.uk,.github.io). - Usa
origin.opaqueyOrigin.from(event)para contextos opacos o de zona de pruebas: Rechaza las URLsdata:para los redireccionamientos (origin.opaque === true) o fija elOriginopaco extraído a través deOrigin.from(event)cuando te comuniques con un widget<iframe sandbox="allow-scripts">específico cuyoevent.originserializado 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)yisSameSite(other)solo aceptan un objetoOrigin; pasar una cadena sin procesar arroja unTypeError. - 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 porevent.originpara orígenes opacos, hace queOrigin.from()arroje unTypeError. - Objetos de plataforma que definen pasos de extracción de origen en la especificación de HTML:
Origin,URL,HTMLAnchorElement(<a>),HTMLAreaElement(<area>),WindowoWorkerGlobalScopedel mismo origen (Origin.from(self)) e instancias deMessageEventenviadas por el navegador (Origin.from(event)). - Los objetos como
Location,WorkerLocation,Document,RequestyResponseno definen pasos de extracción de origen y arrojan unTypeErrorsi se pasan directamente aOrigin.from(). En su lugar, pasaself(para el contexto actual) orequest.url/response.url.
- Son cadenas de URL serializadas (por ejemplo,
- Controla las instancias de
MessageEventsintéticas y enviadas por el navegador: Los eventospostMessagereales del navegador llevan un origen interno extraído porOrigin.from(event), mientras que los eventosnew MessageEvent('message', { origin })sintéticos creados en pruebas de unidades solo propagan la cadenaevent.originy arrojan un error enOrigin.from(event). Si recurres aOrigin.from(event.origin)dentro de untry...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
});
2. Valida los redireccionamientos y vínculos entre subdominios (isSameSite())
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.comyhttp://sub.example.comno 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 (comohttps://tenant-a.github.ioyhttps://tenant-b.github.io, ohttps://a.co.ukyhttps://b.co.uk) se evalúan comofalse.
// 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
Originque sonisSameOrigin()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úanisSameOrigin()comofalse, 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
Origina través deOrigin.from()antes de llamar aisSameOrigin(other)oisSameSite(other). - SÍ pases un
MessageEventenviado por el navegador directamente aOrigin.from(event)cuando necesites conservar la identidad de origen opaca y usaOrigin.from(self)(en lugar deLocationoDocument, que arrojanTypeError) para obtener elOrigindel contexto de ejecución actual. - SÍ une las llamadas de
Origin.from()en entradas no confiables o de tiempo de ejecución en un bloquetry...catchpara que las URLs no válidas, las cadenas"null"o los objetos no admitidos devuelvanfalsede forma segura en lugar de arrojar unTypeErrorno detectado. - NO compares dos cadenas
event.originourl.originde 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.originpara los objetosMessageEventocandidate.hrefpara los elementos de vínculo), analiza las URLs candidatas y de confianza connew URL(), rechaza los orígenes opacos de"null"(candidateUrl.origin !== 'null') y comparacandidateUrl.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 conOrigin.isSameOrigin(). - Resguardo del mismo sitio (advertencia): La interfaz
URLno expone la lista de sufijos públicos del navegador. En los navegadores sinOrigin, solo realiza la coincidencia de sufijos con un dominio registrable privado explícito y conocido que controles (verificando tantoprotocolcomo una coincidencia exacta o con prefijo de puntohostname) o usa una biblioteca de lista de sufijos públicos mantenida. - Resguardo de iframe opaco o de zona de pruebas: Los navegadores sin
Originno exponen identidades de origen opacas distintas enMessageEvent(event.originsiempre es"null"). Si aceptas intencionalmente mensajes de un<iframe sandbox="allow-scripts">de zona de pruebas conocido, verificaevent.source === expectedWidgetWindow(o comunícate a través de unMessageChannelMessagePortdedicado transferido al iframe durante la inicialización) en lugar de compararevent.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;
}
}