diff --git a/src/porchlight/validation.py b/src/porchlight/validation.py index 5d3b48c..e675e0b 100644 --- a/src/porchlight/validation.py +++ b/src/porchlight/validation.py @@ -1,3 +1,4 @@ +import html import re from typing import Annotated from urllib.parse import urlparse @@ -166,7 +167,9 @@ def format_validation_errors(exc: ValidationError) -> str: display_msg = f"{label}: {raw}" else: display_msg = f"{label}: {msg}" - messages.append(display_msg) + # Escape: messages can echo user input (e.g. an invalid group name), + # and the result is interpolated into HTML below. + messages.append(html.escape(display_msg)) if len(messages) == 1: return f'
{messages[0]}
' diff --git a/tests/test_validation.py b/tests/test_validation.py index 2be1afc..93bf2ea 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -292,3 +292,15 @@ class TestFormatValidationErrors: result = format_validation_errors(exc) assert "Picture URL must be a valid HTTP or HTTPS URL" in result assert "Value error" not in result + + def test_escapes_user_input_in_error_message(self) -> None: + # A validation error message that echoes user input (e.g. an invalid + # group name) must not emit raw HTML — otherwise it is reflected XSS. + payload = "" + try: + GroupListInput(groups=payload) + except ValidationError as exc: + result = format_validation_errors(exc) + assert payload not in result + assert "<img src=x onerror=alert(1)>" in result + assert "