Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ Versioning : [Semantic Versioning](https://semver.org/lang/fr/)

---

## [Non publié]

### Sécurité

- **Les données de contenu pouvaient rompre un `<script>`** (#SEC-001). Deux emplacements sérialisaient du contenu avec `JSON.stringify` puis l'injectaient via `set:html` dans un `<script>` : le JSON-LD du layout de repli (`BareLayout`) et les données de la lightbox. `JSON.stringify` **ne touche ni `<` ni `/`** — une valeur portant `</script>` 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.
Expand Down
168 changes: 168 additions & 0 deletions docs/reports/audit-summary-2026-08-24.md
Original file line number Diff line number Diff line change
@@ -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 `<script>` par les données de contenu

Deux emplacements sérialisent des données de contenu avec `JSON.stringify` puis les
injectent via `set:html` dans un `<script>`. `JSON.stringify` **n'échappe ni `<` ni `/`** :
une valeur contenant `</script>` 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</script><img src=x onerror=AUDIT_XSS_MARKER>
```

rendu dans `dist/` :

```html
<script type="application/json" id="hf-lightbox-data">[{…,"alt":"photo</script>
<img src=x onerror=AUDIT_XSS_MARKER>"}]</script>
```

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(/</g, '\\u003c');
```

`<` est un échappement JSON valide : `JSON.parse` rend le `<` d'origine, et la
séquence `</script>` 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 `</script>`, 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://<hôte>/`
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 `<img>` sur 7** portent un `alt` ✅
- **5 `<button>` sur 5** ont un nom accessible (4 `aria-label`, 1 texte) ✅
- `aria-*` présents sur les 8 composants interactifs ; `role` sur `SeriesFilter`,
`SeriesLightbox`, `SeriesMap`

**Non vérifié automatiquement** — la lightbox est un dialogue : piège de focus, `Escape`,
restitution du focus à la fermeture et `aria-modal` demandent un test clavier réel. C'est
la seule raison pour laquelle cet axe n'est pas noté plus haut.

## Documentation — 10/10

`CLAUDE.md` contrôlé fait par fait : **7 commandes déclarées sur 7** existent dans
`package.json`, **11 chemins déclarés sur 11** existent sur le disque. Les écarts relevés
en début de session (8 composants annoncés au lieu de 9, périmètre réduit à la photo) ont
été corrigés dans la PR #84.

---

## Actions retenues

| Priorité | Carte | Effort |
|---|---|---|
| 🔴 P0 | `#SEC-001` — échapper `<` avant injection dans `<script>` (2 sites) | S |
| 🟠 P1 | `#SEC-002` — encoder `embeds[].id` dans les URL de lecture | XS |
| 🟡 P2 | `#TEST-004` — couvrir `getSeriesCover`, `serializeSeries`, `getParentCollection` | S |

## Ce que cet audit n'a pas couvert

- **Test clavier réel de la lightbox** — demande un navigateur, pas une analyse statique.
- **Conformité à la spec 2.9-draft** — le dernier relevé (96 %) date de la 2.7-draft. C'est
le mode `review` qui répond à cette question, pas celui-ci.
- **Audit de dépendances transitif** — traité le jour même par Dependabot : zéro alerte
ouverte après le passage de `nanoid` en 3.3.18.
37 changes: 35 additions & 2 deletions docs/todo.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
kanban-plugin: board
project: hyperfocale
version: "0.17.1"
updated: 2026-08-12
version: "0.18.0"
updated: 2026-08-24
priorities:
P0: Critique (bloquant)
P1: Élevée (important)
Expand Down Expand Up @@ -30,6 +30,33 @@ prefixes:

## Todo

- [ ] #SEC-002 [P1] `embeds[].id` interpolé sans contrainte dans les URL de lecture #sécurité #effort-xs

**Source** : audit 2026-08-24
**Zone** : `src/components/SeriesEmbeds.astro:29-47`, `src/schema.ts:136`

`playerUrl()` interpole `embed.id` directement dans l'URL du lecteur, alors que le schéma déclare `id: z.string().optional()` sans contrainte de format et que `playable` ne vérifie qu'une chaîne non vide. Un id valant `123?autoplay=0&x=` ou `../../autre` détourne l'URL produite ; le cas SoundCloud est le plus fragile, l'id atterrissant dans un paramètre `url=` déjà encodé.

**Ce n'est pas un XSS** — Astro échappe les attributs et le préfixe `https://<hôte>/` tient. L'impact se limite au détournement vers une autre ressource du même hébergeur.

**Checklist** :
- [ ] `encodeURIComponent(embed.id)` à l'interpolation — préférable à un `regex` au schéma, qui rejetterait du contenu que la spec tient pour valide (même logique que la liste ouverte de plateformes)
- [ ] Test unitaire : un id portant `?`, `&` et `/` produit une URL dont le chemin reste intact

- [ ] #TEST-004 [P2] Trois helpers publics sans aucun test #tests #effort-s

**Source** : audit 2026-08-24
**Zone** : `tests/unit/helpers.test.ts`

`getSeriesCover`, `serializeSeries` et `getParentCollection` sont exportés par l'API publique et n'apparaissent **nulle part** dans `tests/` — vérifié par recherche du symbole sur tout le répertoire, zéro occurrence pour les trois. Le ratio global est pourtant bon (1,38 ligne de test par ligne de code) : c'est un trou ponctuel, pas une négligence de fond.

`serializeSeries` est le plus exposé — il façonne ce qu'un site passe à ses îlots client.

**Checklist** :
- [ ] `getSeriesCover` — cover déclarée, cover absente, série introuvable
- [ ] `serializeSeries` — forme du retour, champs optionnels absents
- [ ] `getParentCollection` — slug imbriqué, slug racine

## In Progress

## Blocked
Expand All @@ -38,6 +65,12 @@ prefixes:

## Done

- [x] #SEC-001 [P0] Les données de contenu pouvaient rompre un `<script>` (XSS stocké) #sécurité #effort-s
> ✅ **Terminé** le 2026-08-24 — audit `docs/reports/audit-summary-2026-08-24.md`
**Zone** : `src/layouts/BareLayout.astro`, `src/components/SeriesLightbox.astro`, `tests/e2e/routes.test.ts`, fixtures demo-site
**Résumé** : 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 la suite devenait du HTML actif. Le vecteur large était le JSON-LD de `BareLayout` — il sérialise `title`, champ **obligatoire** de toute série, et ce layout est celui servi par défaut quand le site n'en fournit pas ; la lightbox exposait `images[].alt`. **Trouvé et confirmé par `astro build` réel**, jamais déduit : le marqueur de test sort de la balise dans le HTML produit. Le modèle de menace SSG (« l'auteur écrit son propre contenu ») ne suffisait pas à classer ça bénin — `images.json` (§1.5.1) est produit par `hyperfocale-exporter` depuis les métadonnées des fichiers, et `hyperfocale-cms` écrit le frontmatter : un `alt` peut arriver d'un champ IPTC sans relecture humaine. **Correctif** : `.replace(/</g, '\\u003c')` sur les deux sites — `\u003c` est un échappement JSON valide, donc `JSON.parse` restitue le `<` et la donnée est intacte, seule la séquence littérale disparaît. **Le `.replace` est dupliqué à dessein** : le factoriser demanderait un module `.ts` de plus, donc une entry tsup de plus, or c'est exactement le mode de panne qui a déjà 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. **271 tests verts** (215 unitaires + 56 e2e), dont 5 nouveaux. Les tests portent sur le HTML produit — c'est le seul niveau où le bug existe, un test unitaire ne vérifierait que la ligne de correctif. Validés par mutation : neutraliser les deux `.replace` fait tomber les 5, et eux seuls.
**Reste** : `#SEC-002` (encodage de `embeds[].id`) reste ouvert, sans lien de cause.

- [x] #DATA-009 [P2] Deux vocabulaires du schéma absents de l'entrée racine #packaging #effort-xs
> ✅ **Terminé** le 2026-08-12 — publié en 0.17.1
**Zone** : `src/index.ts`, `tests/unit/exports.test.ts`
Expand Down
15 changes: 15 additions & 0 deletions examples/demo-site/src/content/series/garde-injection/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
title: "Garde injection </script><em>marqueur-titre</em>"
date: 2024-01-01
description: "Fixture de non-régression #SEC-001 — ne pas supprimer."
images:
- file: "01.png"
alt: "Garde injection </script><em>marqueur-alt</em>"
---

Fixture de test. Le titre et le texte alternatif portent volontairement une
séquence `</script>` : ils sont sérialisés en JSON puis injectés via `set:html`
(JSON-LD du layout de repli, données de la lightbox). Sans échappement de `<`,
la balise se ferme et la suite devient du HTML actif.

Voir `tests/e2e/routes.test.ts` — la série existe pour que ce test ait une prise.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions examples/demo-site/src/pages/garde-lightbox.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
// Fixture de non-régression #SEC-001 — ne pas supprimer.
//
// Les routes injectées ne rendent que `SeriesGallery` ; la lightbox n'a donc
// aucune page dans le demo-site, et le second site d'injection resterait hors
// de portée des tests e2e sans celle-ci.
import SeriesLightbox from '@regrets/hyperfocale/components/SeriesLightbox.astro';

const images = [
{
src: '/garde.png',
width: 10,
height: 10,
alt: 'Garde injection </script><em>marqueur-lightbox</em>',
},
];
---

<html lang="fr">
<head><meta charset="utf-8" /><title>Garde lightbox</title></head>
<body><SeriesLightbox images={images} /></body>
</html>
9 changes: 8 additions & 1 deletion src/components/SeriesLightbox.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ const { images } = Astro.props;
// Données sérialisées pour le JS client. Injectées via `set:html` : dans un
// `<script>`, Astro n'évalue pas les expressions `{}` du template — sans ça, le
// JSON serait rendu littéralement et la lightbox n'aurait aucune image.
//
// `set:html` n'échappe rien, et `JSON.stringify` ne touche ni `<` ni `/` : un
// `alt` contenant `</script>` fermerait la balise, et la suite serait du HTML
// actif. `\u003c` est un échappement JSON valide — `JSON.parse` restitue le `<`
// d'origine — donc la séquence ne peut plus apparaître littéralement.
// Un `alt` n'est pas toujours écrit à la main : `images.json` (§1.5.1) est
// produit par des outils qui lisent les métadonnées des fichiers.
const lightboxData = JSON.stringify(
images.map((img) => ({ src: img.src, width: img.width, height: img.height, alt: img.alt })),
);
).replace(/</g, '\\u003c');
---

<div
Expand Down
9 changes: 8 additions & 1 deletion src/layouts/BareLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ interface Props {
}

const { title, description, lang = 'fr', schema } = Astro.props;

// Même précaution que dans `SeriesLightbox` : `set:html` n'échappe rien et
// `JSON.stringify` laisse passer `<`. Le vecteur est ici plus large — ce JSON-LD
// porte le `title` de la série, champ obligatoire, et ce layout est celui servi
// par défaut quand le site n'en fournit pas.
const schemaJson =
schema === undefined ? undefined : JSON.stringify(schema).replace(/</g, '\\u003c');
---

<!DOCTYPE html>
Expand All @@ -26,7 +33,7 @@ const { title, description, lang = 'fr', schema } = Astro.props;
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
{description && <meta name="description" content={description} />}
{schema && <script is:inline type="application/ld+json" set:html={JSON.stringify(schema)} />}
{schemaJson && <script is:inline type="application/ld+json" set:html={schemaJson} />}
</head>
<body>
<main>
Expand Down
Loading