Confronta e convalida origini web e siti

Funzionalità web: Origin

Sebbene il confronto di event.origin con un valore letterale stringa fisso e normalizzato per la porta (come event.origin === 'https://app.example.com') funzioni per le liste consentite semplici, i confronti manuali delle stringhe e le espressioni regolari non funzionano quando si convalidano URL arbitrari, elementi link DOM, reindirizzamenti tra sottodomini o messaggi in sandbox:

  • Spoofing di prefissi e sottostringhe: il controllo di url.startsWith('https://app.example.com') viene ignorato da 'https://app.example.com.attacker.example'.
  • URL non normalizzati: gli URL completi con percorsi, stringhe di query o porte predefinite esplicite ('https://app.example.com:443/path') non corrispondono a 'https://app.example.com' in base all'uguaglianza delle stringhe senza prima eseguire l'analisi e la normalizzazione.
  • Errori nell'elenco dei suffissi pubblici: la suddivisione dei nomi host su . per verificare le relazioni SameSite non riesce sui suffissi pubblici con più etichette (come .co.uk o domini ospitati come .github.io) e ignora le mancate corrispondenze degli schemi (http: rispetto a https:).
  • Collisioni di origini opache "null": gli iframe con sandbox (<iframe sandbox="allow-scripts">) e gli URL data: serializzano event.origin nella stringa letterale "null". Il confronto di due origini di runtime serializzate con originA === originB equipara erroneamente due contesti opachi non correlati ("null" === "null").

L'API Origin (Origin.from(), isSameOrigin(), isSameSite() e opaque) fornisce confronti strutturati e conformi alle specifiche di origine e schemeful same-site supportati dal parser URL del browser e dall'elenco dei suffissi pubblici.

Scegliere il confronto giusto per il tuo scenario

  • UtilizzoisSameOrigin() per limiti di privilegio rigorosi: Richiedi una (scheme, host, port) corrispondenza esatta durante la gestione dei postMessage comandi tra finestre (come un popup di pagamento o di autenticazione incorporato) o decidere se allegare le credenziali. Anche un sottodominio diverso (https://blog.example.com rispetto a https://app.example.com) è un'origine diversa e deve essere rifiutato in modo che una vulnerabilità in un sottodominio non possa controllarne un altro.
  • Utilizza isSameSite() per la navigazione tra sottodomini e la classificazione dei link: consenti qualsiasi sottodominio o porta che condivida lo stesso schema e dominio registrabile durante la convalida degli URL di reindirizzamento post-accesso (?returnTo=https://billing.example.co.uk/orders) o la classificazione dei link come interni o esterni. isSameSite() rifiuta sia i downgrade dello schema http: sia i tenant di pari livello su suffissi pubblici condivisi (.co.uk, .github.io).
  • Utilizza origin.opaque e Origin.from(event) per contesti opachi o in sandbox: rifiuta gli URL data: per i reindirizzamenti (origin.opaque === true) o fissa l'Origin opaco estratto tramite Origin.from(event) quando comunichi con un widget <iframe sandbox="allow-scripts"> specifico la cui event.origin serializzata è "null".

1. Verifica le origini esatte per le azioni con privilegi (Origin.from() e isSameOrigin())

Utilizza Origin.from() per estrarre un oggetto Origin e confrontarlo con un Origin attendibile utilizzando isSameOrigin():

  • Entrambi i lati devono essere istanze Origin: isSameOrigin(other) e isSameSite(other) accettano solo un oggetto Origin; il passaggio di una stringa non elaborata genera un TypeError.
  • Input Origin.from() supportati:
    • Stringhe URL serializzate (ad esempio, 'https://app.example.com:443/path'). Tieni presente che qualsiasi stringa URL non valida, ad esempio 'example' o la stringa 'null' serializzata da event.origin per le origini opache, fa sì che Origin.from() generi un TypeError.
    • Oggetti della piattaforma che definiscono i passaggi di estrazione dell'origine nella specifica HTML: Origin, URL, HTMLAnchorElement (<a>), HTMLAreaElement (<area>), Window o WorkerGlobalScope (Origin.from(self)) della stessa origine e istanze MessageEvent (Origin.from(event)) inviate dal browser.
    • Oggetti come Location, WorkerLocation, Document, Request e Response non definiscono i passaggi di estrazione dell'origine e generano un TypeError se passati direttamente a Origin.from(). Passa invece self (per il contesto corrente) o request.url/response.url.
  • Gestisci le istanze MessageEvent inviate dal browser e sintetiche: gli eventi postMessage del browser reale contengono un'origine interna estratta da Origin.from(event), mentre gli eventi new MessageEvent('message', { origin }) sintetici creati nei test di unità compilano solo la stringa event.origin e generano un errore su Origin.from(event). Il fallback a Origin.from(event.origin) all'interno di un try...catch gestisce entrambi in modo pulito, rifiutando comunque le stringhe non valide 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
});

