diff --git a/README.md b/README.md
index 853c959..95904d2 100644
--- a/README.md
+++ b/README.md
@@ -38,9 +38,9 @@ project/
- **Assets statiques** — servis directement depuis `public/assets/`, laissés au serveur HTTP / reverse proxy.
- **Upload** — `Web::receive()` avec contrôle de taille, puis validation MIME/dimensions côté modèle.
- **Images** — normalisation des médias via `Image`, lecture/écriture via `Base::read()` / `Base::write()`.
-- **Markdown** — `Markdown::instance()->convert()` suivi d'un assainissement DOM ciblé et de la résolution des images média.
+- **Markdown** — `Markdown::instance()->convert()` suivi d'un assainissement DOM strict (liste blanche de balises, attributs et protocoles) et de la résolution locale des images média.
- **Slugs** — `Web::instance()->slug()`.
-- **Session / CSRF** — `$f3->set('JAR', …)`, `new Session(null, 'CSRF')`, hooks `beforeRoute()` sur les contrôleurs protégés, et petit pool de jetons côté session pour éviter les collisions multi-onglets.
+- **Session / CSRF** — `$f3->set('JAR', …)`, `new Session(null, 'CSRF')` pour s'appuyer sur la génération native F3, puis persistance d'un jeton unique en session pour qu'il reste valide entre le GET du formulaire et le POST suivant ; validation centralisée via `verifyCsrf()`.
- **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`.
- **Erreurs** — gestion personnalisée en production via `ONERROR` + fallback HTML minimal sur erreur fatale.
@@ -92,6 +92,10 @@ app.trusted_proxies=127.0.0.1,::1,172.16.0.0/12
Seuls ces proxies sont autorisés à influencer `X-Forwarded-Proto` / `Forwarded`.
+### À propos du CSRF F3
+
+F3 sait générer un jeton via `new Session(null, 'CSRF')`, mais ce jeton est produit à l'instanciation de la requête. Le projet s'en sert donc comme source native F3, puis le persiste explicitement en session pour garder un jeton stable entre l'affichage d'un formulaire et sa soumission.
+
## Développement local
```bash
diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php
index af52d20..420a3dd 100644
--- a/app/Controllers/BaseController.php
+++ b/app/Controllers/BaseController.php
@@ -24,8 +24,9 @@ abstract class BaseController
? $data['flash']
: $this->pullFlash();
- // Jeton CSRF stable par session : plus simple et plus robuste que le
- // pool précédent, tout en restant compatible multi-onglets.
+ // On s'appuie sur Session(..., 'CSRF') pour la génération F3 du
+ // jeton, mais on le persiste en session pour qu'il reste valide
+ // entre la requête GET qui rend le formulaire et le POST suivant.
$this->ensureCsrfToken();
$this->f3->mset($data + [
@@ -34,7 +35,7 @@ abstract class BaseController
'metaDescription' => null,
'adminMode' => false,
'currentUser' => $currentUser,
- 'FORM_CSRF' => (string) $this->f3->get('SESSION.csrf_token'),
+ 'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'),
]);
echo Template::instance()->render('layout.html');
@@ -44,13 +45,15 @@ abstract class BaseController
// stocke dans le hive — accessible partout, y compris les templates.
protected function currentUser(): ?array
{
- if (!$this->f3->exists('currentUser', $user)) {
+ if (!$this->f3->exists('ctx.current_user_loaded')) {
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
$user = $userId > 0 ? (new User())->findById($userId) : null;
+
$this->f3->set('currentUser', $user);
+ $this->f3->set('ctx.current_user_loaded', true);
}
- return $user;
+ return $this->f3->get('currentUser');
}
protected function requireAuth(): void
@@ -94,7 +97,12 @@ abstract class BaseController
return;
}
- $this->f3->set('SESSION.csrf_token', bin2hex(random_bytes(32)));
+ $seed = trim((string) ($this->f3->get('CSRF') ?? ''));
+ if ($seed === '') {
+ $seed = bin2hex(random_bytes(32));
+ }
+
+ $this->f3->set('SESSION.csrf_token', $seed);
}
private function pullFlash(): array
diff --git a/app/Services/MarkdownService.php b/app/Services/MarkdownService.php
index c9bc6a4..ca084e9 100644
--- a/app/Services/MarkdownService.php
+++ b/app/Services/MarkdownService.php
@@ -10,6 +10,11 @@ class MarkdownService extends Prefab
'strong', 'em', 'a', 'img', 'hr', 'br',
];
+ private const ALLOWED_ATTRIBUTES = [
+ 'a' => ['href', 'title', 'rel', 'target'],
+ 'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
+ ];
+
public function compile(string $markdown, Media $media): string
{
$markdown = trim($markdown);
@@ -18,78 +23,205 @@ class MarkdownService extends Prefab
}
$html = Markdown::instance()->convert($markdown);
- $html = strip_tags($html, self::ALLOWED_TAGS);
- $html = self::resolveImages($html, $media);
- $html = self::secureLinks($html);
+ $document = $this->parseFragment($html);
+ $this->sanitizeTree($document, $media);
- return trim($html);
+ return trim($this->renderFragment($document));
}
- // Résout les images media:filename et supprime les images externes.
- private static function resolveImages(string $html, Media $media): string
+ private function parseFragment(string $html): DOMDocument
{
- $f3 = Base::instance();
+ $document = new DOMDocument('1.0', 'UTF-8');
+ $wrapper = '