Webursprünge und Websites vergleichen und validieren

Webfunktionen: Ursprung

Der Vergleich von event.origin mit einem festen, portnormalisierten Stringliteral (z. B. event.origin === 'https://app.example.com') funktioniert zwar für einfache Zulassungslisten, aber manuelle Stringvergleiche und reguläre Ausdrücke versagen bei der Validierung beliebiger URLs, DOM-Link-Elemente, subdomainübergreifender Weiterleitungen oder Nachrichten in einer Sandbox:

  • Präfix- und Teilstring-Spoofing: Die Prüfung von url.startsWith('https://app.example.com') wird durch 'https://app.example.com.attacker.example' umgangen.
  • Nicht normalisierte URLs: Vollständige URLs mit Pfaden, Abfragestrings oder expliziten Standardports ('https://app.example.com:443/path') stimmen nicht mit 'https://app.example.com' überein, wenn sie nicht zuerst geparst und normalisiert werden.
  • Fehler bei der öffentlichen Suffixliste: Wenn Hostnamen bei . aufgeteilt werden, um Same-Site-Beziehungen zu prüfen, schlägt dies bei öffentlichen Suffixen mit mehreren Labels (z. B. .co.uk oder Domains mit gemeinsamem Hosting wie .github.io) fehl. Außerdem werden Abweichungen beim Schema (http: im Vergleich zu https:) ignoriert.
  • Kollisionen mit intransparenten "null"-Ursprüngen: iFrames in der Sandbox (<iframe sandbox="allow-scripts">) und data:-URLs serialisieren event.origin in den Literalstring "null". Wenn zwei serialisierte Laufzeitursprünge mit originA === originB verglichen werden, werden fälschlicherweise zwei nicht zusammenhängende intransparente Kontexte ("null" === "null") gleichgesetzt.

Die Origin API (Origin.from(), isSameOrigin(), isSameSite() und opaque) bietet strukturierte, spezifikationskonforme Vergleiche von Ursprüngen und schemabasierten Same-Site-Vergleichen, die vom URL-Parser des Browsers und der Public Suffix List unterstützt werden.

Den richtigen Vergleich für Ihr Szenario auswählen

  • isSameOrigin() für strenge Berechtigungsgrenzen verwenden: Erfordern Sie eine genaue (scheme, host, port)-Übereinstimmung, wenn Sie fensterübergreifende postMessage-Befehle verarbeiten (z. B. ein eingebettetes Bezahl- oder Authentifizierungs-Pop-up) oder entscheiden, ob Anmeldedaten angehängt werden sollen. Sogar eine andere Subdomain (https://blog.example.com im Vergleich zu https://app.example.com) ist ein anderer Ursprung und muss abgelehnt werden, damit eine Sicherheitslücke in einer Subdomain nicht eine andere steuern kann.
  • isSameSite() für die Navigation zwischen Subdomains und die Linkklassifizierung verwenden: Erlauben Sie jede Subdomain oder jeden Port, die dasselbe Schema und dieselbe registrierbare Domain verwenden, wenn Sie Weiterleitungs-URLs nach der Anmeldung (?returnTo=https://billing.example.co.uk/orders) validieren oder Links als intern oder extern klassifizieren. isSameSite() lehnt sowohl Downgrades des http:-Schemas als auch gleichrangige Mandanten auf gemeinsam genutzten öffentlichen Suffixen (.co.uk, .github.io) ab.
  • origin.opaque und Origin.from(event) für opake oder Sandbox-Kontexte verwenden: Lehne data:-URLs für Weiterleitungen (origin.opaque === true) ab oder pinne den opaken Origin, der über Origin.from(event) extrahiert wurde, wenn du mit einem bestimmten <iframe sandbox="allow-scripts">-Widget kommunizierst, dessen serialisierter event.origin "null" ist.

1. Genaue Ursprünge für privilegierte Aktionen prüfen (Origin.from() und isSameOrigin())

Verwenden Sie Origin.from(), um ein Origin-Objekt zu extrahieren und es mit isSameOrigin() mit einem vertrauenswürdigen Origin zu vergleichen:

  • Beide Seiten müssen Origin-Instanzen sein: isSameOrigin(other) und isSameSite(other) akzeptieren nur ein Origin-Objekt. Wenn Sie einen Rohstring übergeben, wird ein TypeError ausgegeben.
  • Unterstützte Origin.from()-Eingaben:
    • Serialisierte URL-Strings (z. B. 'https://app.example.com:443/path'). Beachten Sie, dass jeder ungültige URL-String, z. B. 'example' oder der von event.origin für nicht transparente Ursprünge serialisierte String 'null', dazu führt, dass Origin.from() einen TypeError auslöst.
    • Plattformobjekte, die Schritte zur Ursprungsextraktion in der HTML-Spezifikation definieren: Origin, URL, HTMLAnchorElement (<a>), HTMLAreaElement (<area>), Window oder WorkerGlobalScope (Origin.from(self)) mit demselben Ursprung und vom Browser gesendete MessageEvent-Instanzen (Origin.from(event)).
    • Objekte wie Location, WorkerLocation, Document, Request und Response definieren keine Schritte zur Ursprungsextraktion und lösen einen TypeError aus, wenn sie direkt an Origin.from() übergeben werden. Übergeben Sie stattdessen self (für den aktuellen Kontext) oder request.url / response.url.
  • Sowohl vom Browser gesendete als auch synthetische MessageEvent-Instanzen verarbeiten: Echte postMessage-Browserereignisse enthalten einen internen Ursprung, der von Origin.from(event) extrahiert wird. Synthetische new MessageEvent('message', { origin })-Ereignisse, die in Unit-Tests erstellt werden, füllen nur den event.origin-String und lösen einen Fehler bei Origin.from(event) aus. Wenn Sie innerhalb eines try...catch-Blocks auf Origin.from(event.origin) zurückgreifen, werden beide Fälle sauber verarbeitet, während ungültige oder "null"-Strings weiterhin abgelehnt werden.
// 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
});

