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
57 changes: 57 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Sécurité (site statique)

**🌐 Français** — MemoForge est un site **statique**, sans backend ni dépendance runtime
(0 npm en production). La surface d'attaque est minimale ; ce document décrit le durcissement
appliqué (E10-5, #166) et ce qui relève de la couche serveur/CDN.

## Content-Security-Policy (stricte, active)

Déclarée via `<meta http-equiv>` dans `index.html`, **sans `'unsafe-inline'`** — les styles
et le script de pré-peinture du thème sont externalisés (voir #163 et `src/theme-init.js`).

```
default-src 'self';
script-src 'self';
style-src 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data:;
connect-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none'
```

- **`object-src 'none'` / `frame-ancestors 'none'`** : ni plugins, ni embarquement en iframe
(anti-clickjacking).
- **`img-src … data:`** : autorise le favicon SVG en data-URI (seul usage de `data:`).
- **Vérifiée en CI** : le harnais (`npm run test:visual`) écoute
`securitypolicyviolation` sur la carte et une salle et **échoue s'il y a la moindre
violation** — les 9 écrans sont par ailleurs rendus **CSP active** (0 régression visuelle).

### Exception documentée : Google Fonts

Seule ressource tierce : les webfonts **VT323** et **IBM Plex Mono**
(`fonts.googleapis.com` + `fonts.gstatic.com`). Choix assumé : les self-héberger dépasserait
le budget de poids (280 KB) actuellement à ~254 KB. Pour éliminer complètement le tiers,
déposer les binaires `woff2` dans `assets/fonts/`, basculer les `src` de
`styles/tokens/fonts.css` en local, puis réduire la CSP à `style-src 'self'; font-src 'self'`.

## En-têtes HTTP (couche serveur/CDN)

Certaines protections ne peuvent **pas** être posées via `<meta>` et doivent l'être en
en-tête de réponse. GitHub Pages ne permet pas d'en-têtes personnalisés ; ces valeurs sont à
appliquer si le site passe derrière un CDN/reverse-proxy configurable :

| En-tête | Valeur recommandée | Rôle |
|---|---|---|
| `X-Content-Type-Options` | `nosniff` | empêche le MIME-sniffing |
| `Referrer-Policy` | `no-referrer` | déjà posé aussi via `<meta name="referrer">` |
| `Permissions-Policy` | `geolocation=(), camera=(), microphone=()` | coupe les API non utilisées |
| `Strict-Transport-Security` | `max-age=63072000` | force HTTPS (déjà fourni par Pages) |

## Rappels

- **0 dépendance runtime**, **0 script tiers**, **0 appel réseau** hors webfonts.
- Les données joueur (progression, stats, thème, langue) restent en `localStorage` — **jamais
envoyées** (voir la garde de confidentialité des stats, E9-4).
25 changes: 8 additions & 17 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,22 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- CSP stricte (E10-5) : tout depuis 'self'. Seule exception documentée : les webfonts
Google Fonts (feuille + binaires). Aucun 'unsafe-inline' — styles et script de thème
sont externalisés. Les en-têtes non disponibles en <meta> (X-Content-Type-Options,
Permissions-Policy) sont documentés dans docs/SECURITY.md (à poser côté serveur/CDN). -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" />
<meta name="referrer" content="no-referrer" />
<title>MemoForge</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='14' font-family='monospace' fill='%2346E08A'%3EM%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="styles/main.css" />
<link rel="stylesheet" href="styles/components.css" />
<link rel="stylesheet" href="styles/views.css" />
<script>
// Applique le thème sauvegardé avant le premier paint (évite le flash de couleur).
try { if (localStorage.getItem('memoforge.theme') === 'light') document.documentElement.classList.add('theme-light'); } catch (e) {}
</script>
<style>
.theme-toggle {
position: fixed; top: 12px; right: 12px; z-index: 50;
min-height: 36px; padding: 6px 12px; cursor: pointer;
font-family: var(--font-mono); font-size: var(--fs-sm); font-weight: 600;
color: var(--text-muted); background: var(--surface-well);
border: 1px solid var(--border); border-radius: var(--radius-sm);
transition: border-color var(--dur), color var(--dur);
}
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
.theme-toggle:focus-visible { outline: none; box-shadow: var(--ring); }
</style>
<script src="src/theme-init.js"></script>
</head>
<body>
<a href="#app" id="skip-link" class="skip-link">Aller au contenu</a>
<button id="lang-toggle" class="theme-toggle" type="button" aria-label="Change language / Changer de langue" style="right: 96px">EN</button>
<button id="lang-toggle" class="theme-toggle theme-toggle--lang" type="button" aria-label="Change language / Changer de langue">EN</button>
<button id="theme-toggle" class="theme-toggle" type="button" aria-label="Basculer entre thème clair et sombre" aria-pressed="false">☀ clair</button>
<main class="wrap">
<h1 class="brand">MemoForge</h1>
Expand Down
7 changes: 7 additions & 0 deletions src/theme-init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Applique le thème sauvegardé AVANT le premier paint (évite le flash de couleur).
// Externalisé de index.html pour permettre une CSP stricte (script-src 'self', sans
// 'unsafe-inline'). Script classique et bloquant dans <head> → s'exécute avant le rendu.
try {
if (localStorage.getItem('memoforge.theme') === 'light')
document.documentElement.classList.add('theme-light');
} catch { /* localStorage indisponible : on garde le thème par défaut */ }
13 changes: 13 additions & 0 deletions styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,19 @@ button:hover { border-color: var(--accent); color: var(--accent); }
.star-off { color: var(--text-muted); opacity: .6; }
.verdict .btn-primary { margin-top: var(--sp-3); }

/* Boutons flottants langue/thème (déplacés de l'inline <style> d'index.html pour la CSP). */
.theme-toggle {
position: fixed; top: 12px; right: 12px; z-index: 50;
min-height: 36px; padding: 6px 12px; cursor: pointer;
font-family: var(--font-mono); font-size: var(--fs-sm); font-weight: 600;
color: var(--text-muted); background: var(--surface-well);
border: 1px solid var(--border); border-radius: var(--radius-sm);
transition: border-color var(--dur), color var(--dur);
}
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
.theme-toggle:focus-visible { outline: none; box-shadow: var(--ring); }
.theme-toggle--lang { right: 96px; }

/* Focus clavier visible sur TOUS les éléments interactifs (WCAG 2.4.7). Un seul anneau
cohérent (--ring) pour boutons, liens, salles et champs. */
.mf-room-link:focus-visible,
Expand Down
40 changes: 39 additions & 1 deletion tests/visual/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,14 @@ async function shoot(page, screen) {
}
}, screen.lang || 'fr');
await page.goto(`http://127.0.0.1:${PORT}${screen.url}`, { waitUntil: 'networkidle0' });
await page.addStyleTag({ content: '*, *::before, *::after { transition: none !important; animation: none !important; caret-color: transparent !important; }' });
// Normalisation du rendu (pas d'anim/transition/caret) via une feuille CONSTRUITE
// (adoptedStyleSheets, CSSOM) : compatible avec la CSP stricte de la page, contrairement
// à un <style> injecté (bloqué par style-src 'self').
await page.evaluate(() => {
const sheet = new CSSStyleSheet();
sheet.replaceSync('*, *::before, *::after { transition: none !important; animation: none !important; caret-color: transparent !important; }');
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
});
if (screen.prepare) await page.evaluate(screen.prepare);
const result = await page.evaluate(screen.verify);
const outFile = path.join(OUT_DIR, `${screen.name}.png`);
Expand Down Expand Up @@ -314,9 +321,39 @@ const A11Y_VIEWS = [
{ name: 'salle rec-1', lang: 'fr', prepare: () => { window.__memoforge.enterRoom('rec-1'); } }
];

