feat: add sanitize_plaintext for XMPP text sanitization (M10-U3)
Add sanitize_plaintext(text: str) -> str to utils/text.py for preparing XMPP plain-text message bodies from untrusted Pronote/AI content. - Strips HTML tags via BeautifulSoup (html.parser) - Strips C0, DEL, and C1 control characters (preserves \t, \n, \r) - Preserves Unicode including emojis (📌📅📚💬📢) - Idempotent: f(f(x)) == f(x) - Addresses SEC-XMPP-06: XMPP injection hardening 37 unit tests covering HTML, entities, control chars, emojis, idempotence. Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid> Co-authored-by: opencode/coder <coder@agents.invalid>
This commit is contained in:
@@ -10,7 +10,9 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
__all__ = ["normalize_subject"]
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
__all__ = ["normalize_subject", "sanitize_plaintext"]
|
||||||
|
|
||||||
|
|
||||||
def normalize_subject(subject: str) -> str:
|
def normalize_subject(subject: str) -> str:
|
||||||
@@ -30,3 +32,29 @@ def normalize_subject(subject: str) -> str:
|
|||||||
normalized = re.sub(r"[^\w\s]", "", normalized)
|
normalized = re.sub(r"[^\w\s]", "", normalized)
|
||||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||||
return normalized.lower()
|
return normalized.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# Pattern des caractères de contrôle ASCII non imprimables (à l'exception
|
||||||
|
# des tabulations ``\\t``, des sauts de ligne ``\\n`` et des retours chariot ``\\r``).
|
||||||
|
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_plaintext(text: str) -> str:
|
||||||
|
"""Prépare un texte pour le corps de message XMPP en texte brut.
|
||||||
|
|
||||||
|
Supprime les balises HTML (via ``BeautifulSoup`` avec le parseur
|
||||||
|
``html.parser``) puis les caractères de contrôle ASCII non imprimables,
|
||||||
|
à l'exception des tabulations (``\\t``), des sauts de ligne (``\\n``) et
|
||||||
|
des retours chariot (``\\r``). Les caractères Unicode au-delà de ``\\x1f``,
|
||||||
|
notamment les emojis, sont conservés. La transformation est idempotente :
|
||||||
|
appliquée deux fois, elle produit le même résultat qu'appliquée une seule
|
||||||
|
fois. Une chaîne vide donne une chaîne vide.
|
||||||
|
|
||||||
|
:param text: Le texte brut ou HTML à assainir.
|
||||||
|
:return: Le texte assaini, sans balises HTML ni caractères de contrôle.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
# Étape 1 : suppression des balises HTML.
|
||||||
|
plain = BeautifulSoup(text, "html.parser").get_text()
|
||||||
|
# Étape 2 : suppression des caractères de contrôle.
|
||||||
|
return _CONTROL_CHARS_RE.sub("", plain)
|
||||||
|
|||||||
197
tests/unit/test_text_sanitize.py
Normal file
197
tests/unit/test_text_sanitize.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""Tests unitaires pour la fonction sanitize_plaintext dans pronote_sync.utils.text.
|
||||||
|
|
||||||
|
Ce module valide le comportement de sanitize_plaintext qui prépare du texte
|
||||||
|
pour les corps de message XMPP en appliquant plusieurs transformations :
|
||||||
|
- Suppression des balises HTML (via beautifulsoup4)
|
||||||
|
- Suppression des caractères de contrôle ASCII non imprimables
|
||||||
|
- Préservation des emojis autorisés
|
||||||
|
- Idempotence de la fonction
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pronote_sync.utils.text import sanitize_plaintext
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizePlaintext:
|
||||||
|
"""Tests de la fonction sanitize_plaintext."""
|
||||||
|
|
||||||
|
def test_empty_string_returns_empty(self) -> None:
|
||||||
|
"""Test que la chaîne vide retourne une chaîne vide.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("") == ""
|
||||||
|
|
||||||
|
def test_plain_text_unchanged(self) -> None:
|
||||||
|
"""Test qu'un texte simple sans balises ni caractères spéciaux reste inchangé.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("Hello world") == "Hello world"
|
||||||
|
|
||||||
|
def test_html_tags_stripped(self) -> None:
|
||||||
|
"""Test que les balises HTML simples sont supprimées.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("<b>Hello</b> world") == "Hello world"
|
||||||
|
|
||||||
|
def test_nested_html_stripped(self) -> None:
|
||||||
|
"""Test que les balises HTML imbriquées sont supprimées.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("<div><p>Nested</p></div>") == "Nested"
|
||||||
|
|
||||||
|
def test_html_entities_decoded(self) -> None:
|
||||||
|
"""Test que les entités HTML sont décodées.
|
||||||
|
|
||||||
|
La fonction doit décoder les entités HTML comme & en &.
|
||||||
|
Si beautifulsoup4 décode les entités, le résultat attendu est "&".
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
result = sanitize_plaintext("&")
|
||||||
|
# beautifulsoup4 décode les entités par défaut, donc & devient &
|
||||||
|
assert result == "&"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_text,expected",
|
||||||
|
[
|
||||||
|
("Hello\x00\x01\x02world", "Helloworld"),
|
||||||
|
("Hello\x03world", "Helloworld"),
|
||||||
|
("Hello\x04world", "Helloworld"),
|
||||||
|
("Hello\x05world", "Helloworld"),
|
||||||
|
("Hello\x06world", "Helloworld"),
|
||||||
|
("Hello\x07world", "Helloworld"),
|
||||||
|
("Hello\x08world", "Helloworld"),
|
||||||
|
("Hello\x0e\x0fworld", "Helloworld"),
|
||||||
|
("Hello\x10\x11\x12world", "Helloworld"),
|
||||||
|
("Hello\x13\x14\x15\x16\x17world", "Helloworld"),
|
||||||
|
("Hello\x18\x19\x1a\x1b\x1c\x1d\x1e\x1fworld", "Helloworld"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_control_chars_stripped(self, input_text: str, expected: str) -> None:
|
||||||
|
"""Test que les caractères de contrôle ASCII non imprimables sont supprimés.
|
||||||
|
|
||||||
|
Les caractères à supprimer sont : \x00-\x08, \x0b, \x0c, \x0e-\x1f
|
||||||
|
Les caractères à préserver sont : \t, \n, \r
|
||||||
|
|
||||||
|
:param input_text: Texte avec caractères de contrôle
|
||||||
|
:param expected: Texte attendu après nettoyage
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext(input_text) == expected
|
||||||
|
|
||||||
|
def test_tab_preserved(self) -> None:
|
||||||
|
"""Test que la tabulation est préservée.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("Hello\tworld") == "Hello\tworld"
|
||||||
|
|
||||||
|
def test_newline_preserved(self) -> None:
|
||||||
|
"""Test que le saut de ligne est préservé.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("Hello\nworld") == "Hello\nworld"
|
||||||
|
|
||||||
|
def test_carriage_return_preserved(self) -> None:
|
||||||
|
"""Test que le retour chariot est préservé.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("Hello\rworld") == "Hello\rworld"
|
||||||
|
|
||||||
|
def test_vertical_tab_stripped(self) -> None:
|
||||||
|
"""Test que la tabulation verticale est supprimée.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("Hello\x0bworld") == "Helloworld"
|
||||||
|
|
||||||
|
def test_form_feed_stripped(self) -> None:
|
||||||
|
"""Test que le saut de page est supprimé.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("Hello\x0cworld") == "Helloworld"
|
||||||
|
|
||||||
|
def test_emojis_preserved(self) -> None:
|
||||||
|
"""Test que les emojis autorisés sont préservés.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("📌📅📚💬📢") == "📌📅📚💬📢"
|
||||||
|
|
||||||
|
def test_emoji_with_text(self) -> None:
|
||||||
|
"""Test qu'un emoji combiné avec du texte est préservé.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("📌 Devoir: Math") == "📌 Devoir: Math"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"test_input",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"Hello world",
|
||||||
|
"<b>Hello</b>",
|
||||||
|
"Hello\x00world",
|
||||||
|
"📌📅",
|
||||||
|
"&",
|
||||||
|
"<div>Test</div>",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_idempotent(self, test_input: str) -> None:
|
||||||
|
"""Test que la fonction est idempotente.
|
||||||
|
|
||||||
|
Pour tout texte d'entrée x, sanitize_plaintext(sanitize_plaintext(x)) doit
|
||||||
|
être égal à sanitize_plaintext(x).
|
||||||
|
|
||||||
|
:param test_input: Texte à tester
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
first_pass = sanitize_plaintext(test_input)
|
||||||
|
second_pass = sanitize_plaintext(first_pass)
|
||||||
|
assert second_pass == first_pass
|
||||||
|
|
||||||
|
def test_mixed_html_control_emoji(self) -> None:
|
||||||
|
"""Test une combinaison de balises HTML, caractères de contrôle et emojis.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("<b>📌</b>\x00 Hello") == "📌 Hello"
|
||||||
|
|
||||||
|
def test_unicode_text_preserved(self) -> None:
|
||||||
|
"""Test que le texte Unicode avec accents est préservé.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("Café résumé") == "Café résumé"
|
||||||
|
|
||||||
|
def test_del_char_stripped(self) -> None:
|
||||||
|
"""Test que le caractère ASCII DEL (\\x7f) est supprimé.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext("a\x7fb") == "ab"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("c1_char", ["\x80", "\x85", "\x9f"])
|
||||||
|
def test_c1_controls_stripped(self, c1_char: str) -> None:
|
||||||
|
"""Test que les caractères de contrôle C1 (\\x80-\\x9f) sont supprimés.
|
||||||
|
|
||||||
|
:param c1_char: Caractère de contrôle C1 à tester
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert sanitize_plaintext(f"a{c1_char}b") == "ab"
|
||||||
|
|
||||||
|
def test_del_and_c1_idempotent(self) -> None:
|
||||||
|
"""Test que la suppression de DEL et des contrôles C1 est idempotente.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
text = "a\x7f\x80\x9fb"
|
||||||
|
assert sanitize_plaintext(sanitize_plaintext(text)) == sanitize_plaintext(text)
|
||||||
Reference in New Issue
Block a user