Verwenden Sie isSameSite(), wenn Sie Weiterleitungsziele (z. B. ?returnTo=-Parameter) oder interne Links über Subdomains derselben registrierbaren Domain (z. B. https://app.example.co.uk und https://billing.example.co.uk:8443) validieren:

  • isSameSite() führt einen Schemeful-Same-Site-Vergleich durch: https://sub.example.com und http://sub.example.com sind nicht Same-Site, da sich ihre Schemata unterscheiden.
  • isSameSite() verwendet die im Browser integrierte öffentliche Suffixliste, sodass unterschiedliche Mandanten mit gemeinsamen öffentlichen Suffixen (z. B. https://tenant-a.github.io und https://tenant-b.github.io oder https://a.co.uk und https://b.co.uk) als false ausgewertet werden.
// 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. Nicht transparente Ursprünge unterscheiden und anpinnen (origin.opaque und Origin.from(event))

Sandboxed iFrames (<iframe sandbox="allow-scripts">), data:-URLs und new Origin() erzeugen intransparente Ursprünge (origin.opaque === true). Während ihre String-Serialisierung immer "null" ist, extrahiert Origin.from(event) den tatsächlichen zugrunde liegenden intransparenten Ursprung des Absenders aus einem vom Browser gesendeten MessageEvent:

  • Zwei Nachrichten, die vom selben iFrame in der Sandbox gesendet werden, ergeben Origin-Objekte, die miteinander isSameOrigin() sind (true).
  • Nachrichten, die von verschiedenen iFrames in einer Sandbox oder separaten Origin.from('data:...')- oder new Origin()-Aufrufen gesendet werden, führen zu unterschiedlichen opaken Ursprüngen, die isSameOrigin() zu false auswerten. Dadurch wird "null" === "null"-Spoofing in nicht zusammenhängenden opaken Kontexten verhindert.
  • new Origin() erstellt einen neuen, eindeutigen, intransparenten Ursprung, der nur mit sich selbst übereinstimmt. Das ist nützlich als Standard-Sentinel, der alles ablehnt, bevor ein erwarteter Peer-Ursprung einmal bei der Initialisierung angepinnt wird.
// 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 Practices

  • DO convert both sides of a comparison to Origin instances via Origin.from() before calling isSameOrigin(other) or isSameSite(other).
  • EMPFOHLEN: Übergeben Sie ein vom Browser gesendetes MessageEvent direkt an Origin.from(event), wenn Sie die Identität des opaken Ursprungs beibehalten müssen, und verwenden Sie Origin.from(self) (anstelle von Location oder Document, die TypeError auslösen), um das Origin des aktuellen Ausführungskontexts abzurufen.
  • DO wrap Origin.from() calls on untrusted or runtime inputs in a try...catch block so invalid URLs, "null" strings, or unsupported objects safely return false instead of throwing an uncaught TypeError.
  • Vergleichen Sie NICHT zwei Laufzeit-event.origin- oder url.origin-Strings direkt, ohne zuerst "null" (origin !== 'null') abzulehnen, da zwei nicht zusammenhängende opake Ursprünge beide zu "null" serialisiert werden.
  • NICHT Ursprünge oder Websites mit startsWith(), includes(), endsWith() oder einfachem .split('.')-Hostnamen-Slicing validieren.

Fallback-Strategie

Wenn Ihr Baseline-Ziel Browser umfasst, die die Origin-Schnittstelle noch nicht unterstützen, sollten Sie die Funktion 'Origin' in globalThis erkennen und auf das Parsen von URLs mit new URL() zurückgreifen:

  • Same-Origin-Fallback (Tupel-Ursprünge): Extrahieren Sie den URL-String (mit event.origin für MessageEvent-Objekte oder candidate.href für Link-Elemente), parsen Sie sowohl die Kandidaten- als auch die vertrauenswürdigen URLs mit new URL(), lehnen Sie "null" opake Ursprünge (candidateUrl.origin !== 'null') ab und vergleichen Sie candidateUrl.origin === trustedUrl.origin. Da ein opaker Ursprung nie denselben Ursprung wie ein Tupel-Ursprung wie 'https://app.example.com' hat, entspricht die sofortige Ablehnung von 'null' der Origin.isSameOrigin().
  • Same-Site-Fallback (Einschränkung): Die URL-Schnittstelle stellt die öffentliche Suffixliste des Browsers nicht bereit. In Browsern ohne Origin sollten Sie nur einen Suffixabgleich mit einer expliziten, bekannten privaten registrierbaren Domain durchführen, die Sie kontrollieren (wobei sowohl protocol als auch eine genaue oder mit einem Punkt vorangestellte hostname-Übereinstimmung überprüft werden). Alternativ können Sie eine gepflegte Public Suffix List-Bibliothek verwenden.
  • Undurchsichtiger/Sandbox-Iframe-Fallback: Browser ohne Origin stellen keine eindeutigen undurchsichtigen Ursprungsidentitäten für MessageEvent bereit (event.origin ist immer "null"). Wenn Sie absichtlich Nachrichten von einer bekannten Sandbox-<iframe sandbox="allow-scripts"> akzeptieren, prüfen Sie event.source === expectedWidgetWindow (oder kommunizieren Sie über einen dedizierten MessageChannel MessagePort, der bei der Initialisierung an den Iframe übertragen wird), anstatt event.origin zu vergleichen.
// 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;
  }
}