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 "![]()