"""Sprint S1.3 — XSS dans le rapport HTML généré.
Vérifie que tout contenu utilisateur (corpus_name, sentences narratives,
nom de moteur) est échappé HTML dans le rapport produit par
``picarones.reports.html.ReportGenerator``.
Bandit B701 (CWE-94) avait flaggé ``Environment(autoescape=False)`` —
ce test concrétise l'attaque que l'audit décrivait : un corpus nommé
```` doit être inerte dans le HTML.
"""
from __future__ import annotations
from pathlib import Path
from picarones.evaluation.benchmark_result import (
BenchmarkResult,
DocumentResult,
EngineReport,
)
def _make_minimal_benchmark(
corpus_name: str,
engine_name: str = "tesseract",
) -> BenchmarkResult:
"""Construit un BenchmarkResult minimal valide avec le ``corpus_name`` fourni."""
from picarones.evaluation.metric_result import MetricsResult
metrics = MetricsResult(cer=0.0, wer=0.0, mer=0.0, wil=0.0)
doc = DocumentResult(
doc_id="doc01",
image_path="doc01.png",
ground_truth="Bonjour",
hypothesis="Bonjour",
metrics=metrics,
duration_seconds=0.1,
)
engine = EngineReport(
engine_name=engine_name,
engine_version="5.3.0",
engine_config={},
document_results=[doc],
)
return BenchmarkResult(
corpus_name=corpus_name,
corpus_source=None,
document_count=1,
engine_reports=[engine],
)
# ──────────────────────────────────────────────────────────────────────
# 1. corpus_name avec script tag → doit être échappé dans
# ──────────────────────────────────────────────────────────────────────
class TestCorpusNameXSS:
"""Le ``corpus_name`` est injecté dans ```` de
``base.html.j2``. Sans échappement, un nom malicieux compromet
tout rapport partagé."""
def test_script_tag_in_corpus_name_is_escaped(self, tmp_path: Path) -> None:
from picarones.reports.html import ReportGenerator
bench = _make_minimal_benchmark(
corpus_name="",
)
gen = ReportGenerator(bench)
out = tmp_path / "report.html"
gen.generate(out)
html = out.read_text()
# Le tag ne doit PAS apparaître au milieu du HTML
# (autre que celui légitime à la fin du ) au point
# d'attacher un " not in html, (
"XSS confirmé : le script malicieux est exécutable dans le rapport.\n"
"Causes possibles : autoescape=False dans Jinja2 + corpus_name "
"non filtré."
)
# Forme correcte : caractères dangereux échappés.
assert "<script>alert(" in html or "<script>" in html.lower(), (
"Le ``<`` dans corpus_name doit être échappé en ``<``."
)
def test_html_attribute_injection_in_corpus_name(self, tmp_path: Path) -> None:
"""Cas d'attaque attribute-based : ``" onerror=alert(1)``
peut casser une balise si corpus_name est utilisée dans
un attribut."""
from picarones.reports.html import ReportGenerator
bench = _make_minimal_benchmark(
corpus_name='" onerror="alert(1)" foo="',
)
gen = ReportGenerator(bench)
out = tmp_path / "report.html"
gen.generate(out)
html = out.read_text()
# Le caractère ``"`` doit être échappé en ``"`` ou
# ``"`` partout où corpus_name est rendu. Aucune
# attribute injection ne doit subsister.
assert ' onerror="alert(' not in html, (
"Attribute injection : un guillemet non échappé permet "
"d'injecter onerror=."
)
def test_corpus_name_with_unicode_renders_correctly(
self, tmp_path: Path,
) -> None:
"""Corollaire — vérifie qu'un nom Unicode légitime
(``Manuscrit médiéval — chartes XIII°``) reste lisible
après échappement."""
from picarones.reports.html import ReportGenerator
bench = _make_minimal_benchmark(
corpus_name="Manuscrit médiéval — chartes XIII°",
)
gen = ReportGenerator(bench)
out = tmp_path / "report.html"
gen.generate(out)
html = out.read_text()
# Caractères Unicode safe doivent rester intacts.
assert "Manuscrit médiéval" in html
assert "XIII°" in html
# ──────────────────────────────────────────────────────────────────────
# 2. Engine name (moteur OCR) avec injection
# ──────────────────────────────────────────────────────────────────────
class TestEngineNameXSS:
"""Le nom de moteur peut venir d'un import HuggingFace ou d'une
config utilisateur. Doit être échappé dans tous les renderers
qui l'affichent."""
def test_engine_name_with_script_is_escaped(self, tmp_path: Path) -> None:
from picarones.reports.html import ReportGenerator
bench = _make_minimal_benchmark(
corpus_name="test",
engine_name="",
)
gen = ReportGenerator(bench)
out = tmp_path / "report.html"
gen.generate(out)
html = out.read_text()
assert "" not in html, (
"Engine name XSS : un nom de moteur malicieux est exécuté."
)
# ──────────────────────────────────────────────────────────────────────
# 3. Bandit B701 ne doit plus signaler autoescape=False
# ──────────────────────────────────────────────────────────────────────
class TestJinja2EnvIsAutoescaped:
"""Garde-fou contre la régression : ``_build_jinja_env`` doit
retourner un Environment avec autoescape activé pour les
extensions HTML/J2."""
def test_env_has_autoescape_enabled_for_html(self) -> None:
from picarones.reports.html.generator import _build_jinja_env
env = _build_jinja_env()
# autoescape doit être un Callable (select_autoescape) ou True,
# pas False ni None.
autoescape = env.autoescape
assert autoescape, (
f"Jinja2 Environment.autoescape={autoescape!r} — XSS exposé."
)
# Si c'est une fonction (select_autoescape), elle doit
# retourner True pour les HTML/J2.
if callable(autoescape):
assert autoescape("base.html.j2"), (
"select_autoescape doit activer l'échappement pour .j2"
)
assert autoescape("any.html"), (
"select_autoescape doit activer l'échappement pour .html"
)