Ajoute 4 mécanismes moteur manquants pour le thème sécurité incendie : étiquette, carte, image SVG inline, bouton avec pièce jointe

Contenu et mécanisme uniquement, aucun style ajouté (voir consigne du
24/09/2026) : deux nouveaux kinds de contenu (badge/carte, rendu en
div brutes sans CSS), un mode SVG inline pour l'image (svg_markup,
nettoyé par un nouveau sanitizer allow-list avant chaque rendu) et un
fichier téléchargeable joignable à un bouton (upload/download routes,
stockage sous db.support_dir). Le futur système de templates portera
l'habillage visuel de ces éléments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-24 08:12:29 +02:00
co-authored by Claude Sonnet 5
parent e7c6ed7159
commit a34bcf4159
15 changed files with 668 additions and 18 deletions
@@ -0,0 +1,129 @@
"""Nettoyage d'un fragment SVG saisi/collé par le créateur comme contenu
d'une image (voir render_document_element._render_image) — jamais un
rendu direct de `attributes["svg_markup"]`, qui exposerait une injection
XSS triviale (`<script>`, `onload="..."`, `href="javascript:..."`).
Construit sur `html.parser.HTMLParser` (analyseur de balises pur, sans
DTD ni résolution d'entité externe) plutôt que sur un analyseur XML
(`xml.etree.ElementTree`), qui resterait exposé aux attaques classiques
d'entité externe/"milliard de rires" sur une entrée non fiable."""
from html.parser import HTMLParser
_ALLOWED_TAGS = {
"svg",
"path",
"circle",
"rect",
"line",
"polyline",
"polygon",
"ellipse",
"g",
"defs",
"lineargradient",
"radialgradient",
"stop",
"title",
"desc",
}
_ALLOWED_ATTRS = {
"viewbox",
"width",
"height",
"fill",
"stroke",
"stroke-width",
"stroke-linecap",
"stroke-linejoin",
"stroke-dasharray",
"opacity",
"fill-opacity",
"stroke-opacity",
"fill-rule",
"d",
"cx",
"cy",
"r",
"rx",
"ry",
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"points",
"transform",
"offset",
"stop-color",
"stop-opacity",
}
class _SvgSanitizer(HTMLParser):
"""Reconstruit un fragment SVG balise par balise, en ne conservant que
les éléments/attributs de la liste blanche — jamais de liste noire
(une balise/un attribut absent de la liste blanche est TOUJOURS
supprimé, y compris un futur ajout du format SVG qu'on n'aurait pas
anticipé ici)."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.output: list[str] = []
self._skip_depth = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self._open_tag(tag, attrs, self_closing=False)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self._open_tag(tag, attrs, self_closing=True)
def _open_tag(self, tag: str, attrs: list[tuple[str, str | None]], *, self_closing: bool) -> None:
tag = tag.lower()
if self._skip_depth or tag not in _ALLOWED_TAGS:
# Une balise auto-fermante (ex. <script/>) n'aura jamais de
# handle_endtag correspondant : incrémenter ici ferait fuir
# tout le reste du document dans un skip permanent.
if not self_closing:
self._skip_depth += 1
return
kept = [(name.lower(), value) for name, value in attrs if name.lower() in _ALLOWED_ATTRS]
attrs_html = "".join(f' {name}="{_escape_attr(value or "")}"' for name, value in kept)
self.output.append(f"<{tag}{attrs_html}{'/>' if self_closing else '>'}")
def handle_endtag(self, tag: str) -> None:
if self._skip_depth:
self._skip_depth -= 1
return
if tag.lower() in _ALLOWED_TAGS:
self.output.append(f"</{tag.lower()}>")
def handle_data(self, data: str) -> None:
if not self._skip_depth:
self.output.append(_escape_text(data))
def _escape_attr(value: str) -> str:
return value.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
def _escape_text(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def sanitize_svg_markup(markup: str) -> str:
"""Nettoie `markup` selon la liste blanche `_ALLOWED_TAGS`/
`_ALLOWED_ATTRS` — toute balise/attribut absent de ces listes (y
compris `<script>`, `on*`, `style`, `href`/`xlink:href`,
`<foreignObject>`) est supprimé, jamais échappé tel quel.
- **Retour** : le fragment SVG nettoyé, sûr à insérer tel quel dans le
HTML rendu (jamais vide de sens : une balise inconnue disparaît
avec son contenu, une balise connue avec un attribut refusé perd
seulement cet attribut).
- **Exceptions** : aucune."""
sanitizer = _SvgSanitizer()
sanitizer.feed(markup)
sanitizer.close()
return "".join(sanitizer.output)