1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

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 <base>
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.
This commit is contained in:
catborise 2026-10-19 15:22:01 +03:00
parent 75559629e4
commit 6f44461c28
4 changed files with 30 additions and 0 deletions

View file

@ -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`:

View file

@ -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

View file

@ -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

View file

@ -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'"
)