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
@@ -64,6 +64,17 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
svg_markup = str(a.get("svg_markup", "")).strip()
if svg_markup:
# Contenu vectoriel dessiné/collé par le créateur plutôt qu'un
# fichier hébergé — prioritaire sur `src` (voir
# element_kind_labels.element_default_attributes). Nettoyé à
# CHAQUE rendu (jamais seulement à l'écriture) par sanitize_svg_markup,
# même défense en profondeur que html.escape sur les autres kinds.
from .sanitize_svg_markup import sanitize_svg_markup
sanitized = sanitize_svg_markup(svg_markup)
return f'<div class="docImage" data-element-id="{el["id"]}" data-kind="image">{sanitized}</div>'
src = html_lib.escape(str(a.get("src", "")))
alt = html_lib.escape(str(a.get("alt", "")))
if not src:
@@ -93,9 +104,37 @@ def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, lis
label = html_lib.escape(str(a.get("label", "Bouton")))
target = html_lib.escape(str(a.get("target", "")))
target_attr = f' data-target="{target}"' if target else ""
# `data-attachment-filename` sert UNIQUEMENT de marqueur mécanique : un
# fichier a bien été joint (voir routes/document/
# document_element_upload_attachment.py). L'URL de téléchargement
# elle-même n'est jamais construite ici (ce renderer ne connaît pas le
# slug du support) — static/document/js/document-editor.js l'assemble
# à partir de `data-element-id` + FORGE_DOCUMENT.slug, même principe
# que le reste des appels AJAX de l'éditeur.
attachment_filename = html_lib.escape(str(a.get("attachment_filename", "")))
attachment_attr = f' data-attachment-filename="{attachment_filename}"' if attachment_filename else ""
return (
f'<button type="button" class="docButton" data-element-id="{el["id"]}" data-kind="bouton"{target_attr}>'
f"{label}</button>"
f'<button type="button" class="docButton" data-element-id="{el["id"]}" '
f'data-kind="bouton"{target_attr}{attachment_attr}>{label}</button>'
)
def _render_badge(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
content = html_lib.escape(str(el["attributes"].get("content", "")))
return f'<div class="docBadge" data-element-id="{el["id"]}" data-kind="badge">{content}</div>'
def _render_carte(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
label = html_lib.escape(str(a.get("label", "")))
title = html_lib.escape(str(a.get("title", "")))
description = html_lib.escape(str(a.get("description", "")))
return (
f'<div class="docCard" data-element-id="{el["id"]}" data-kind="carte">'
f'<div class="docCardLabel">{label}</div>'
f'<div class="docCardTitle">{title}</div>'
f'<div class="docCardDescription">{description}</div>'
f"</div>"
)
@@ -514,6 +553,8 @@ _RENDERERS = {
"bouton": _render_button,
"liste_puces": _render_list,
"liste_numerotee": _render_list,
"badge": _render_badge,
"carte": _render_carte,
"quiz": _render_quiz,
"association": _render_association,
"memory": _render_memory,
+38 -2
View File
@@ -30,8 +30,25 @@ regroupement à chaque appel.
récursivement.
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel.
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide — OU, si
`attributes["svg_markup"]` est non vide (prioritaire sur `src`), un
`<div>` portant directement ce fragment SVG nettoyé par
`sanitize_svg_markup` (voir `sanitize_svg_markup.py` ci-dessous) : un
contenu vectoriel dessiné/collé par le créateur plutôt qu'un fichier
hébergé, sans aucun style qui lui soit propre.
- **Bouton** : `<button>` avec son `label`, un `data-target` optionnel
(navigation) et un `data-attachment-filename` optionnel — marqueur
mécanique posé quand un fichier a été joint (voir
`routes/document/document_element_upload_attachment.py`), jamais
l'URL de téléchargement elle-même (ce renderer ne connaît pas le slug
du support ; `static/document/js/document-editor.js` l'assemble à
partir de `data-element-id` + `FORGE_DOCUMENT.slug`, même principe que
le reste des appels AJAX de l'éditeur).
- **Étiquette** (`badge`) : `<div>` portant `attributes["content"]`
échappé — contenu pur, aucun attribut de style.
- **Carte** (`carte`) : `<div>` composé de trois blocs enfants
(`label`/`title`/`description`, tous échappés) — contenu pur, aucune
couleur/forme choisie ici (voir `element_kind_labels.md`).
- **Liste à puces/numérotée** (`liste_puces`/`liste_numerotee`) :
`<ul>` ou `<ol>` selon le `kind` (fonction privée `_render_list`,
partagée par les deux) — un `<li>` par entrée de `attributes["items"]`.
@@ -146,3 +163,22 @@ regroupement à chaque appel.
du type (voir `document_engine/labels/element_kind_labels.py`) —
emplacement réservé, formulaire de contenu dédié hors périmètre de
cette passe.
## `sanitize_svg_markup.py` — nettoyage du contenu SVG inline d'une image
### `sanitize_svg_markup(markup: str) -> str`
Nettoie un fragment SVG selon une LISTE BLANCHE de balises/attributs
(`_ALLOWED_TAGS`/`_ALLOWED_ATTRS`, privées) — construit sur
`html.parser.HTMLParser` (tokenizer de balises pur, sans DTD ni
résolution d'entité externe) plutôt qu'un analyseur XML, qui resterait
exposé aux attaques classiques d'entité externe sur une entrée non
fiable. Toute balise absente de la liste blanche (`<script>`,
`<foreignObject>`, `<a>`, `<use>`, `<image>`...) disparaît AVEC son
contenu ; tout attribut absent (`on*`, `style`, `href`/`xlink:href`,
`class`...) disparaît seul, la balise porteuse étant conservée si elle
est autorisée. Appelé à CHAQUE rendu (`_render_image`), jamais
seulement à l'écriture — même défense en profondeur que
`html.escape` sur les autres kinds.
- **Retour** : le fragment SVG nettoyé, sûr à insérer tel quel dans le
HTML rendu.
- **Exceptions** : aucune.
@@ -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)