From 6f44461c284521591478891efb4b7af3d27a67ed Mon Sep 17 00:00:00 2001 From: catborise Date: Mon, 19 Oct 2026 15:22:01 +0300 Subject: [PATCH] feat(security): every page carries a Content-Security-Policy that needs no template change A small middleware sends object-src 'none', base-uri 'self', form-action 'self' and frame-ancestors 'none': no plugin content, no injected redirecting relative URLs, no form posting to another origin, no framing. script-src and style-src are left out: the templates still hold inline scripts, event handlers and javascript: URLs, which an enforced script policy would block. The middleware is added to the settings template, which the update steps and each Docker container rebuild settings.py from. --- README.md | 2 ++ webvirtcloud/middleware.py | 12 ++++++++++++ webvirtcloud/settings.py.template | 1 + webvirtcloud/tests/test_csp.py | 15 +++++++++++++++ 4 files changed, 30 insertions(+) create mode 100644 webvirtcloud/tests/test_csp.py diff --git a/README.md b/README.md index b61d7d36..974104f4 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,8 @@ For a panel served over HTTPS: TLS can end at the bundled nginx or at a proxy in front of it; such a proxy must send `X-Forwarded-Proto: https`. Failed logins are counted per client address, which the bundled nginx passes in `X-Real-IP`. A proxy that replaces the bundled nginx must set `X-Real-IP` itself, and its address must be in `LOGIN_TRUSTED_PROXIES` in `settings.py`. With a proxy in front of the bundled nginx, configure nginx's `set_real_ip_from` / `real_ip_header` for that proxy, or all clients share its address. +Every page is sent with `X-Frame-Options: DENY` and `Content-Security-Policy: object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'`: the panel cannot be shown in a frame, and its forms post only to its own origin. + ### Cloud-init datasource WebVirtCloud serves cloud-init metadata (root SSH keys and hostname) to guest instances. The host in the URL must be in `ALLOWED_HOSTS`: diff --git a/webvirtcloud/middleware.py b/webvirtcloud/middleware.py index 5e30eaf2..f9db7196 100644 --- a/webvirtcloud/middleware.py +++ b/webvirtcloud/middleware.py @@ -7,6 +7,8 @@ from operations import claims, jobs from operations.utils import busy_message, public_error_text, summary API_PREFIX = "/api/" +# the directives that need no change to the templates' inline scripts and styles +CONTENT_SECURITY_POLICY = "object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" def error_text(request, exception): @@ -21,6 +23,16 @@ def error_text(request, exception): return public_error_text(exception) +class ContentSecurityPolicyMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + response.setdefault("Content-Security-Policy", CONTENT_SECURITY_POLICY) + return response + + class ExceptionMiddleware: def __init__(self, get_response): self.get_response = get_response diff --git a/webvirtcloud/settings.py.template b/webvirtcloud/settings.py.template index 98bc4ba6..b63d266d 100644 --- a/webvirtcloud/settings.py.template +++ b/webvirtcloud/settings.py.template @@ -99,6 +99,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.RemoteUserMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "webvirtcloud.middleware.ContentSecurityPolicyMiddleware", "appsettings.middleware.AppSettingsMiddleware", "webvirtcloud.middleware.ExceptionMiddleware", # last, as django-axes requires diff --git a/webvirtcloud/tests/test_csp.py b/webvirtcloud/tests/test_csp.py new file mode 100644 index 00000000..ac6998a0 --- /dev/null +++ b/webvirtcloud/tests/test_csp.py @@ -0,0 +1,15 @@ +"""Every response carries the Content-Security-Policy directives that need no +change to the templates' inline scripts and styles.""" + +from django.test import TestCase +from django.urls import reverse + + +class ContentSecurityPolicyTests(TestCase): + def test_pages_and_redirects_carry_the_policy(self): + for url in (reverse("accounts:login"), reverse("index")): # a page, and a redirect to the login + with self.subTest(url=url): + policy = self.client.get(url)["Content-Security-Policy"] + self.assertEqual( + policy, "object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" + )