Comparer et valider les origines et les sites Web

Fonctionnalités Web : Origine

Bien que la comparaison de event.origin avec un littéral de chaîne fixe normalisé par port (tel que event.origin === 'https://app.example.com') fonctionne pour les listes d'autorisation simples, les comparaisons de chaînes manuelles et les expressions régulières ne fonctionnent pas lors de la validation d'URL arbitraires, d'éléments link DOM, de redirections entre sous-domaines ou de messages en bac à sable :

  • Spoofing de préfixe et de sous-chaîne : la vérification de url.startsWith('https://app.example.com') est contournée par 'https://app.example.com.attacker.example'.
  • URL non normalisées : les URL complètes avec des chemins d'accès, des chaînes de requête ou des ports par défaut explicites ('https://app.example.com:443/path') ne correspondent pas à 'https://app.example.com' en cas d'égalité de chaîne sans analyse ni normalisation préalables.
  • Erreurs de la liste des suffixes publics : la division des noms d'hôte sur . pour vérifier les relations SameSite échoue sur les suffixes publics à plusieurs libellés (comme .co.uk ou les domaines d'hébergement partagé comme .github.io) et ignore les incohérences de schéma (http: par rapport à https:).
  • Collisions d'origine "null" opaque : les iFrames en bac à sable (<iframe sandbox="allow-scripts">) et les URL data: sérialisent event.origin en chaîne littérale "null". La comparaison de deux origines d'exécution sérialisées avec originA === originB assimile à tort deux contextes opaques non liés ("null" === "null").

L'API Origin (Origin.from(), isSameOrigin(), isSameSite() et opaque) fournit des comparaisons d'origine et de même site structurées et conformes aux spécifications, basées sur l'analyseur d'URL du navigateur et la liste des suffixes publics.

Choisir la comparaison adaptée à votre scénario

  • Utilisez isSameOrigin() pour définir des limites de privilèges strictes : exigez une correspondance exacte de (scheme, host, port) lors du traitement des commandes postMessage entre fenêtres (comme un pop-up de paiement ou d'authentification intégré) ou pour décider d'associer ou non des identifiants. Même un sous-domaine différent (https://blog.example.com par rapport à https://app.example.com) est une origine différente et doit être rejeté afin qu'une faille sur un sous-domaine ne puisse pas en contrôler un autre.
  • Utilisez isSameSite() pour la navigation entre sous-domaines et la classification des liens : autorisez tout sous-domaine ou port partageant le même schéma et le même domaine enregistrable lors de la validation des URL de redirection après connexion (?returnTo=https://billing.example.co.uk/orders) ou de la classification des liens comme internes ou externes. isSameSite() refuse à la fois les rétrogradations de schéma http: et les locataires frères sur les suffixes publics partagés (.co.uk, .github.io).
  • Utilisez origin.opaque et Origin.from(event) pour les contextes opaques ou de bac à sable : refusez les URL data: pour les redirections (origin.opaque === true) ou épinglez l'Origin opaque extrait via Origin.from(event) lorsque vous communiquez avec un widget <iframe sandbox="allow-scripts"> spécifique dont l'event.origin sérialisé est "null".

1. Vérifier les origines exactes des actions privilégiées (Origin.from() et isSameOrigin())

Utilisez Origin.from() pour extraire un objet Origin et le comparer à un Origin de confiance à l'aide de isSameOrigin() :

  • Les deux côtés doivent être des instances Origin : isSameOrigin(other) et isSameSite(other) n'acceptent qu'un objet Origin. Transmettre une chaîne brute génère une TypeError.
  • Entrées Origin.from() compatibles :
    • Chaînes d'URL sérialisées (par exemple, 'https://app.example.com:443/path'). Notez que toute chaîne d'URL non valide (par exemple, 'example' ou la chaîne 'null' sérialisée par event.origin pour les origines opaques) entraîne l'affichage d'une TypeError par Origin.from().
    • Objets de plate-forme qui définissent les étapes d'extraction de l'origine dans la spécification HTML : Origin, URL, HTMLAnchorElement (<a>), HTMLAreaElement (<area>), Window ou WorkerGlobalScope de même origine (Origin.from(self)) et instances MessageEvent envoyées par le navigateur (Origin.from(event)).
    • Les objets tels que Location, WorkerLocation, Document, Request et Response ne définissent pas d'étapes d'extraction d'origine et génèrent une TypeError s'ils sont transmis directement à Origin.from(). Transmettez plutôt self (pour le contexte actuel) ou request.url/response.url.
  • Gérer les instances MessageEvent envoyées par le navigateur et synthétiques : les événements postMessage réels du navigateur comportent une origine interne extraite par Origin.from(event), tandis que les événements new MessageEvent('message', { origin }) synthétiques créés dans les tests unitaires ne renseignent que la chaîne event.origin et génèrent une exception sur Origin.from(event). Le recours à Origin.from(event.origin) dans un try...catch gère les deux de manière propre, tout en rejetant les chaînes non valides ou "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
});

Lorsque vous validez des cibles de redirection (comme les paramètres ?returnTo=) ou des liens internes entre des sous-domaines d'un même domaine enregistrable (par exemple, https://app.example.co.uk et https://billing.example.co.uk:8443), utilisez isSameSite() :

  • isSameSite() effectue une comparaison Schemeful Same-Site : https://sub.example.com et http://sub.example.com ne sont pas du même site, car leurs schémas diffèrent.
  • isSameSite() utilise la liste des suffixes publics intégrée au navigateur. Ainsi, les locataires distincts sur des suffixes publics partagés (tels que https://tenant-a.github.io et https://tenant-b.github.io, ou https://a.co.uk et https://b.co.uk) sont évalués à 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. Distinction et épinglage des origines opaques (origin.opaque et Origin.from(event))

Les iFrames en bac à sable (<iframe sandbox="allow-scripts">), les URL data: et new Origin() produisent des origines opaques (origin.opaque === true). Bien que leur sérialisation de chaîne soit toujours "null", Origin.from(event) extrait l'origine opaque sous-jacente réelle de l'expéditeur à partir d'un MessageEvent envoyé par le navigateur :

  • Deux messages envoyés depuis la même iframe en bac à sable génèrent des objets Origin qui sont isSameOrigin() l'un avec l'autre (true).
  • Les messages envoyés à partir d'iframes en bac à sable différentes (ou d'appels Origin.from('data:...')/new Origin() distincts) génèrent des origines opaques distinctes qui évaluent isSameOrigin() à false, ce qui empêche l'usurpation de "null" === "null" dans des contextes opaques non liés.
  • new Origin() crée une origine opaque unique qui ne correspond à rien d'autre qu'elle-même. Cela est utile en tant que sentinelle par défaut de refus de tout avant qu'une origine de pair attendue ne soit épinglée une fois lors de l'initialisation.
// 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;
  }
}

