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>
130 lines
4.1 KiB
Python
130 lines
4.1 KiB
Python
"""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("&", "&").replace('"', """).replace("<", "<").replace(">", ">")
|
|
|
|
|
|
def _escape_text(value: str) -> str:
|
|
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
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)
|