Skip to content

fix(security): échapper < avant injection JSON dans un <script> (#SEC-001) - #85

Merged
izo merged 1 commit into
mainfrom
fix/sec-001-json-script-breakout
Aug 24, 2026
Merged

fix(security): échapper < avant injection JSON dans un <script> (#SEC-001)#85
izo merged 1 commit into
mainfrom
fix/sec-001-json-script-breakout

Conversation

@izo

@izo izo commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Trouvé par l'audit blackemperor du 2026-08-24 — rapport complet dans docs/reports/audit-summary-2026-08-24.md.

Le défaut

Deux emplacements sérialisaient du contenu avec JSON.stringify puis l'injectaient via set:html dans un <script>. JSON.stringify ne touche ni < ni / : une valeur portant </script> fermait la balise, et tout ce qui suivait devenait du HTML actif.

Fichier Champ vecteur Portée
src/layouts/BareLayout.astro title, description (JSON-LD) toute route injectée sur le layout de repli
src/components/SeriesLightbox.astro images[].alt toute page rendant la lightbox

Le premier est le plus large : title est obligatoire sur chaque série, et BareLayout est le layout servi par défaut quand le site n'en fournit pas.

Confirmé par astro build réel avant correction, pas déduit. La mutation ci-dessous rejoue cette preuve à chaque exécution de la suite.

Pourquoi ce n'est pas du simple auto-XSS

L'objection normale — « le contenu d'un SSG est écrit par l'auteur du site » — ne couvre pas les deux cas suivants :

  1. images.json (§1.5.1) est produit par des outils. hyperfocale-exporter lit les métadonnées des fichiers, hyperfocale-cms écrit le frontmatter : un texte alternatif peut venir d'un champ IPTC sans jamais passer sous les yeux d'un humain.
  2. Un CMS multi-utilisateurs rend le frontmatter semi-hostile par construction.

Le correctif

.replace(/</g, '\u003c') sur les deux sites. \u003c est un échappement JSON valide : JSON.parse restitue le < d'origine, la donnée est intacte, seule la séquence littérale ne peut plus apparaître. Aucun changement d'API, aucun contenu rejeté.

Le .replace est dupliqué à dessein. Le factoriser demanderait un module .ts de plus, donc une entry tsup de plus — le mode de panne exact qui a livré quatre composants non importables en v0.8.0 et deux vocabulaires manquants en 0.17.1. Une ligne dupliquée coûte moins cher qu'une surface de packaging supplémentaire.

Les tests

Cinq tests e2e, portant sur le HTML réellement produit : c'est le seul niveau où le bug existait — un test unitaire ne vérifierait que la ligne de correctif.

Deux fixtures les alimentent. SeriesLightbox n'étant rendu par aucune route du plugin, le second site serait resté hors de portée sans une page dédiée dans le demo-site.

Validés par mutation — neutraliser les deux .replace fait tomber ces 5 tests, et eux seuls (51 autres restent verts).

Vérification

  • npm run typecheck — 0 erreur
  • npm test271 tests verts (215 unitaires + 56 e2e), dont 5 nouveaux

Reste ouvert

#SEC-002 — encodage de embeds[].id dans les URL de lecture (P1, sans lien de cause avec celui-ci).

🤖 Generated with Claude Code

https://claude.ai/code/session_01AgEyJQRrNvugcuu9WsujGy

…#SEC-001)

Deux emplacements sérialisaient du contenu avec `JSON.stringify` puis
l'injectaient via `set:html` dans un `<script>` — le JSON-LD de `BareLayout` et
les données de `SeriesLightbox`. `JSON.stringify` ne touche ni `<` ni `/` : une
valeur portant `</script>` fermait la balise, et la suite devenait du HTML actif.

Le vecteur large est le JSON-LD : il sérialise `title`, champ obligatoire de
toute série, et `BareLayout` est le layout servi par défaut quand le site n'en
fournit pas.

Le modèle de menace d'un SSG — l'auteur écrit son propre contenu — ne suffit pas
à classer cela bénin : `images.json` (§1.5.1) est produit par des outils, et un
texte alternatif peut arriver d'un champ IPTC sans relecture humaine.

`<` est un échappement JSON valide : `JSON.parse` restitue le `<`, la donnée
est intacte, seule la séquence littérale disparaît. Aucun changement d'API,
aucun contenu rejeté.

Le `.replace` est dupliqué à dessein. Le factoriser demanderait un module `.ts`
de plus, donc une entry tsup de plus — le mode de panne exact qui a livré quatre
composants non importables en v0.8.0 et deux vocabulaires manquants en 0.17.1.
Une ligne dupliquée coûte moins qu'une surface de packaging.

Les tests portent sur le HTML réellement produit : c'est le seul niveau où le
bug existait, un test unitaire ne vérifierait que la ligne de correctif. Deux
fixtures les alimentent — la lightbox n'étant rendue par aucune route, le second
site serait resté hors de portée sans une page dédiée.

271 tests verts (215 unitaires + 56 e2e), dont 5 nouveaux. Validés par mutation :
neutraliser les deux `.replace` fait tomber ces 5 tests, et eux seuls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEyJQRrNvugcuu9WsujGy
@izo
izo merged commit 3fbd13e into main Aug 24, 2026
1 check passed
@izo
izo deleted the fix/sec-001-json-script-breakout branch August 24, 2026 10:10
@izo izo mentioned this pull request Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant