From c519061f5939367e6874fb082439dd42728da7f8 Mon Sep 17 00:00:00 2001 From: Mathieu Drouet Date: Mon, 24 Aug 2026 12:02:50 +0200 Subject: [PATCH] =?UTF-8?q?fix(security):=20=C3=A9chapper=20`<`=20avant=20?= =?UTF-8?q?injection=20JSON=20dans=20un=20`` 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) Claude-Session: https://claude.ai/code/session_01AgEyJQRrNvugcuu9WsujGy --- CHANGELOG.md | 16 ++ docs/reports/audit-summary-2026-08-24.md | 168 ++++++++++++++++++ docs/todo.md | 37 +++- .../content/series/garde-injection/index.md | 15 ++ .../series/garde-injection/media/01.png | Bin 0 -> 69 bytes .../demo-site/src/pages/garde-lightbox.astro | 22 +++ src/components/SeriesLightbox.astro | 9 +- src/layouts/BareLayout.astro | 9 +- tests/e2e/routes.test.ts | 60 +++++++ 9 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 docs/reports/audit-summary-2026-08-24.md create mode 100644 examples/demo-site/src/content/series/garde-injection/index.md create mode 100644 examples/demo-site/src/content/series/garde-injection/media/01.png create mode 100644 examples/demo-site/src/pages/garde-lightbox.astro diff --git a/CHANGELOG.md b/CHANGELOG.md index 646212f2..ad80eb97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ Versioning : [Semantic Versioning](https://semver.org/lang/fr/) --- +## [Non publié] + +### Sécurité + +- **Les données de contenu pouvaient rompre un `` fermait la balise, et tout ce qui suivait 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. La lightbox exposait `images[].alt`. + + Le modèle de menace d'un générateur statique — l'auteur écrit son propre contenu — ne suffit pas à classer cela bénin. `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 donc arriver d'un champ IPTC sans jamais passer sous les yeux d'un humain. + + Le correctif remplace `<` par `\u003c` avant l'injection. C'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é. + + Cinq tests e2e couvrent les deux sites, sur le HTML réellement produit — le seul niveau où le bug existait. + +--- + ## [0.18.0] — 2026-08-20 Les helpers savent lire plusieurs collections dans un même build. Aucun changement cassant. diff --git a/docs/reports/audit-summary-2026-08-24.md b/docs/reports/audit-summary-2026-08-24.md new file mode 100644 index 00000000..7327eb84 --- /dev/null +++ b/docs/reports/audit-summary-2026-08-24.md @@ -0,0 +1,168 @@ +# Audit consolidé — hyperfocale 0.18.0 + +> Généré le 2026-08-24 · commit `9f2b285` · mode `blackemperor audit` +> Audit précédent : [`audit-summary-2026-06-24.md`](audit-summary-2026-06-24.md) (v0.5.0) + +## Scores + +| Axe | Score | Évolution vs 2026-06-24 | +|-----|-------|--------------------------| +| Code / architecture | **8,5/10** | ↗ périmètre triplé, dette non accumulée | +| Performance | **9/10** | ↗ zéro framework client, JS inline borné | +| Accessibilité | **8/10** | → base solide, lightbox à vérifier au clavier | +| Sécurité | **5/10** | ↘ **deux XSS stockés confirmés** (nouveaux composants) | +| Documentation (`CLAUDE.md`) | **10/10** | ↗ corrigée le jour même | + +**Verdict — ne pas publier de nouvelle version avant correction de `#SEC-001`.** +Le paquet 0.18.0 déjà en ligne porte la faille : les deux sites d'injection existent +depuis 0.16.0 (`BareLayout`) et bien avant pour la lightbox. + +--- + +## 🚨 Sécurité — 2 findings HAUTS, confirmés par build + +### `#SEC-001` · Rupture de `` ferme la balise et tout ce qui suit devient du HTML actif. + +| # | Fichier | Champ vecteur | Portée | +|---|---------|---------------|--------| +| a | `src/layouts/BareLayout.astro:29` | `title`, `description` de la série (JSON-LD) | **toute route injectée** sur le layout de repli | +| b | `src/components/SeriesLightbox.astro:74` | `images[].alt` | toute page rendant la lightbox | + +Le cas (a) est le plus large : `title` est **obligatoire** sur chaque série, et +`BareLayout` est le layout servi par défaut quand le site ne passe pas le sien. + +**Preuve** — page construite puis `astro build` réel, HTML inspecté : + +``` +alt = photo +``` + +rendu dans `dist/` : + +```html + +"}] +``` + +Le marqueur se trouve **après** la fermeture du script : il est sorti de la zone JSON. +Page de test supprimée après vérification. + +**Modèle de menace** — le contenu d'un SSG est en principe écrit par l'auteur du site, +ce qui limiterait l'impact à de l'auto-XSS. Deux raisons de ne pas s'en contenter : + +1. `images.json` (§1.5.1) est **produit par des outils** — `hyperfocale-exporter` lit les + métadonnées d'images, `hyperfocale-cms` écrit le frontmatter. Un `alt` peut donc 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. + +**Correctif** (une ligne par site, sans changement d'API) : + +```ts +const safe = JSON.stringify(data).replace(/` ne peut plus apparaître littéralement. Même traitement pour le +JSON-LD de `BareLayout`. + +**Test de non-régression à ajouter** : une série au `title` contenant ``, buildée +en e2e, dont le HTML ne doit pas contenir la séquence hors de la balise. + +### `#SEC-002` · `embeds[].id` interpolé sans contrainte dans les URL de lecture + +`src/components/SeriesEmbeds.astro:29-47` construit l'URL du lecteur par interpolation +directe, alors que le schéma déclare `id: z.string().optional()` — **aucun format imposé** +(`src/schema.ts:136`), et `playable` ne vérifie qu'une chose : chaîne non vide. + +```ts +return `https://player.vimeo.com/video/${embed.id}?autoplay=1`; +``` + +Un `id` valant `123?autoplay=0&x=` ou `../../autre-chemin` détourne l'URL produite. Le cas +SoundCloud est le plus fragile — l'id atterrit dans un paramètre `url=` déjà encodé, où un +`&` casse la structure. + +Ce n'est **pas** un XSS : Astro échappe les attributs, et le préfixe `https:///` +reste en place. L'impact se limite au détournement de l'URL vers une autre ressource du +même hébergeur. + +**Correctif** : `encodeURIComponent(embed.id)` à l'interpolation, ou un `z.string().regex(/^[\w-]+$/)` +au schéma. La première option est préférable — elle ne rejette aucun contenu que la spec +tient pour valide, ce qui est la ligne suivie pour les plateformes inconnues. + +--- + +## Code & architecture — 8,5/10 + +| Indicateur | Valeur | +|---|---| +| `src/` TypeScript | 6 fichiers · 1 794 lignes | +| `src/` Astro | 13 fichiers · 2 086 lignes | +| Tests | 16 fichiers · 2 482 lignes — **1,38× le TypeScript**, 0,64× tout `src/` | +| `any` · `@ts-ignore` · `!.` | **0** | +| `console.*` non gardés | **0** (le seul `console.info` est derrière `HYPERFOCALE_DEBUG_CACHE`) | +| `TODO` / `FIXME` / `HACK` | **0** | +| Fichier `src/` non importé | **0** | + +Deux fonctions dépassent 90 lignes dans `src/helpers/index.ts` — `parseImageManifest` (93) +et `getSeriesImages` (92). Les deux traitent les trois formes d'entrée du manifeste ; leur +longueur vient de la couverture des cas, pas d'un enchevêtrement. Pas d'action recommandée. + +## Performance — 9/10 + +`dist/` publié : **224 Ko**, 32 fichiers. Aucun framework client — le JS envoyé au +navigateur est inline et borné : + +| Composant | JS inline | +|---|---| +| `SeriesLightbox` | 5 633 c. | +| `SeriesFilter` | 2 463 c. | +| `SeriesEmbeds` | 1 169 c. | +| `SeriesGallery` | 401 c. | +| `SeriesMasonry` | 263 c. | + +`loading` et `decoding` sont posés sur les images des cinq composants concernés. Les embeds +sont rendus en façade — l'iframe tierce n'arrive qu'au clic, ce qui évite à la fois le coût +de chargement et le dépôt de cookies non sollicité. + +## Accessibilité — 8/10 + +Vérifié sur balises multi-lignes, pas au `grep` naïf : + +- **7 `` sur 7** portent un `alt` ✅ +- **5 `