fix(security): escape user input in validation error HTML

format_validation_errors interpolated Pydantic error messages directly into
HTML. Some messages echo user input (e.g. "Invalid group name '<name>'"), so
a crafted group name was reflected as raw HTML — a stored/reflected XSS.

HTML-escape each formatted message before interpolation.

Refs: porchlight-due

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-04 10:23:32 +02:00
parent 437ad59658
commit c52778326e
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
2 changed files with 16 additions and 1 deletions

View file

@ -1,3 +1,4 @@
import html
import re import re
from typing import Annotated from typing import Annotated
from urllib.parse import urlparse from urllib.parse import urlparse
@ -166,7 +167,9 @@ def format_validation_errors(exc: ValidationError) -> str:
display_msg = f"{label}: {raw}" display_msg = f"{label}: {raw}"
else: else:
display_msg = f"{label}: {msg}" 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: if len(messages) == 1:
return f'<div role="alert">{messages[0]}</div>' return f'<div role="alert">{messages[0]}</div>'

View file

@ -292,3 +292,15 @@ class TestFormatValidationErrors:
result = format_validation_errors(exc) result = format_validation_errors(exc)
assert "Picture URL must be a valid HTTP or HTTPS URL" in result assert "Picture URL must be a valid HTTP or HTTPS URL" in result
assert "Value error" not 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 = "<img src=x onerror=alert(1)>"
try:
GroupListInput(groups=payload)
except ValidationError as exc:
result = format_validation_errors(exc)
assert payload not in result
assert "&lt;img src=x onerror=alert(1)&gt;" in result
assert "<img" not in result