Skip to content

Commit 32cb1ff

Browse files
claudefrousselet
authored andcommitted
Fix Trust Center review findings (XSS, header injection, IP, gated link)
A max-effort self-review of the Trust Center surfaced several issues, fixed here: - Stored XSS via custom CSS: clean_css() did a single regex pass, defeated by split-token recreation (e.g. `</sty</stylele>` -> `</style>`). Custom CSS is now served from a dedicated text/css endpoint (/trust/custom.css) via <link>, so there is no HTML context to break out of; clean_css() also iterates to a fixed point as defence in depth. Drops the now-unused safe_css template filter. - HTTP 500 on the public request endpoint: the client-controlled X-Forwarded-For was stored into a GenericIPAddressField unvalidated, crashing on the Postgres inet column. _client_ip() now validates via ipaddress and returns None on bad input. - Content-Disposition header injection: the download filename was interpolated unescaped; a double quote broke the header and CRLF caused a 500. Both public and gated downloads now use a sanitized, RFC 5987-encoded disposition. - Gated link outliving takedown: the gated download re-checks the document is still published(), so unpublishing/archiving revokes outstanding approved links. - Approval now warns when the download-link email could not be sent instead of always reporting success. - Removes the unused "trust_request" throttle rate and its misleading comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e939b84 commit 32cb1ff

9 files changed

Lines changed: 126 additions & 29 deletions

File tree

core/settings.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,12 +215,11 @@ def _detect_version():
215215
"DEFAULT_RENDERER_CLASSES": [
216216
"context.api.renderers.StandardJSONRenderer",
217217
],
218-
# Throttling is opt-in per view (public Trust Center endpoints only); the
219-
# authenticated app is not throttled. Rates are consumed by AnonRateThrottle
220-
# (public reads) and the "trust_request" ScopedRateThrottle (gated requests).
218+
# Throttling is opt-in per view (public Trust Center read endpoints only,
219+
# via AnonRateThrottle); the authenticated app is not throttled. The public
220+
# gated-request form is a Django view rate-limited separately in its view.
221221
"DEFAULT_THROTTLE_RATES": {
222222
"anon": "120/hour",
223-
"trust_request": "5/hour",
224223
},
225224
}
226225

trust_center/admin_views.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,8 +302,13 @@ def post(self, request, pk):
302302
if target == DocumentRequestState.APPROVED:
303303
token = obj.issue_download_link(dj_settings.TRUST_CENTER_DOWNLOAD_TTL)
304304
url = reverse("trust_center:gated-download", kwargs={"token": token})
305-
send_gated_link_email(obj, url)
306-
messages.success(request, _("Request approved and a download link was emailed."))
305+
if send_gated_link_email(obj, url):
306+
messages.success(request, _("Request approved and a download link was emailed."))
307+
else:
308+
messages.warning(
309+
request,
310+
_("Request approved, but the download link email could not be sent."),
311+
)
307312
else:
308313
messages.success(request, _("Request updated."))
309314
return redirect("trust_center_manage:request-detail", pk=pk)

trust_center/sanitizers.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,10 +371,21 @@ def clean_html(markup: str) -> str:
371371

372372

373373
def clean_css(css: str) -> str:
374-
"""Strip style-tag breakout and active-content constructs from custom CSS."""
374+
"""Strip active-content / breakout constructs from custom CSS.
375+
376+
Applied to a fixed point so a split-token payload (e.g. ``<scr<script>ipt>``)
377+
cannot reconstruct a stripped token after a single pass. Custom CSS is served
378+
from a dedicated ``text/css`` endpoint (not inlined in a ``<style>``), so this
379+
is defence in depth rather than the sole boundary.
380+
"""
375381
if not css:
376382
return ""
377-
return _CSS_DANGEROUS.sub("", css)
383+
previous = None
384+
out = css
385+
while previous != out:
386+
previous = out
387+
out = _CSS_DANGEROUS.sub("", out)
388+
return out
378389

379390

380391
# --- Logo rendering ---------------------------------------------------------

