Merge branch 'csp-refactor' into 'master'

CSP refactor

See merge request pretix/pretix!35
This commit is contained in:
Raphael Michel
2026-07-03 10:33:35 +02:00
2 changed files with 65 additions and 36 deletions
+43 -36
View File
@@ -229,8 +229,8 @@ def _parse_csp(header):
VALID_CSP_DIRECTIVES = [
"child-src", "connect-src", "default-src", "fenced-frame-src", "font-src", "form-action", "frame-src", "img-src",
"manifest-src", "media-src", "object-src", "prefetch-src", "script-src", "script-src-elem", "script-src-attr",
"style-src", "style-src-elem", "style-src-attr", "worker-src",
"manifest-src", "media-src", "object-src", "prefetch-src", "report-uri", "script-src", "script-src-elem",
"script-src-attr", "style-src", "style-src-elem", "style-src-attr", "worker-src",
]
CSP_ILLEGAL_CHARS = re.compile(r'[\s,;]')
@@ -266,21 +266,7 @@ def _merge_csp(a, b):
class SecurityMiddleware(MiddlewareMixin):
CSP_EXEMPT = (
'/api/v1/docs/',
)
def process_response(self, request, resp):
def nested_dict_values(d):
for v in d.values():
if isinstance(v, dict):
yield from nested_dict_values(v)
else:
if isinstance(v, str):
yield v
url = resolve(request.path_info)
if settings.DEBUG and resp.status_code >= 400:
# Don't use CSP on debug error page as it breaks of Django's fancy error
# pages
@@ -291,18 +277,15 @@ class SecurityMiddleware(MiddlewareMixin):
# https://github.com/pretix/pretix/issues/765
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
img_src = []
gs = global_settings_object(request)
if gs.settings.leaflet_tiles:
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
if not getattr(resp, '_csp_ignore', False):
resp['Content-Security-Policy'] = _render_csp(self._build_csp(request, resp))
elif 'Content-Security-Policy' in resp:
del resp['Content-Security-Policy']
font_src = set()
if hasattr(request, 'event'):
for font in get_fonts(request.event, pdf_support_required=False).values():
for path in list(nested_dict_values(font)):
font_location = urlparse(path)
if font_location.scheme and font_location.netloc:
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
return resp
def _build_csp(self, request, resp):
url = resolve(request.path_info)
h = {
'default-src': ["{static}"],
@@ -311,8 +294,8 @@ class SecurityMiddleware(MiddlewareMixin):
'frame-src': ['{static}'],
'style-src': ["{static}", "{media}"],
'connect-src': ["{dynamic}", "{media}"],
'img-src': ["{static}", "{media}", "data:"] + img_src,
'font-src': ["{static}"] + list(font_src),
'img-src': ["{static}", "{media}", "data:"],
'font-src': ["{static}"],
'media-src': ["{static}", "data:"],
# form-action is not only used to match on form actions, but also on URLs
# form-actions redirect to. In the context of e.g. payment providers or
@@ -321,6 +304,13 @@ class SecurityMiddleware(MiddlewareMixin):
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
}
gs = global_settings_object(request)
if gs.settings.leaflet_tiles:
h['img-src'].append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
if hasattr(request, 'event'):
h['font-src'] += list(self._get_font_origins(request.event))
if settings.VITE_DEV_MODE:
h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"]
h['style-src'] += ["'unsafe-inline'"]
@@ -332,6 +322,7 @@ class SecurityMiddleware(MiddlewareMixin):
if not settings.VITE_DEV_MODE:
# can't have 'unsafe-inline' and nonce at the same time
h['style-src'].append(nonce)
# Only include pay.google.com for wallet detection purposes on the Payment selection page
if (
url.url_name == "event.order.pay.change" or
@@ -340,10 +331,13 @@ class SecurityMiddleware(MiddlewareMixin):
h['script-src'].append('https://pay.google.com')
h['frame-src'].append('https://pay.google.com')
h['connect-src'].append('https://google.com/pay')
if settings.LOG_CSP:
h['report-uri'] = ["/csp_report/"]
if 'Content-Security-Policy' in resp:
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
if settings.CSP_ADDITIONAL_HEADER:
_merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER))
@@ -375,14 +369,27 @@ class SecurityMiddleware(MiddlewareMixin):
domain = '%s:%d' % (domain, siteurlsplit.port)
placeholders["{dynamic}"].append(domain)
if request.path not in self.CSP_EXEMPT and not getattr(resp, '_csp_ignore', False):
for k, v in h.items():
h[k] = sorted(set(result for part in v for result in placeholders.get(part, [part])))
resp['Content-Security-Policy'] = _render_csp(h)
elif 'Content-Security-Policy' in resp:
del resp['Content-Security-Policy']
for k, v in h.items():
h[k] = sorted(set(result for part in v for result in placeholders.get(part, [part])))
return resp
return h
def _get_font_origins(self, event):
def nested_dict_values(d):
for v in d.values():
if isinstance(v, dict):
yield from nested_dict_values(v)
else:
if isinstance(v, str):
yield v
font_src = set()
for font in get_fonts(event, pdf_support_required=False).values():
for path in list(nested_dict_values(font)):
font_location = urlparse(path)
if font_location.scheme and font_location.netloc:
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
return font_src
class RejectInvalidInputMiddleware(MiddlewareMixin):
+22
View File
@@ -170,3 +170,25 @@ def test_merge_csp():
assert _render_csp(h) == (
"default-src 'self'; script-src 'self'; style-src 'self' 'self' 'unsafe-inline'; form-action 'self' https:; connect-src 'self' 'self' https://example.com"
)
def test_roundtrip_csp():
from pretix.base.middleware import _parse_csp, _merge_csp, _render_csp
prod_csp = "default-src 'self' https://pretix.eu https://static.pretix.cloud; script-src 'self' 'sha256-+tmFggeXIPOAC2UgcQ3LW/gPHTkwyWg3/D6FOJ5BHGo=' 'unsafe-eval' https://matomo.rami.io https://pretix.eu https://static.pretix.cloud https://support.rami.io; object-src 'none'; frame-src 'self' https://matomo.rami.io https://pretix.eu https://static.pretix.cloud https://support.rami.io https://www.youtube-nocookie.com; style-src 'self' 'unsafe-inline' data: https://cdn.pretix.cloud https://pretix.eu https://static.pretix…rt.rami.io; connect-src 'self' https://cdn.pretix.cloud https://matomo.rami.io https://pretix.eu https://static.pretix.cloud https://support.rami.io ws://support.rami.io; img-src 'self' data: https://cdn.pretix.cloud https://matomo.rami.io https://pretix.eu https://static.pretix.cloud https://support.rami.io; font-src 'self' https://pretix.eu https://static.pretix.cloud; media-src 'self' data: https://cdn.pretix.cloud https://pretix.eu https://static.pretix.cloud; form-action 'self' https: https://pretix.eu"
h = _parse_csp(prod_csp)
_merge_csp(h, _parse_csp(prod_csp))
assert _render_csp(h) == prod_csp
def test_sanitize_csp():
from pretix.base.middleware import _render_csp
h = {
'style-src': ["'self'", "https://example.com", "https://example.org https://attack.example.net", "https://\fexample.org",
"https://\texample.org", "https://example.org;script-src https://example.org", ],
'script-src': ["'self'"],
}
assert _render_csp(h) == (
"style-src 'self' https://example.com; script-src 'self'"
)