# Audit d'échappement des blocs Scormia — 2026-08-01

**Contexte.** Le rendu d'un bloc Scormia est produit côté client par la bibliothèque
partagée `assets/scormia/blocks/`, puis injecté via `innerHTML`
(`assets/controllers/scormia/lib/canvas.js:205` pour l'éditeur,
`assets/scormia/player/player.js` pour le player). Jusqu'ici cette surface n'était
vue que par l'auteur du module ; avec le partage par lien, elle est servie à
n'importe quel destinataire — y compris un utilisateur connecté, sur l'origine de
l'application. Un champ non échappé n'est donc plus un auto-dégât : c'est une
exécution de JavaScript tiers dans la session d'un visiteur.

**Périmètre.** Les 25 types de blocs enregistrés dans
`assets/scormia/blocks/elements/index.js`, champ par champ, pour tout ce qui est
interpolé dans du HTML par `render()`. Les chemins d'édition (`inlineEdit`,
`toolbarExtra`) ne sont pas la surface publique — le player ne les exécute jamais —
mais ceux qui construisent du DOM sont notés quand ils écartent un risque.

**Verdicts employés.**

- `échappé` — la valeur passe par `escapeHtml` (contenu textuel) ou `escapeAttr`
  (valeur d'attribut), tous deux définis dans `assets/scormia/blocks/render.js:77-83`.
- `validé` — la valeur ne peut pas être arbitraire : elle est bornée, whitelistée
  ou dérivée d'un index, avant même toute question d'échappement.
- `HTML riche assumé` — interpolé volontairement sans échappement, la sûreté venant
  de l'assainissement serveur.
- `non échappé` — défaut.

---

## 1. Inventaire — un champ interpolé par ligne

Tous les chemins sont relatifs à `assets/scormia/blocks/elements/`, sauf mention.

| Bloc | Champ de `content` | Fichier:ligne | Contexte | Verdict |
|---|---|---|---|---|
| *(enveloppe)* | `block.type` | `../render.js:25` | valeur de classe CSS | échappé (`escapeAttr`) |
| *(enveloppe)* | `block.style.*` | `../render.js:35-52` | valeur d'attribut `style` | validé (px clampés 0-96, `align` whitelisté, `background` motif hex strict) puis échappé |
| heading | `text` | `heading.js:20` | contenu textuel | échappé |
| heading | `variant` | `heading.js:19` | choix d'un gabarit constant | validé (entier 0-3) |
| subtitle | `text` | `subtitle.js:23` | contenu textuel | échappé |
| subtitle | `variant` | `subtitle.js:20-23` | suffixe de classe pris dans un tableau constant | validé (entier 0-2) |
| **richtext** | **`html`** | **`richtext.js:29`** | **contenu HTML** | **HTML riche assumé** |
| richtext | `variant` | `richtext.js:28` | suffixe de classe constant | validé |
| divider | `variant` | `divider.js:15-22` | suffixe de classe constant | validé (entier 0-2) |
| spacer | `px` | `spacer.js:32` | valeur d'attribut `style` + `data-label` | validé (clampé 4-240, entier) |
| **callout** | **`html`** | **`callout.js:43`** | **contenu HTML** | **HTML riche assumé** |
| callout | `variant` | `callout.js:38-41` | choix dans `VARIANTS` (constantes : glyphe, titre, classe) | validé (entier 0-4) |
| **quote** | **`html`** | **`quote.js:45`** | **contenu HTML** | **HTML riche assumé** |
| quote | `author` | `quote.js:41, 43` | contenu textuel (initiales + nom) | échappé |
| quote | `variant` | `quote.js:38-45` | suffixe de classe constant | validé |
| list | `items[]` | `list.js:26` | contenu textuel de `<li>` | échappé |
| list | `variant` | `list.js:23-27` | balise (`ul`/`ol`) + classe, tableau constant | validé |
| image | `url` | `image.js:25` | valeur d'attribut `src` d'un `<img>` | échappé (`escapeAttr`) |
| image | `caption` | `image.js:25` | valeur d'attribut `alt` | échappé (`escapeAttr`) |
| image | `caption` | `image.js:26` | contenu textuel de `<figcaption>` | échappé |
| image | `variant` | `image.js:21` | choix d'un gabarit constant | validé (entier 0-2) |
| video | `url` | `video.js:25` | valeur d'attribut `src` d'un `<video>` | échappé (`escapeAttr`) |
| video | `url` | `video.js:29` | valeur d'attribut `src` d'une `<iframe>` | validé (`toEmbedUrl`, `../embed.js` : https strict + 4 fournisseurs, sinon `null` → placeholder) puis échappé |
| video | `url` | `video.js:31` | titre dérivé (nom de fichier ou fournisseur) | échappé |
| video | `mode` | `video.js:24` | aiguillage `file` / `embed` | validé (comparaison stricte) |
| audio | `url` | `audio.js:29` | valeur d'attribut `src` d'un `<audio>` | échappé (`escapeAttr`) |
| audio | `caption` | `audio.js:30` | contenu textuel de `<figcaption>` | échappé |
| embed | `url` | `embed.js:53, 54, 63` | attributs `href`, `title`, `src` d'`<iframe>` | validé (`isEmbeddableUrl` : https strict ou http localhost) puis échappé |
| embed | `url` | `embed.js:50` | `src` de la vignette | validé (URL reconstruite par `thumbUrl`, identifiant `encodeURIComponent`) puis échappé |
| embed | `url` | `embed.js:51, 58, 59` | glyphe, titre, URL affichée | échappé |
| embed | `height` | `embed.js:61, 63` | valeur d'attribut `style` | validé (clé dans `HEIGHTS`, repli `medium`) |
| code | `text` | `code.js:39` | contenu de `<code>` | échappé (highlight.js échappe le source ; repli explicite `escapeCode`, `../hljs.js:111-126`) |
| code | `lang` | `code.js:33, 37` | libellé textuel + choix de grammaire | validé (`normalizeLang`, whitelist de 32 ids) puis échappé |
| button | `text` | `button.js:60` | contenu textuel du libellé | échappé |
| button | `url` | `button.js:59, 66` | valeur d'attribut `href` | validé (`/^https?:\/\//i`, sinon `<span>` inerte) puis échappé |
| button | `variant` | `button.js:57-58` | suffixe de classe constant | validé |
| stats | `items[].value` | `stats.js:25` | contenu textuel | échappé |
| stats | `items[].label` | `stats.js:26` | contenu textuel | échappé |
| stats | `cols` | `stats.js:28` | valeur d'attribut `style` (`repeat(n, 1fr)`) | validé (`clampCols`, entier 1-4) |
| table | `rows[0][]` (en-tête) | `table.js:45` | contenu textuel de `<th>` | échappé |
| table | `rows[1..][]` | `table.js:51` | contenu textuel de `<td>` | échappé |
| **timeline** | **`items[].html`** | **`timeline.js:135`** | **contenu HTML** | **HTML riche assumé** |
| timeline | `items[].label` | `timeline.js:133` | contenu textuel | échappé |
| timeline | `items[].title` | `timeline.js:134` | contenu textuel | échappé |
| timeline | `variant` | `timeline.js:27-31` | suffixe de classe constant | validé |
| **accordion** | **`items[].html`** | **`accordion.js:42`** | **contenu HTML** | **HTML riche assumé** |
| accordion | `items[].title` | `accordion.js:41` | contenu textuel | échappé |
| accordion | `variant` | `accordion.js:37-44` | suffixe de classe constant | validé |
| flashcards | `cards[].front` | `flashcards.js:35` | contenu textuel | échappé |
| flashcards | `cards[].back` | `flashcards.js:36` | contenu textuel | échappé |
| flashcards | `variant` | `flashcards.js:49` | suffixe de classe constant | validé |
| flashcards | `backColor` | — | plus lu depuis P4 (couleur pilotée par le thème) | sans objet |
| hotspot | `url` | `hotspot.js:51` | valeur d'attribut `src` | échappé (`escapeAttr`) |
| hotspot | `alt` | `hotspot.js:51` | valeur d'attribut `alt` | échappé (`escapeAttr`) |
| hotspot | `spots[].x` / `.y` | `hotspot.js:47` | valeur d'attribut `style` (`left`/`top` en %) | validé (`pos()` : nombre fini clampé 0-100, sinon 50) |
| hotspot | `spots[].title` | `hotspot.js:48` | valeur d'attribut `aria-label` | échappé (`escapeAttr`) |
| hotspot | `spots[].text` | `hotspot.js:49` | valeur d'attribut `data-sc-text` | échappé (`escapeAttr`) |
| hotspot | `spots[].title` / `.text` | `hotspot.js:58-59` | contenu textuel des cartes (variante 1) | échappé |
| hotspot | `spots[].title` / `.text` | `hotspot.js:295-301` | bulle du player | validé par construction (`textContent`, jamais `innerHTML`) |
| dragdrop | `instruction` | `dragdrop.js:51` | contenu textuel | échappé |
| dragdrop | `groups[].name` | `dragdrop.js:48` | contenu textuel | échappé |
| dragdrop | `groups[].items[]` | `dragdrop.js:45` | contenu textuel des étiquettes | échappé |
| dragdrop | *(index de groupe)* | `dragdrop.js:43, 47` | attributs `data-sc-group` / `data-sc-zone` | validé (index de boucle, jamais une donnée) |
| match | `pairs[].term` | `match.js:34` | contenu textuel | échappé |
| match | `pairs[].def` | `match.js:36` | contenu textuel | échappé |
| match | *(index de paire)* | `match.js:34, 36` | attribut `data-sc-pair` | validé (index de boucle) |
| fill | `text` (hors trous) | `fill.js:54` | contenu textuel | échappé |
| fill | `text` (dans les trous) | `fill.js:55-58` | attribut `size` d'un `<input>` | validé (longueur clampée 4-24) ; le mot attendu n'entre JAMAIS dans le DOM |
| order | `steps[]` | `order.js:48` | contenu textuel | échappé |
| order | *(rang)* | `order.js:29, 46-47` | attributs `style` et `data-sc-step` | validé (dérivé de l'index de boucle) |
| quiz | `questions[].q` | `quiz.js:56` | contenu textuel de `<legend>` | échappé |
| quiz | `questions[].opts[].text` | `quiz.js:53` | contenu textuel de l'option | échappé |
| quiz | `questions[].opts[].text` | `quiz.js:382-387, 528` | récapitulatif et feedback | validé par construction (`textContent`) |
| quiz | `questions[].explanation` | `quiz.js:390-394` | explication du récapitulatif | validé par construction (`textContent`) |
| quiz | `questions[].type` | `quiz.js:51-52` | type d'`<input>` (`checkbox`/`radio`) | validé (comparaison stricte) |
| quiz | *(clé de bloc, index)* | `quiz.js:52, 55` | attributs `name`, `value`, `data-sc-q` | validé (`s{i}b{j}` généré + index de boucle) |

**Décompte** : 25 types de blocs audités, 76 lignes (champ × point d'interpolation).
5 champs relèvent du HTML riche assumé — `richtext.html`, `callout.html`,
`quote.html`, `accordion.items[].html`, `timeline.items[].html`. **Aucune
interpolation `non échappé` par inadvertance n'a été trouvée dans les renderers.**

### Les puits `innerHTML` HORS de `elements/`

Le périmètre « 25 types de `elements/index.js` » ci-dessus est exhaustif pour les
renderers, mais ce ne sont pas les seuls endroits où du HTML est injecté. Complément
d'inventaire (`grep -rn "innerHTML" assets/scormia/ assets/controllers/scormia/`) :

| Fichier:ligne | Ce qui est injecté | Actif sur la page publique ? | Verdict |
|---|---|---|---|
| `assets/scormia/player/player.js:256` | `renderScreen()` — sortie de la bibliothèque auditée ci-dessus | **oui** | couvert par l'inventaire |
| `assets/controllers/scormia/lib/canvas.js:205` | idem, côté éditeur | non (éditeur) | couvert |
| `assets/controllers/scormia/preview_controller.js:36` | `renderScreen()` pour la vignette d'un écran | non (bibliothèque authentifiée) | couvert — même sortie, même garanties |
| **`assets/scormia/blocks/math.js:28`** | **HTML produit par `katex.renderToString()`** | **oui** (`player.js:257`) | **voir ci-dessous** |
| `assets/scormia/blocks/media_common.js:105,113` | `TRASH_SVG`, `PLUS_SVG` | mobilier d'éditeur | validé (constantes du module, jamais de donnée) |
| `elements/*.js` (quiz:150,621,633,726 ; stats:44 ; button:183,287 ; table:194 ; dragdrop:248 ; flashcards:100 ; accordion:97) | pictogrammes SVG constants, ou `''` de vidage, ou re-rendu par `render()` | mobilier d'éditeur | validé |
| `assets/scormia/blocks/interactions.js` | *(aucun `innerHTML`)* | oui | sans objet — dispatch d'`init()` par classe de bloc |

**`math.js` mérite sa propre ligne** : `renderMath()` ne balaie pas un champ mais
**tous les nœuds texte de l'écran rendu**, donc du contenu venant de n'importe quel
type de bloc — y compris ceux dont l'inventaire ci-dessus garantit l'échappement,
puisque KaTeX travaille après coup, sur le texte affiché. Le texte hors formule est
ré-échappé par `math.js` lui-même (`escapeHtml`, ligne 84) ; le contenu **entre**
`\( \)` est en revanche remis à KaTeX, dont la sortie HTML est réinjectée telle
quelle.

| Bloc | Champ | Fichier:ligne | Contexte | Verdict |
|---|---|---|---|---|
| *(tous)* | tout texte affiché contenant `\( … \)`, `\[ … \]` ou `$$ … $$` | `../math.js:28` (via `player.js:257`) | HTML produit par KaTeX, réinjecté par `innerHTML` | validé — sous condition de `trust: false` |

Ce n'est **pas exploitable** : `trust: false` désactive `\href`, `\url`,
`\includegraphics` et les commandes `\html*` — les seules de KaTeX capables d'émettre
une URL ou des attributs arbitraires. Mais c'était, jusqu'à ce correctif, une
**dépendance de sécurité non écrite à une valeur par défaut d'une bibliothèque
tierce** : rien dans le dépôt ne figeait `trust: false`, et une future option ajoutée
à cet appel (ou une évolution du défaut de KaTeX) l'aurait perdue en silence, sur la
page publique. L'option est désormais **passée explicitement**, avec le commentaire
qui dit pourquoi (`math.js`).

### Vérifications transversales

- **`escapeAttr` n'échappe pas l'apostrophe** (`render.js:81-83`). Ce n'est sûr que
  si tout attribut interpolé est entouré de guillemets doubles. Vérifié :
  `grep -rnE "=[[:space:]]*'\\$\{" assets/scormia/blocks/` ne renvoie **rien** —
  aucun attribut à guillemets simples dans la bibliothèque. Le contrat tient.
- **Aucune interpolation d'URL n'échappe à une validation** : `escapeAttr` seul
  suffit pour `<img src>`, `<video src>`, `<audio src>` (aucun schéma exécutable
  n'y est interprété) ; les cas où un schéma serait exécutable (`<a href>`,
  `<iframe src>`) sont tous précédés d'une whitelist de schéma ou de fournisseur.
- **Le corrigé n'est jamais dans le DOM** pour `fill` et `quiz` : il est relu du
  contenu via `context.getBlockContent(blockKey)` au moment de la correction.

---

## 2. Défaut trouvé — l'assainissement des 5 champs riches

Les 5 champs `html` ne sont pas un défaut en soi : les échapper afficherait le
balisage en toutes lettres. Leur sûreté repose entièrement sur
`src/Service/Scormia/Block/HtmlCleaner.php`, invoqué par les `normalize()` des cinq
types PHP correspondants, eux-mêmes appelés par
`ScormiaPersistenceService::moduleToJson()` — **à l'ÉMISSION du JSON** (ligne 118),
et non seulement à la sauvegarde. C'est le point de passage unique de l'éditeur
authentifié, de la page publique de partage et du ZIP SCORM exporté : un contenu
antérieur en base est donc rattrapé à la lecture.

Les trois sources qui alimentent ces champs passent toutes par ce goulot :
l'éditeur (`ScormiaEditorController:88`), la génération IA
(`GenerateScormiaModuleMessageHandler:118`) et — c'est le cas le plus intéressant —
l'**import d'un paquet SCORM** (`ScormiaPackageImporter:70`), dont le contenu est
par nature rédigé par un tiers. Toutes trois via `applyJsonToModule()`, qui
normalise à l'enregistrement ; `moduleToJson()` renormalise à la lecture.

Or `HtmlCleaner` fonctionnait par **listes noires en expressions régulières** :
il retirait `<script>…</script>`, ` on*=…` et `href="javascript:…"`. Une liste
noire ne voit que ce qu'elle nomme, là où le parseur du navigateur, lui, est
permissif. Charges utiles vérifiées **contre la classe réelle** (et non une copie
reconstruite) :

| Charge utile | Sortie de l'ancien nettoyeur | Conséquence |
|---|---|---|
| `<img src=x onerror=alert(1)>` *(référence du chantier)* | `<img src=x>` | arrêtée |
| `<img/onerror=alert(1) src=x>` | **inchangée** | **XSS** — la barre oblique sépare les attributs pour le parseur, mais le motif exigeait une espace avant `on` |
| `<img src="x"onerror=alert(1)>` | **inchangée** | **XSS** — un guillemet fermant suffit à enchaîner sur l'attribut suivant |
| `<a href="javascript&#58;alert(1)">clic</a>` | **inchangée** | **XSS au clic** — le motif cherchait la chaîne littérale `javascript:`, que le parseur reconstitue depuis l'entité |
| `<script>alert(1)` *(non refermé)* | **inchangée** | inerte via `innerHTML` (un `<script>` inséré ainsi ne s'exécute pas), mais le code s'affichait tel quel |
| `<iframe srcdoc="<script>alert(1)</script>">` | `alert(1)</script>">` | pas d'exécution, mais sortie mutilée : du code affiché comme du texte |
| `<a href="jAvAsCrIpT:alert(1)">` | `<a>clic</a>` | arrêtée (le motif est insensible à la casse) |

Les trois lignes marquées **XSS** sont exploitables par toute personne recevant un
lien de partage : le contenu piégé arrive dans le JSON de la page publique, puis
dans `innerHTML`.

> Note de méthode : une première mesure de ces charges utiles, faite sur une copie
> du motif reconstruite dans un shell, avait faussement classé `jAvAsCrIpT:` comme
> franchissant. Le tableau ci-dessus provient d'une exécution de la classe réelle.

### Correctif

`HtmlCleaner` est réécrit en **liste blanche appliquée sur l'arbre DOM**
(`DOMDocument`) : on ne garde que les balises connues, et pour chacune que les
attributs connus ; les `href` sont validés **après** analyse, donc sur la valeur
déjà décodée par le parseur — ce qui neutralise l'encodage en entités.

- Balises conservées : structure de texte (`p`, `div`, `span`, `br`, `hr`,
  `blockquote`, `h1`-`h6`), mise en forme en ligne (`strong`, `b`, `em`, `i`, `u`,
  `s`, `strike`, `del`, `ins`, `mark`, `small`, `sub`, `sup`, `code`, `pre`, `kbd`,
  `abbr`), listes (`ul`, `ol`, `li`, `dl`, `dt`, `dd`), liens (`a`), tableaux
  (`table`, `thead`, `tbody`, `tfoot`, `tr`, `th`, `td`, `caption`).
- Attributs conservés : `href` et `title` sur `<a>`, `title` sur `<abbr>`,
  `colspan`/`rowspan` sur `<th>`/`<td>`. Rien d'autre — donc jamais de `on*`.
- Schémas d'`href` acceptés : `http`, `https`, `mailto`, `tel`, plus les URL
  relatives. Un `href` refusé est retiré ; le libellé du lien reste lisible.
- Balises supprimées **avec leur contenu** (leur texte est du code, pas du
  rédactionnel) : `script`, `style`, `iframe`, `object`, `embed`, `applet`, `form`,
  `noscript`, `template`, `svg`, `math`, `canvas`, `audio`, `video`, `head`,
  `meta`, `link`, `base`… Toute autre balise inconnue (`font`, `center`…) est
  **déballée** : ses enfants remontent, le texte rédigé survit.

Calibrage de la liste blanche sur les producteurs réels, et non sur une idée
générale du HTML :

- la génération IA (`ScormiaGenerationService`, prompt ligne 1022) n'autorise que
  `<p>`, `<strong>`, `<em>`, `<a href="">` et `<br>` ;
- l'éditeur est un `contenteditable` nu, **sans barre d'outils de mise en forme**
  (aucun `execCommand` dans `assets/scormia/`) : le balisage vient des raccourcis
  natifs du navigateur (`<b>`, `<i>`, `<u>`) et des collages — d'où l'ajout des
  listes, titres, citation et tableaux, courants dans un collage.

### Le coût d'une liste blanche ici n'est PAS un coût d'affichage

Point décisif pour calibrer la liste, et qui a été mal évalué en première rédaction :
**tout ce qui est retiré de la liste blanche est une perte de données définitive.**

La chaîne : `assets/controllers/scormia/editor_controller.js` poste
`JSON.stringify(this.module)` — le module **entier**, jamais un delta — et
`ScormiaPersistenceService::applyJsonToModule()` (lignes 242-249) **recrée** les
blocs depuis ce JSON. Or l'éditeur a reçu ce JSON de `moduleToJson()`, donc déjà
filtré. Séquence complète :

1. l'auteur ouvre un module existant ; le JSON qu'il reçoit est déjà amputé ;
2. il corrige un titre, ajoute un bloc — n'importe quoi, sans rapport ;
3. la sauvegarde réécrit **tous** les blocs depuis ce JSON amputé.

L'original est écrasé, en silence, un module à la fois, sans avertissement ni
récupération. Ce n'est donc pas « une mise en forme qui s'affiche moins bien » :
c'est une migration destructive déclenchée par l'usage normal. La liste blanche est
calibrée en conséquence — `<img>` et un jeu étroit de déclarations `style` sont
**conservés à dessein**, et leur sûreté vient du filtrage de leurs valeurs, pas de
leur absence.

**`<img>`** : attributs `src`, `alt`, `title` ; `src` passe la MÊME validation d'URL
que les `href` (`isUrlSure`) — donc pas de `javascript:`, pas de `data:`, pas de
protocol-relative.

**`style`** : reconstruit déclaration par déclaration, jamais « nettoyé ». Sont
retenues `color`, `background-color`, `font-size`, `text-align` — de la couleur, une
taille, un alignement, rien qui puisse repositionner, recouvrir ou charger. La valeur
doit tenir dans un jeu de caractères clos (ni antislash, donc pas d'échappement CSS
`\75 rl(` ; ni `/`, donc pas de commentaire `/*` ; ni guillemet ni `@`) et **toutes**
ses fonctions doivent être dans `rgb/rgba/hsl/hsla` — `url()`, `image()`, `attr()`,
`var()`, `calc()`, `expression()` sont rejetées, y compris en seconde position
(`color: rgb(0,0,0) url(x)` tombe entièrement).

Exclusions maintenues, et ce qu'elles coûtent :

| Exclu | Raison | Perte |
|---|---|---|
| `class` | aucune source n'en produit ; une classe choisie par l'auteur pourrait usurper l'habillage du player | aucune en pratique |
| `target` | évite d'avoir à maintenir un `rel` cohérent | un lien s'ouvre dans l'onglet courant |
| URL `data:` | un `data:` peut porter un document entier ; le valider n'est pas du ressort de ce service | une image collée depuis le presse-papiers (souvent `data:`) ne survit pas |
| déclarations CSS hors des quatre retenues | positionnement, dimensions, `behavior`, `content`… n'ont aucun usage rédactionnel et beaucoup d'usages de recouvrement | une mise en page collée depuis un traitement de texte s'aplatit |
| *(balise inconnue)* | déballée, pas supprimée | son texte survit, sa mise en forme non — ex. `<font color>` collé |

Coût qui n'en est PAS un, mais qu'il faut connaître : dans un extrait de code collé
dans un bloc riche, `if (a <b) {}` perd la fin de sa ligne — `<b` est une balise
pour n'importe quel parseur HTML. C'est **exactement** ce que produisait déjà
`innerHTML` sur la chaîne non nettoyée : la liste blanche n'introduit rien ici. Le
cas voisin `if (a < b) {}` (espace après le chevron), lui, est désormais **préservé**
alors qu'il était mutilé par le navigateur auparavant. Les deux comportements sont
figés par `HtmlCleanerTest::testUnExtraitDeCodeColleDansUnBlocRiche`.

Effet de bord corrigé au passage : un `<` qui n'ouvre pas une balise (« a < b »,
« J'aime <3 ») faisait abandonner au parseur **tout le reste du fragment**. Il est
désormais échappé avant l'analyse, par un motif qui n'accepte que ce qui ne peut
pas devenir une balise — il ne peut donc rien créer, seulement sauver du texte.

### Ce qui reste côté client

Aucune seconde liste blanche n'est ajoutée dans `assets/scormia/blocks/` :
deux listes finiraient par diverger, et le dépôt n'a aucune infrastructure de test
JavaScript pour garder la seconde. Les cinq points d'interpolation portent en
revanche un commentaire qui nomme l'autorité unique et le point de passage —
`richtext.js`, `callout.js`, `quote.js`, `accordion.js`, `timeline.js`, plus la note
de sécurité de `inline_edit.js`.

Le chemin d'édition (contenu en mémoire de l'auteur, avant tout aller-retour
serveur) reste rendu tel quel : l'auteur y voit son propre contenu, dans son propre
navigateur — pas une surface de tiers.

---

## 3. Tests

| Test | Ce qu'il prouve |
|---|---|
| `tests/Unit/Service/Scormia/Block/HtmlCleanerTest.php` | 15 charges utiles, dont les 3 qui franchissaient l'ancien nettoyeur ; assertion de fond par **ré-analyse du résultat** (aucune balise ni attribut hors liste blanche ne subsiste), plus 14 contenus légitimes préservés, les accents français, une formule LaTeX |
| `ScormiaPublicShareTest::testUnBlocRicheNInjectePasDeHtmlArbitraire` | 5 types de blocs × 4 charges utiles sur la **page publique réelle** ; contenu écrit directement dans l'entité (scénario dégradé), donc c'est bien la normalisation à l'émission qui est mesurée |
| `ScormiaPublicShareTest::testLaMiseEnFormeLegitimeSurvitSurLaPagePublique` | l'assainissement ne vide pas les blocs riches |
| `ScormiaEditorSaveTest::testLeContenuRicheSurvitALAllerRetourDeLEditeur` | aller-retour complet par l'**éditeur authentifié** : la mise en forme survit, la charge utile non |

Aux 15 charges utiles initiales s'en ajoutent 14 visant **les deux ouvertures
consenties** (`<img>`, attribut `style`) : `src` en `javascript:`/`data:`/protocol-relative,
`srcset`, `url()`/`expression()`/`behavior` en CSS, échappement CSS `\75 rl(`,
commentaire `/**/`, fonction non autorisée en seconde position, `position: fixed`,
`formaction`, `xlink:href`. **Aucune ne passe.** Règle à tenir : tout assouplissement
futur de la liste blanche doit repasser par ce jeu de charges.

Deux pièges rencontrés, notés ici parce qu'ils rendraient un test vert et vide :

1. La charge utile de référence du chantier (`<img src=x onerror=alert(1)>`) était
   **déjà** arrêtée par l'ancien nettoyeur. Un test bâti sur elle seule aurait été
   vert avant comme après le correctif.
2. Un module de test dont l'écran est relié par le seul `setModule()` (sans
   `addScreen()`) sort `"screens":[]` : les assertions passaient sur une page vide.
3. Le JSON de la page **éditeur** passe par `|e('html_attr')`, qui n'épargne que
   `[A-Za-z0-9,.\-_]` : un marqueur d'assertion contenant `#`, `:` ou une espace
   (`color: #c0392b`) y est faussement absent. Marqueurs choisis en conséquence.

---

## 3 bis. Portée du correctif : ce qu'il ne rattrape pas

Le nettoyage s'applique à l'émission, donc **tous les modules en base sont rattrapés
à la lecture**, sans migration. Une exception :

> **Les ZIP SCORM exportés AVANT ce correctif embarquent le JSON non assaini et
> l'ancien player.** Rejoués dans un LMS externe, ils restent vulnérables jusqu'à
> réexport — et l'exécution a alors lieu dans l'**origine du LMS**, hors de portée
> de toute mesure prise ici.

Rien à corriger dans le code : la réparation est un réexport. Mentionné au
`CHANGELOG.md` pour que la consigne soit visible côté produit.

---

## 4. Configuration serveur de `public/uploads/` — constat

`grep -rn "uploads" public/.htaccess config/` ne renvoie rien : **il n'existe aucun
fichier de configuration de serveur web dans le dépôt** — ni `.htaccess`, ni
configuration nginx/Apache, ni `Dockerfile`, ni `symfony/apache-pack`
(`git ls-files | grep -iE "htaccess|nginx|apache|\.conf$|Dockerfile"` : vide).

La non-exécution de PHP et la désactivation de l'autoindex sous `public/uploads/`
dépendent donc **entièrement de la configuration d'hébergement, hors dépôt**. Aucun
fichier n'a été fabriqué ici : inventer un `.htaccess` non appliqué (nginx l'ignore)
donnerait l'illusion d'une protection.

À vérifier côté hébergement :

- `public/uploads/` ne passe aucune requête au gestionnaire PHP-FPM ;
- l'autoindex y est désactivé ;
- rien n'y est servi avec un `Content-Type` interprétable (le `X-Content-Type-Options: nosniff`
  global, s'il existe, est un complément — pas un substitut).

Contrôle compensatoire déjà en place côté application : `ScormiaUploadService` ne
retient que les MIME de `ScormiaConfig::ALLOWED_*_MIMES` et nomme le fichier depuis
`guessExtension()` (dérivée du MIME détecté). Le commit `2fb1e1c0`
(« l'import ne peut plus déposer un .php dans la racine web ») a fermé le chemin
d'import symétrique.
