Porównywanie i weryfikowanie originów stron i witryn

Funkcje internetowe: Origin

Porównywanie event.origin ze stałym, znormalizowanym pod kątem portu ciągiem znaków (np. event.origin === 'https://app.example.com') działa w przypadku prostych list dozwolonych, ale ręczne porównywanie ciągów znaków i wyrażenia regularne nie sprawdzają się podczas sprawdzania dowolnych adresów URL, elementów linków DOM, przekierowań między subdomenami czy wiadomości w piaskownicy:

  • Podszywanie się pod prefiks i podłańcuch: sprawdzanie url.startsWith('https://app.example.com') jest pomijane przez 'https://app.example.com.attacker.example'.
  • Nieznormalizowane adresy URL: pełne adresy URL ze ścieżkami, ciągami zapytania lub jawnymi portami domyślnymi ('https://app.example.com:443/path') nie pasują do 'https://app.example.com' w przypadku równości ciągów znaków bez wcześniejszego przeanalizowania i znormalizowania.
  • Błędy na liście domen publicznych: dzielenie nazw hostów na . w celu sprawdzenia relacji w tej samej witrynie nie działa w przypadku domen publicznych z wieloma etykietami (takich jak .co.uk lub współdzielone domeny hostingowe, np. .github.io) i ignoruje niezgodności schematów (http: i https:).
  • Kolizje nieprzezroczystych źródeł "null": elementy iframe w piaskownicy (<iframe sandbox="allow-scripts">) i adresy URL data: serializują event.origin do ciągu tekstowego "null". Porównanie 2 zserializowanych źródeł środowiska wykonawczego za pomocą originA === originB błędnie zrównuje 2 niepowiązane nieprzezroczyste konteksty ("null" === "null").

Interfejs API Origin (Origin.from(), isSameOrigin(), isSameSite() i opaque) zapewnia ustrukturyzowane, zgodne ze specyfikacją porównania pochodzenia i schematu tej samej witryny, które są obsługiwane przez parser adresów URL przeglądarki i listę sufiksów publicznych.

Wybór odpowiedniego porównania dla danego scenariusza

  • Używaj isSameOrigin() do określania ścisłych granic uprawnień: wymagaj dokładnego dopasowania (scheme, host, port) podczas obsługi poleceń postMessage między oknami (takich jak osadzona płatność lub wyskakujące okienko uwierzytelniania) lub decydowania, czy dołączyć dane logowania. Nawet inna subdomena (https://blog.example.com i https://app.example.com) jest innym źródłem i musi zostać odrzucona, aby luka w zabezpieczeniach jednej subdomeny nie mogła kontrolować innej.
  • Używaj isSameSite() do nawigacji między subdomenami i klasyfikacji linków: zezwalaj na dowolną subdomenę lub port, które mają ten sam schemat i rejestrowalną domenę, podczas sprawdzania adresów URL przekierowań po zalogowaniu (?returnTo=https://billing.example.co.uk/orders) lub klasyfikowania linków jako wewnętrznych lub zewnętrznych. isSameSite() odrzuca zarówno obniżenie schematu http:, jak i dzierżawy równorzędne we wspólnych sufiksach publicznych (.co.uk, .github.io).
  • Używaj origin.opaque i Origin.from(event) w przypadku nieprzezroczystych lub izolowanych kontekstów: odrzucaj adresy URL data: w przypadku przekierowań (origin.opaque === true) lub przypinaj nieprzezroczyste Origin wyodrębnione za pomocą Origin.from(event) podczas komunikacji z określonym widżetem <iframe sandbox="allow-scripts">, którego zserializowany event.origin to "null".

1. Weryfikuj dokładne źródła działań uprzywilejowanych (Origin.from() i isSameOrigin())

Użyj Origin.from(), aby wyodrębnić obiekt Origin i porównać go z zaufanym obiektem Origin za pomocą isSameOrigin():

  • Obie strony muszą być instancjami Origin: isSameOrigin(other) i isSameSite(other) akceptują tylko obiekt Origin – przekazanie nieprzetworzonego ciągu znaków powoduje błąd TypeError.
  • Obsługiwane dane wejściowe Origin.from():
    • Zserializowane ciągi adresów URL (np. 'https://app.example.com:443/path'). Pamiętaj, że każdy nieprawidłowy ciąg adresu URL – np. 'example' lub ciąg 'null' zserializowany przez event.origin w przypadku nieprzezroczystych źródeł – powoduje, że Origin.from() zgłasza błąd TypeError.
    • Obiekty platformy, które definiują kroki wyodrębniania źródła w specyfikacji HTML: Origin, URL, HTMLAnchorElement (<a>), HTMLAreaElement (<area>), instancje Window lub WorkerGlobalScope (Origin.from(self)) z tego samego źródła oraz instancje MessageEvent wysyłane przez przeglądarkę (Origin.from(event)).
    • Obiekty takie jak Location, WorkerLocation, Document, Request i Response nie definiują kroków wyodrębniania źródła i zwracają błąd TypeError, jeśli zostaną przekazane bezpośrednio do Origin.from(). Zamiast tego przekaż self (w przypadku bieżącego kontekstu) lub request.url / response.url.
  • Obsługuj zarówno instancje MessageEvent wysyłane przez przeglądarkę, jak i instancje syntetyczne: prawdziwe zdarzenia postMessage przeglądarki zawierają wewnętrzne źródło wyodrębnione przez Origin.from(event), podczas gdy syntetyczne zdarzenia new MessageEvent('message', { origin }) utworzone w testach jednostkowych wypełniają tylko ciąg event.origin i wywołują wyjątek w Origin.from(event). Zastosowanie Origin.from(event.origin) w ramach try...catch pozwala obsłużyć oba te przypadki, a jednocześnie odrzucać nieprawidłowe ciągi znaków lub ciągi znaków "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
});