Quando convalidi i target di reindirizzamento (come i parametri ?returnTo=) o i link interni tra i sottodomini dello stesso dominio registrabile (ad esempio https://app.example.co.uk e https://billing.example.co.uk:8443), utilizza isSameSite():

  • isSameSite() esegue un confronto schemeful same-site: https://sub.example.com e http://sub.example.com non sono same-site perché i loro schemi sono diversi.
  • isSameSite() utilizza l'elenco dei suffissi pubblici integrato nel browser, pertanto i tenant distinti su suffissi pubblici condivisi (come https://tenant-a.github.io e https://tenant-b.github.io o https://a.co.uk e https://b.co.uk) vengono valutati come 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. Distinguere e bloccare le origini opache (origin.opaque e Origin.from(event))

Gli iframe con sandbox (<iframe sandbox="allow-scripts">), gli URL data: e new Origin() producono origini opache (origin.opaque === true). Sebbene la loro serializzazione delle stringhe sia sempre "null", Origin.from(event) estrae l'origine opaca sottostante effettiva del mittente da un MessageEvent inviato dal browser:

  • Due messaggi inviati dallo stesso iframe con sandbox producono oggetti Origin che sono isSameOrigin() tra loro (true).
  • I messaggi inviati da iframe con sandbox diversi o chiamate Origin.from('data:...')/new Origin() separate generano origini opache distinte che valutano isSameOrigin() come false, impedendo lo spoofing di "null" === "null" in contesti opachi non correlati.
  • new Origin() crea un'origine opaca nuova e univoca che non corrisponde a nient'altro se non a se stessa, il che è utile come sentinella predefinita di negazione di tutto prima che un'origine peer prevista venga fissata una volta durante l'inizializzazione.
// 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;
  }
}

Best practice

  • SÌ: converti entrambi i lati di un confronto in istanze Origin tramite Origin.from() prima di chiamare isSameOrigin(other) o isSameSite(other).
  • DEVI passare un MessageEvent inviato dal browser direttamente a Origin.from(event) quando devi preservare l'identità dell'origine opaca e utilizzare Origin.from(self) (anziché Location o Document, che generano TypeError) per ottenere il Origin del contesto di esecuzione corrente.
  • SÌ: racchiudi le chiamate Origin.from() su input non attendibili o di runtime in un blocco try...catch in modo che URL non validi, stringhe "null" o oggetti non supportati restituiscano in sicurezza false anziché generare un TypeError non rilevato.
  • NON confrontare direttamente due stringhe di runtime event.origin o url.origin senza prima rifiutare "null" (origin !== 'null'), poiché due origini opache non correlate vengono entrambe serializzate in "null".
  • NON convalidare origini o siti con startsWith(), includes(), endsWith() o slicing ingenuo del nome host .split('.').

Strategia di riserva

Se il tuo target Baseline include browser che non supportano ancora l'interfaccia Origin, esegui il rilevamento delle funzionalità di 'Origin' in globalThis e utilizza il fallback per l'analisi degli URL con new URL():

  • Fallback della stessa origine (origini delle tuple): estrai la stringa dell'URL (utilizzando event.origin per gli oggetti MessageEvent o candidate.href per gli elementi link), analizza gli URL candidati e attendibili con new URL(), rifiuta le origini opache "null" (candidateUrl.origin !== 'null') e confronta candidateUrl.origin === trustedUrl.origin. Poiché un'origine opaca non è mai della stessa origine di un'origine tupla come 'https://app.example.com', il rifiuto immediato di 'null' corrisponde a Origin.isSameOrigin().
  • Fallback per lo stesso sito (avviso): l'interfaccia URL non espone l'elenco dei suffissi pubblici del browser. Nei browser senza Origin, esegui la corrispondenza del suffisso solo con un dominio registrabile privato esplicito e noto che controlli (verificando sia protocol sia una corrispondenza hostname esatta o con prefisso punto) oppure utilizza una libreria Public Suffix List gestita.
  • Fallback iframe opaco/sandbox: i browser senza Origin non espongono identità di origine opache distinte su MessageEvent (event.origin è sempre "null"). Se accetti intenzionalmente messaggi da un <iframe sandbox="allow-scripts"> con sandbox noto, verifica event.source === expectedWidgetWindow (o comunica tramite un MessagePort MessageChannel dedicato trasferito all'iframe durante l'inizializzazione) anziché confrontare 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;
  }
}