From d47f72a2519f1392378abeeaa8062216ac5102ec Mon Sep 17 00:00:00 2001 From: Frank Netscher Date: Tue, 11 Aug 2026 13:36:35 -0500 Subject: [PATCH 1/3] Created view for collecting reports on content-security-policy violations. --- server/conf/nginx/nginx.conf | 11 ++++++++++- server/portal/apps/csp/urls.py | 7 +++++++ server/portal/apps/csp/views.py | 27 +++++++++++++++++++++++++++ server/portal/settings/settings.py | 10 +++++++++- server/portal/urls.py | 6 +++--- server/portal/views/views.py | 24 ++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 server/portal/apps/csp/urls.py create mode 100644 server/portal/apps/csp/views.py diff --git a/server/conf/nginx/nginx.conf b/server/conf/nginx/nginx.conf index e28afdde4c..5d0837a287 100644 --- a/server/conf/nginx/nginx.conf +++ b/server/conf/nginx/nginx.conf @@ -65,11 +65,20 @@ http { ssl_prefer_server_ciphers on; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; - + add_header Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; form-action 'self'; script-src 'self' 'nonce-$cspNonce' https://cdn.jsdelivr.net ${csp_script_extra}; style-src 'self' 'unsafe-inline' 'nonce-$cspNonce' ${csp_style_extra}; font-src 'self' ${csp_font_extra}; img-src 'self' blob: https://cdn.jsdelivr.net ${csp_img_extra}; frame-src 'self' ${csp_frame_extra}; frame-ancestors 'self' https://*.tacc.utexas.edu; connect-src 'self' wss://portal.tacc.utexas.edu https://cdn.jsdelivr.net https://pypi.org https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com https://*.tacc.utexas.edu https://*.tapis.io https://api.github.com ${csp_connect_extra}; report-uri /csp-report/;" always; # gzip on; # gzip_types application/javascript image/* text/css; # gunzip on; + location /csp-report/ { + proxy_pass http://portal_core; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + proxy_read_timeout 600s; + proxy_send_timeout 60s; + } + location / { proxy_pass http://portal_cms; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/server/portal/apps/csp/urls.py b/server/portal/apps/csp/urls.py new file mode 100644 index 0000000000..655fdbc51f --- /dev/null +++ b/server/portal/apps/csp/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from portal.apps.csp import views + + +# urlpatterns = [ +# path("", views.csp_report, name="csp_report"), +# ] diff --git a/server/portal/apps/csp/views.py b/server/portal/apps/csp/views.py new file mode 100644 index 0000000000..520d8a1acd --- /dev/null +++ b/server/portal/apps/csp/views.py @@ -0,0 +1,27 @@ +import json +import logging + +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +logger = logging.getLogger("csp_reports") + + +# @csrf_exempt +# @require_POST +# def csp_report(request): +# try: +# payload = json.loads(request.body.decode("utf-8")) +# except (ValueError, UnicodeDecodeError): +# # Malformed report body — log raw bytes for debugging, don't 500 +# logger.warning("Malformed CSP report body: %r", request.body[:500]) +# return HttpResponse(status=204) + +# # Browsers send either the older `csp-report` wrapper or newer +# # Reporting API format depending on report-uri vs report-to +# report = payload.get("csp-report", payload) + +# logger.warning("CSP violation: %s", json.dumps(report)) + +# return HttpResponse(status=204) diff --git a/server/portal/settings/settings.py b/server/portal/settings/settings.py index d576634e3f..28942b9f05 100644 --- a/server/portal/settings/settings.py +++ b/server/portal/settings/settings.py @@ -352,6 +352,9 @@ def portal_filter(record): 'formatter': 'metrics', 'filters': ['portalFilter'] }, + "csp_file": { + "class": "logging.StreamHandler", + }, }, 'loggers': { 'django': { @@ -381,7 +384,12 @@ def portal_filter(record): 'console', ], 'level': 'INFO' - } + }, + "csp_reports": { + "handlers": ["csp_file"], + "level": "WARNING", + "propagate": False, + }, }, } diff --git a/server/portal/urls.py b/server/portal/urls.py index c7586c36d9..16bb9cbfee 100644 --- a/server/portal/urls.py +++ b/server/portal/urls.py @@ -28,8 +28,8 @@ from django.views.generic.base import TemplateView from django.urls import path, re_path, include from impersonate import views as impersonate_views -from portal.views.views import health_check -from portal.views.views import serve_docs +from portal.views.views import health_check, serve_docs, csp_report + admin.autodiscover() urlpatterns = [ @@ -106,7 +106,7 @@ path('request-access/', include('portal.apps.request_access.urls', namespace='request_access')), path('user-news/', include('portal.apps.news.urls', namespace='news')), path('search/', include('portal.apps.site_search.urls', namespace='site_search')), - + path("csp-report/", csp_report, name="csp-report"), # portal_messages path('api/portal_messages/', include('portal.apps.portal_messages.urls', namespace='portal_messages')), diff --git a/server/portal/views/views.py b/server/portal/views/views.py index 795a481887..63d86b6ec4 100644 --- a/server/portal/views/views.py +++ b/server/portal/views/views.py @@ -4,9 +4,31 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.http import HttpResponse, JsonResponse, Http404 +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +import json logger = logging.getLogger(__name__) +@csrf_exempt +@require_POST +def csp_report(request): + logger = logging.getLogger("csp_reports") + + try: + payload = json.loads(request.body.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + # Malformed report body — log raw bytes for debugging, don't 500 + logger.warning("Malformed CSP report body: %r", request.body[:500]) + return HttpResponse(status=204) + + # Browsers send either the older `csp-report` wrapper or newer + # Reporting API format depending on report-uri vs report-to + report = payload.get("csp-report", payload) + + logger.warning("CSP violation: %s", json.dumps(report)) + + return HttpResponse(status=204) def project_version(request): try: @@ -46,3 +68,5 @@ def serve_docs(request, path): raise Http404("Directory index not found") return serve(request, path, document_root=settings.INTERNAL_DOCS_ROOT) + + From a9313888fa55426e1f7f827655d7a5f2b50ca3a6 Mon Sep 17 00:00:00 2001 From: Frank Netscher Date: Wed, 26 Aug 2026 11:53:46 -0500 Subject: [PATCH 2/3] cleaning up some cruft from the development and testing process. --- server/conf/nginx/nginx.conf | 1 - server/portal/apps/csp/urls.py | 7 ------- server/portal/apps/csp/views.py | 27 --------------------------- server/portal/views/views.py | 5 ----- 4 files changed, 40 deletions(-) delete mode 100644 server/portal/apps/csp/urls.py delete mode 100644 server/portal/apps/csp/views.py diff --git a/server/conf/nginx/nginx.conf b/server/conf/nginx/nginx.conf index 5d0837a287..5247bc5039 100644 --- a/server/conf/nginx/nginx.conf +++ b/server/conf/nginx/nginx.conf @@ -65,7 +65,6 @@ http { ssl_prefer_server_ciphers on; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; - add_header Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; form-action 'self'; script-src 'self' 'nonce-$cspNonce' https://cdn.jsdelivr.net ${csp_script_extra}; style-src 'self' 'unsafe-inline' 'nonce-$cspNonce' ${csp_style_extra}; font-src 'self' ${csp_font_extra}; img-src 'self' blob: https://cdn.jsdelivr.net ${csp_img_extra}; frame-src 'self' ${csp_frame_extra}; frame-ancestors 'self' https://*.tacc.utexas.edu; connect-src 'self' wss://portal.tacc.utexas.edu https://cdn.jsdelivr.net https://pypi.org https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com https://*.tacc.utexas.edu https://*.tapis.io https://api.github.com ${csp_connect_extra}; report-uri /csp-report/;" always; # gzip on; # gzip_types application/javascript image/* text/css; # gunzip on; diff --git a/server/portal/apps/csp/urls.py b/server/portal/apps/csp/urls.py deleted file mode 100644 index 655fdbc51f..0000000000 --- a/server/portal/apps/csp/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path -from portal.apps.csp import views - - -# urlpatterns = [ -# path("", views.csp_report, name="csp_report"), -# ] diff --git a/server/portal/apps/csp/views.py b/server/portal/apps/csp/views.py deleted file mode 100644 index 520d8a1acd..0000000000 --- a/server/portal/apps/csp/views.py +++ /dev/null @@ -1,27 +0,0 @@ -import json -import logging - -from django.http import HttpResponse -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_POST - -logger = logging.getLogger("csp_reports") - - -# @csrf_exempt -# @require_POST -# def csp_report(request): -# try: -# payload = json.loads(request.body.decode("utf-8")) -# except (ValueError, UnicodeDecodeError): -# # Malformed report body — log raw bytes for debugging, don't 500 -# logger.warning("Malformed CSP report body: %r", request.body[:500]) -# return HttpResponse(status=204) - -# # Browsers send either the older `csp-report` wrapper or newer -# # Reporting API format depending on report-uri vs report-to -# report = payload.get("csp-report", payload) - -# logger.warning("CSP violation: %s", json.dumps(report)) - -# return HttpResponse(status=204) diff --git a/server/portal/views/views.py b/server/portal/views/views.py index 63d86b6ec4..783f1adc1e 100644 --- a/server/portal/views/views.py +++ b/server/portal/views/views.py @@ -18,12 +18,9 @@ def csp_report(request): try: payload = json.loads(request.body.decode("utf-8")) except (ValueError, UnicodeDecodeError): - # Malformed report body — log raw bytes for debugging, don't 500 logger.warning("Malformed CSP report body: %r", request.body[:500]) return HttpResponse(status=204) - # Browsers send either the older `csp-report` wrapper or newer - # Reporting API format depending on report-uri vs report-to report = payload.get("csp-report", payload) logger.warning("CSP violation: %s", json.dumps(report)) @@ -68,5 +65,3 @@ def serve_docs(request, path): raise Http404("Directory index not found") return serve(request, path, document_root=settings.INTERNAL_DOCS_ROOT) - - From f28e11d9f225e6f870df3769fea2be2341127a83 Mon Sep 17 00:00:00 2001 From: fnets Date: Wed, 26 Aug 2026 15:20:43 -0500 Subject: [PATCH 3/3] Removing cruft --- server/conf/nginx/nginx.conf | 9 --------- 1 file changed, 9 deletions(-) diff --git a/server/conf/nginx/nginx.conf b/server/conf/nginx/nginx.conf index 5247bc5039..1b280da101 100644 --- a/server/conf/nginx/nginx.conf +++ b/server/conf/nginx/nginx.conf @@ -69,15 +69,6 @@ http { # gzip_types application/javascript image/* text/css; # gunzip on; - location /csp-report/ { - proxy_pass http://portal_core; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_redirect off; - proxy_read_timeout 600s; - proxy_send_timeout 60s; - } - location / { proxy_pass http://portal_cms; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;