Bonnes pratiques

  • À FAIRE : Convertissez les deux côtés d'une comparaison en instances Origin via Origin.from() avant d'appeler isSameOrigin(other) ou isSameSite(other).
  • À FAIRE : transmettez un MessageEvent envoyé par le navigateur directement à Origin.from(event) lorsque vous devez préserver l'identité d'origine opaque, et utilisez Origin.from(self) (plutôt que Location ou Document, qui génèrent TypeError) pour obtenir le Origin du contexte d'exécution actuel.
  • À FAIRE : encapsulez les appels Origin.from() sur des entrées non fiables ou d'exécution dans un bloc try...catch afin que les URL non valides, les chaînes "null" ou les objets non compatibles renvoient false de manière sécurisée au lieu de générer une TypeError non interceptée.
  • NE COMPAREZ PAS directement deux chaînes d'exécution event.origin ou url.origin sans d'abord rejeter "null" (origin !== 'null'), car deux origines opaques non liées sont toutes deux sérialisées en "null".
  • NE validez PAS les origines ou les sites avec startsWith(), includes(), endsWith() ou un découpage naïf du nom d'hôte .split('.').

Stratégie de remplacement

Si votre cible Baseline inclut des navigateurs qui ne sont pas encore compatibles avec l'interface Origin, détectez la fonctionnalité 'Origin' in globalThis et revenez à l'analyse des URL avec new URL() :

  • Solution de secours de même origine (origines de tuple) : extrayez la chaîne d'URL (à l'aide de event.origin pour les objets MessageEvent ou de candidate.href pour les éléments link), analysez les URL candidates et de confiance avec new URL(), rejetez les origines opaques "null" (candidateUrl.origin !== 'null') et comparez candidateUrl.origin === trustedUrl.origin. Étant donné qu'une origine opaque n'est jamais de même origine qu'une origine de tuple comme 'https://app.example.com', le rejet immédiat de 'null' correspond à Origin.isSameOrigin().
  • Solution de secours SameSite (mise en garde) : l'interface URL n'expose pas la liste de suffixes publics du navigateur. Dans les navigateurs sans Origin, n'effectuez la mise en correspondance des suffixes qu'avec un domaine enregistrable privé explicite et connu que vous contrôlez (en vérifiant à la fois protocol et une correspondance exacte ou précédée d'un point hostname), ou utilisez une bibliothèque de liste de suffixes publics gérée.
  • Solution de secours pour les iFrames opaques/dans un bac à sable : les navigateurs sans Origin n'exposent pas d'identités d'origine opaques distinctes sur MessageEvent (event.origin est toujours "null"). Si vous acceptez intentionnellement les messages d'un <iframe sandbox="allow-scripts"> connu dans un bac à sable, vérifiez event.source === expectedWidgetWindow (ou communiquez via un MessageChannel MessagePort dédié transféré à l'iFrame lors de l'initialisation) plutôt que de comparer 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;
  }
}