From d71cf304a9d157a10425d9ece1ce3eec2fba65b5 Mon Sep 17 00:00:00 2001 From: julien Date: Sun, 29 Mar 2026 21:01:07 +0200 Subject: [PATCH] Less home code more F3 --- README.md | 8 +- app/Controllers/BaseController.php | 20 ++- app/Services/MarkdownService.php | 248 ++++++++++++++++++++++------- app/Views/partials/csrf_field.html | 2 +- app/bootstrap.php | 2 +- docker/entrypoint.sh | 0 6 files changed, 212 insertions(+), 68 deletions(-) mode change 100755 => 100644 docker/entrypoint.sh 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 = '
' . $html . '
'; - return preg_replace_callback('/]*>/i', function (array $m) use ($f3, $media): string { - if (!preg_match('/src="([^"]*)"/', $m[0], $s) || !str_starts_with($s[1], 'media:')) { - return ''; - } + $previous = libxml_use_internal_errors(true); + $document->loadHTML( + '' . $wrapper, + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD + ); + libxml_clear_errors(); + libxml_use_internal_errors($previous); - $fileName = substr($s[1], 6); - if ($fileName === '') { - return ''; - } - - $item = $media->findByFileName($fileName); - if ($item === null) { - throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.'); - } - - // L'alt du Markdown est déjà échappé par le parser F3. - // Le fallback vers l'alt de la base nécessite un échappement. - $alt = ''; - if (preg_match('/alt="([^"]*)"/', $m[0], $a)) { - $alt = $a[1]; - } - if ($alt === '') { - $alt = $f3->encode($item['alt']); - } - - $url = $f3->encode($item['url']); - $attrs = 'src="' . $url . '" alt="' . $alt . '"'; - - // width/height préviennent le layout shift au chargement. - if ((int) $item['width'] > 0) { - $attrs .= ' width="' . (int) $item['width'] . '"'; - } - if ((int) $item['height'] > 0) { - $attrs .= ' height="' . (int) $item['height'] . '"'; - } - - return ''; - }, $html) ?? $html; + return $document; } - // Sécurise les liens : rel="noopener noreferrer" sur tous, - // target="_blank" sur les liens externes uniquement. - private static function secureLinks(string $html): string + private function renderFragment(DOMDocument $document): string { - return preg_replace_callback('/]*>/i', function (array $m): string { - if (!preg_match('/href="([^"]*)"/', $m[0], $h)) { - return $m[0]; + $root = $document->getElementById('markdown-root'); + if ($root === null) { + return ''; + } + + $html = ''; + foreach (iterator_to_array($root->childNodes) as $child) { + $html .= $document->saveHTML($child); + } + + return $html; + } + + private function sanitizeTree(DOMDocument $document, Media $media): void + { + $root = $document->getElementById('markdown-root'); + if ($root === null) { + return; + } + + $this->sanitizeChildren($root, $media); + } + + private function sanitizeChildren(DOMNode $parent, Media $media): void + { + foreach (iterator_to_array($parent->childNodes) as $child) { + if ($child instanceof DOMElement) { + $tag = strtolower($child->tagName); + + if (!in_array($tag, self::ALLOWED_TAGS, true)) { + $this->dropDisallowedElement($child); + continue; + } + + $this->sanitizeElement($child, $media); + $this->sanitizeChildren($child, $media); } + } + } - $attrs = 'href="' . $h[1] . '" rel="noopener noreferrer"'; + private function dropDisallowedElement(DOMElement $element): void + { + $parent = $element->parentNode; + if ($parent === null) { + return; + } - if (preg_match('~^https?://~i', $h[1])) { - $attrs .= ' target="_blank"'; + if (in_array(strtolower($element->tagName), ['script', 'style'], true)) { + $parent->removeChild($element); + return; + } + + while ($element->firstChild !== null) { + $parent->insertBefore($element->firstChild, $element); + } + + $parent->removeChild($element); + } + + private function sanitizeElement(DOMElement $element, Media $media): void + { + $tag = strtolower($element->tagName); + $allowedAttributes = self::ALLOWED_ATTRIBUTES[$tag] ?? []; + + foreach (iterator_to_array($element->attributes) as $attribute) { + if (!in_array(strtolower($attribute->name), $allowedAttributes, true)) { + $element->removeAttributeNode($attribute); } + } - if (preg_match('/title="([^"]*)"/', $m[0], $t)) { - $attrs .= ' title="' . $t[1] . '"'; - } + if ($tag === 'a') { + $this->sanitizeLink($element); + return; + } - return ''; - }, $html) ?? $html; + if ($tag === 'img') { + $this->sanitizeImage($element, $media); + } + } + + private function sanitizeLink(DOMElement $element): void + { + $href = trim((string) $element->getAttribute('href')); + if (!$this->isAllowedHref($href)) { + $this->unwrapElement($element); + return; + } + + $element->setAttribute('href', $href); + $element->setAttribute('rel', 'noopener noreferrer'); + + if ($this->isExternalHttpUrl($href)) { + $element->setAttribute('target', '_blank'); + } else { + $element->removeAttribute('target'); + } + } + + private function sanitizeImage(DOMElement $element, Media $media): void + { + $src = trim((string) $element->getAttribute('src')); + if (!str_starts_with($src, 'media:')) { + $element->parentNode?->removeChild($element); + return; + } + + $fileName = substr($src, 6); + if ($fileName === '') { + $element->parentNode?->removeChild($element); + return; + } + + $item = $media->findByFileName($fileName); + if ($item === null) { + throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.'); + } + + $alt = trim((string) $element->getAttribute('alt')); + if ($alt === '') { + $alt = (string) ($item['alt'] ?? ''); + } + + $element->setAttribute('src', (string) $item['url']); + $element->setAttribute('alt', $alt); + $element->setAttribute('loading', 'lazy'); + $element->setAttribute('decoding', 'async'); + + $width = (int) ($item['width'] ?? 0); + if ($width > 0) { + $element->setAttribute('width', (string) $width); + } else { + $element->removeAttribute('width'); + } + + $height = (int) ($item['height'] ?? 0); + if ($height > 0) { + $element->setAttribute('height', (string) $height); + } else { + $element->removeAttribute('height'); + } + } + + private function unwrapElement(DOMElement $element): void + { + $parent = $element->parentNode; + if ($parent === null) { + return; + } + + while ($element->firstChild !== null) { + $parent->insertBefore($element->firstChild, $element); + } + + $parent->removeChild($element); + } + + private function isAllowedHref(string $href): bool + { + if ($href == '') { + return false; + } + + if (str_starts_with($href, '#') || str_starts_with($href, '/')) { + return true; + } + + if (preg_match('~^(?:https?://|mailto:)~i', $href)) { + return true; + } + + return !preg_match('~^[a-z][a-z0-9+.-]*:~i', $href); + } + + private function isExternalHttpUrl(string $href): bool + { + return (bool) preg_match('~^https?://~i', $href); } } diff --git a/app/Views/partials/csrf_field.html b/app/Views/partials/csrf_field.html index bf6b374..1e66770 100644 --- a/app/Views/partials/csrf_field.html +++ b/app/Views/partials/csrf_field.html @@ -1 +1 @@ - + diff --git a/app/bootstrap.php b/app/bootstrap.php index a6aac56..93cda47 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -77,7 +77,7 @@ $f3->set('JAR', [ 'samesite' => 'Lax', ]); -new Session(); +new Session(null, 'CSRF'); // ── Template ──────────────────────────────────────────────────────── diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh old mode 100755 new mode 100644