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:
co-authored by
Claude Sonnet 5
parent
e7c6ed7159
commit
a34bcf4159
@@ -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,
|
||||
|
||||
@@ -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("&", "&").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)
|
||||
Reference in New Issue
Block a user