// Vérifie que la CSP stricte n'émet AUCUNE violation sur les vues réelles (AC #166) :
// écoute l'événement securitypolicyviolation, installé avant tout chargement de ressource.
async function checkCSP(page) {
let failures = 0;
for (const view of [
{ name: 'carte', prepare: null },
{ name: 'salle rec-1', prepare: () => window.__memoforge.enterRoom('rec-1') }
]) {
await page.evaluateOnNewDocument(() => {
window.__csp = [];
document.addEventListener('securitypolicyviolation', (e) => {
window.__csp.push(`${e.violatedDirective} ← ${e.blockedURI}`);
});
});
await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'networkidle0' });
if (view.prepare) await page.evaluate(view.prepare);
const violations = await page.evaluate(() => window.__csp || []);
if (violations.length) {
failures += 1;
console.error(`✖ CSP ${view.name} — ${violations.length} violation(s) : ${[...new Set(violations)].join(' · ')}`);
} else {
console.log(`✔ CSP ${view.name} — aucune violation (script/style/img/font depuis 'self')`);
}
}
return failures;
}

// Lance axe-core sur chaque vue et échoue sur toute violation serious/critical (WCAG 2.x AA).
async function checkA11y(page) {
let failures = 0;
// axe s'injecte comme script inline → bloqué par script-src 'self'. On désactive la CSP
// UNIQUEMENT pour cet audit (axe est un outil de test, pas du code de prod).
await page.setBypassCSP(true);
for (const view of A11Y_VIEWS) {
await page.evaluateOnNewDocument((lang) => {
try { window.localStorage.setItem('memoforge.lang', lang || 'fr'); } catch { /* ignore */ }
Expand Down Expand Up @@ -376,6 +413,7 @@ async function main() {
}
}
failures += await checkPerfBudget(page);
failures += await checkCSP(page);
failures += await checkA11y(page);
} finally {
await browser.close();
Expand Down
Loading