trust_center/templates/trust_center/public_base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@
125125
.tc-doc-icon { color: var(--accent); font-size: 1.15rem; flex-shrink: 0; }
126126
.badge-soft { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
127127
</style>
128-
{% if tc.custom_css %}<style>{{ tc.custom_css|safe_css }}</style>{% endif %}
128+
{% if tc.custom_css %}<link rel="stylesheet" href="{% url 'trust_center:custom-css' %}">{% endif %}
129129
{% block extra_head %}{% endblock %}
130130
</head>
131131
<body>

trust_center/templatetags/trust_center_tags.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from django.utils.safestring import mark_safe
33

44
from trust_center.sanitizers import (
5-
clean_css,
65
clean_html,
76
clean_svg,
87
logo_href,
@@ -39,9 +38,3 @@ def safe_svg(markup):
3938
def safe_html(markup):
4039
"""Sanitize admin-authored rich text (Jodit) for safe public rendering."""
4140
return mark_safe(clean_html(markup or "")) # noqa: S308 - sanitized by clean_html
42-
43-
44-
@register.filter(name="safe_css")
45-
def safe_css(css):
46-
"""Sanitize operator-supplied custom CSS for inline <style> injection."""
47-
return mark_safe(clean_css(css or "")) # noqa: S308 - sanitized by clean_css

trust_center/tests/test_branding.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,15 @@ def test_landing_shows_document_type_icon():
115115
# --- Custom CSS -------------------------------------------------------------
116116

117117

118-
def test_custom_css_injected_and_sanitized():
119-
_enable(custom_css="body { background: rebeccapurple; } </style><script>alert(1)</script>")
120-
content = Client().get("/trust/").content
121-
assert b"rebeccapurple" in content # custom CSS present
122-
assert b"<script>alert(1)" not in content # breakout neutralized
118+
def test_custom_css_served_from_stylesheet_endpoint():
119+
_enable(custom_css="body { background: rebeccapurple; }")
120+
page = Client().get("/trust/").content.decode()
121+
assert "/trust/custom.css" in page # linked, not inlined
122+
assert "<style>body { background: rebeccapurple" not in page
123+
css = Client().get("/trust/custom.css")
124+
assert css.status_code == 200
125+
assert css["Content-Type"].startswith("text/css")
126+
assert b"rebeccapurple" in css.content
123127

124128

125129
def test_clean_css_strips_dangerous_constructs():
@@ -133,6 +137,43 @@ def test_clean_css_strips_dangerous_constructs():
133137
assert "a{}" in out # benign CSS preserved
134138

135139

140+
def test_clean_css_defeats_split_token_recreation():
141+
out = clean_css("</sty</stylele><scr<scriptipt>x</scr<scriptipt>@imp@importort")
142+
lowered = out.lower()
143+
assert "</style" not in lowered
144+
assert "<script" not in lowered
145+
assert "@import" not in lowered
146+
147+
148+
def test_client_ip_validates_x_forwarded_for():
149+
from django.test import RequestFactory
150+
151+
from trust_center.views import _client_ip
152+
153+
rf = RequestFactory()
154+
assert _client_ip(rf.get("/", HTTP_X_FORWARDED_FOR="not-an-ip")) is None
155+
assert _client_ip(rf.get("/", HTTP_X_FORWARDED_FOR="203.0.113.7")) == "203.0.113.7"
156+
157+
158+
def test_public_download_filename_header_is_safe():
159+
from trust_center.tests.factories import TrustCenterDocumentFactory, publish
160+
161+
_enable()
162+
doc = publish(TrustCenterDocumentFactory(file_name='evil".pdf'))
163+
resp = Client().get(f"/trust/documents/{doc.id}/download/")
164+
assert resp.status_code == 200
165+
assert 'filename="evil.pdf"' in resp["Content-Disposition"] # quote stripped
166+
167+
168+
def test_public_download_filename_crlf_does_not_500():
169+
from trust_center.tests.factories import TrustCenterDocumentFactory, publish
170+
171+
_enable()
172+
doc = publish(TrustCenterDocumentFactory(file_name="a\r\nb.pdf"))
173+
resp = Client().get(f"/trust/documents/{doc.id}/download/")
174+
assert resp.status_code == 200
175+
176+
136177
# --- CSS upload -------------------------------------------------------------
137178

138179

trust_center/tests/test_gated.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,18 @@ def test_download_rejects_expired_token():
145145
assert resp.status_code == 410
146146

147147

148+
def test_download_blocked_after_document_unpublished():
149+
_enable()
150+
req = DocumentRequestFactory(workflow_state=DocumentRequestState.APPROVED)
151+
publish(req.document)
152+
token = req.make_download_token()
153+
assert Client().get(f"/trust/documents/download/{token}/").status_code == 200
154+
# Taking the document down revokes outstanding approved links too.
155+
req.document.workflow_state = "draft"
156+
req.document.save()
157+
assert Client().get(f"/trust/documents/download/{token}/").status_code == 404
158+
159+
148160
def test_download_rejected_after_revoke():
149161
_enable()
150162
req = DocumentRequestFactory(workflow_state=DocumentRequestState.APPROVED)

trust_center/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
urlpatterns = [
88
path("", views.TrustCenterLandingView.as_view(), name="landing"),
9+
path("custom.css", views.TrustCenterCustomCSSView.as_view(), name="custom-css"),
910
path(
1011
"documents/<uuid:pk>/download/",
1112
views.TrustCenterPublicDocumentDownloadView.as_view(),

trust_center/views.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
global ``TrustCenterSettings.is_published`` kill switch 404s everything when off.
66
"""
77

8+
import ipaddress
9+
from urllib.parse import quote
10+
811
from django.conf import settings
912
from django.core import signing
1013
from django.core.cache import cache
@@ -28,13 +31,30 @@
2831
TrustCenterSettings,
2932
TrustCenterSubprocessor,
3033
)
34+
from trust_center.sanitizers import clean_css
3135

3236

3337
def _client_ip(request):
34-
xff = request.META.get("HTTP_X_FORWARDED_FOR")
35-
if xff:
36-
return xff.split(",")[0].strip()
37-
return request.META.get("REMOTE_ADDR")
38+
"""Return a valid client IP string, or None.
39+
40+
X-Forwarded-For is client-controlled, so the value is validated before it
41+
reaches the GenericIPAddressField (an ``inet`` column on PostgreSQL rejects
42+
a malformed value with a hard error).
43+
"""
44+
xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
45+
candidate = (xff.split(",")[0].strip() if xff else "") or request.META.get("REMOTE_ADDR")
46+
if not candidate:
47+
return None
48+
try:
49+
return str(ipaddress.ip_address(candidate))
50+
except ValueError:
51+
return None
52+
53+
54+
def _attachment_disposition(filename):
55+
"""Build a safe Content-Disposition value (no header injection / breakout)."""
56+
safe = "".join(ch for ch in (filename or "") if ch not in '"\\\r\n').strip() or "document"
57+
return f"attachment; filename=\"{safe}\"; filename*=UTF-8''{quote(safe)}"
3858

3959

4060
def _rate_ok(key, limit, window):
@@ -84,11 +104,22 @@ def get(self, request, pk):
84104
resp = HttpResponse(
85105
data, content_type=doc.content_type or "application/octet-stream"
86106
)
87-
filename = doc.effective_file_name or "document"
88-
resp["Content-Disposition"] = f'attachment; filename="{filename}"'
107+
resp["Content-Disposition"] = _attachment_disposition(doc.effective_file_name)
89108
return resp
90109

91110

111+
class TrustCenterCustomCSSView(View):
112+
"""Serve the operator-supplied custom CSS as a standalone stylesheet.
113+
114+
Served with ``text/css`` (not inlined in a <style> element) so there is no
115+
HTML context to break out of; ``clean_css`` is applied as defence in depth.
116+
"""
117+
118+
def get(self, request):
119+
css = clean_css(TrustCenterSettings.get().custom_css or "")
120+
return HttpResponse(css, content_type="text/css; charset=utf-8")
121+
122+
92123
class DocumentRequestCreateView(FormView):
93124
"""Public request form for a GATED document (no authentication)."""
94125

@@ -183,6 +214,11 @@ def get(self, request, token):
183214
if req is None or not req.is_granted:
184215
raise Http404()
185216

217+
# The document must still be published (an admin unpublishing or
218+
# archiving it revokes outstanding approved links too).
219+
if not TrustCenterDocument.objects.published().filter(pk=req.document_id).exists():
220+
raise Http404()
221+
186222
data = req.document.get_file_bytes()
187223
if not data:
188224
raise Http404()
@@ -193,6 +229,5 @@ def get(self, request, token):
193229
resp = HttpResponse(
194230
data, content_type=req.document.content_type or "application/octet-stream"
195231
)
196-
filename = req.document.effective_file_name or "document"
197-
resp["Content-Disposition"] = f'attachment; filename="{filename}"'
232+
resp["Content-Disposition"] = _attachment_disposition(req.document.effective_file_name)
198233
return resp

0 commit comments

Comments
 (0)