Podczas sprawdzania poprawności celów przekierowania (takich jak parametry ?returnTo=) lub linków wewnętrznych w subdomenach tej samej domeny, którą można zarejestrować (np. https://app.example.co.uk i https://billing.example.co.uk:8443), używaj isSameSite():

  • isSameSite() przeprowadza porównanie schemeful same-site: https://sub.example.com i http://sub.example.com nie są tymi samymi witrynami, ponieważ ich schematy się różnią.
  • isSameSite() używa wbudowanej w przeglądarkę listy domen publicznych, więc różne dzierżawy we wspólnych domenach publicznych (takich jak https://tenant-a.github.io i https://tenant-b.github.io lub https://a.co.uk i https://b.co.uk) są oceniane jako 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. Rozróżnianie i przypinanie nieprzezroczystych źródeł (origin.opaque i Origin.from(event))

Elementy iframe w trybie piaskownicy (<iframe sandbox="allow-scripts">), adresy URL data: i new Origin() generują nieprzezroczyste źródła (origin.opaque === true). Ich serializacja ciągów znaków zawsze wynosi "null", ale Origin.from(event) wyodrębnia rzeczywiste nieprzezroczyste źródło nadawcy z wysłanego przez przeglądarkę MessageEvent:

  • Dwie wiadomości wysłane z tego samego elementu iframe w piaskownicy dają obiekty Origin, które są isSameOrigin() (true).
  • Wiadomości wysyłane z różnych elementów iframe w trybie piaskownicy lub oddzielne wywołania Origin.from('data:...') / new Origin() dają różne nieprzezroczyste źródła, które są oceniane jako isSameOrigin() do false, co zapobiega podszywaniu się pod "null" === "null" w niepowiązanych nieprzezroczystych kontekstach.
  • new Origin() tworzy nowe, unikalne, nieprzezroczyste źródło, które nie pasuje do niczego poza sobą. Jest to przydatne jako domyślny strażnik odrzucający wszystko, zanim oczekiwane źródło równorzędne zostanie przypięte raz podczas inicjowania.
// 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;
  }
}

Sprawdzone metody

  • ZALECENIE: przed wywołaniem isSameOrigin(other) lub isSameSite(other) przekonwertuj obie strony porównania na instancje Origin za pomocą Origin.from().
  • PRZEKAZUJ obiekt MessageEvent wysłany przez przeglądarkę bezpośrednio do Origin.from(event), gdy musisz zachować nieprzezroczystą tożsamość źródła, i używaj Origin.from(self) (a nie Location ani Document, które wywołują TypeError), aby uzyskać Origin bieżącego kontekstu wykonywania.
  • ZALECENIE: wywołania Origin.from() dotyczące niezaufanych lub środowiskowych danych wejściowych umieszczaj w bloku try...catch, aby nieprawidłowe adresy URL, ciągi znaków "null" lub nieobsługiwane obiekty bezpiecznie zwracały false zamiast nieprzechwyconego błędu TypeError.
  • NIE porównuj bezpośrednio 2 ciągów znaków event.origin lub url.origin środowiska wykonawczego bez wcześniejszego odrzucenia "null" (origin !== 'null'), ponieważ 2 niepowiązane nieprzezroczyste źródła są serializowane do "null".
  • NIE weryfikuj źródeł ani witryn za pomocą startsWith(), includes(), endsWith() ani naiwnego dzielenia nazwy hosta .split('.').

Strategia zastępcza

Jeśli Twoja grupa docelowa korzysta z przeglądarek, które nie obsługują jeszcze interfejsu Origin, wykryj funkcję 'Origin' in globalThis i wróć do analizowania adresów URL za pomocą new URL():

  • Zastępcze źródło z tej samej domeny (źródła krotek): wyodrębnij ciąg adresu URL (za pomocą event.origin w przypadku obiektów MessageEvent lub candidate.href w przypadku elementów linku), przeanalizuj adresy URL kandydatów i zaufane adresy URL za pomocą new URL(), odrzuć nieprzezroczyste źródła "null" (candidateUrl.origin !== 'null') i porównaj candidateUrl.origin === trustedUrl.origin. Ponieważ nieprzezroczyste źródło nigdy nie jest tym samym źródłem co źródło krotki, takie jak 'https://app.example.com', natychmiastowe odrzucenie 'null' pasuje do Origin.isSameOrigin().
  • Zastępcza domena SameSite (zastrzeżenie): interfejs URL nie udostępnia listy sufiksów publicznych przeglądarki. W przeglądarkach bez Origin dopasowuj sufiksy tylko do jawnej, znanej, prywatnej domeny możliwej do zarejestrowania, którą kontrolujesz (weryfikując zarówno protocol, jak i dokładne dopasowanie lub dopasowanie z prefiksem kropki hostname), lub używaj utrzymywanej biblioteki Public Suffix List.
  • Zastępcza kreacja zastępcza nieprzezroczystego elementu iframe / elementu iframe w piaskownicy: przeglądarki bez Origin nie ujawniają odrębnych nieprzezroczystych tożsamości pochodzenia w MessageEvent (event.origin to zawsze "null"). Jeśli celowo akceptujesz wiadomości ze znanego elementu <iframe sandbox="allow-scripts"> w piaskownicy, zweryfikuj event.source === expectedWidgetWindow (lub komunikuj się przez dedykowany element MessageChannel MessagePort przeniesiony do elementu iframe podczas inicjowania), zamiast porównywać 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;
  }
}