1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00

feat(webvirtcloud): pages run only the scripts the panel serves

The Content-Security-Policy now also sends script-src 'self': a script
injected into a page, inline or as an event handler, does not run. The
templates have none of their own any more, and a test keeps it so.
worker-src 'self' blob: lets the XML editor (Ace) and the API docs
(Redoc) start their workers, which they make from blob: URLs. The
Swagger UI page is drf-spectacular's split view, which serves its start
script as a file instead of inline.
This commit is contained in:
catborise 2026-11-02 10:08:09 +03:00
parent 50dcaba699
commit 37905a0b75
4 changed files with 36 additions and 9 deletions

View file

@ -330,7 +330,7 @@ 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.
Every page is sent with `X-Frame-Options: DENY` and `Content-Security-Policy: script-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'`: the panel runs only scripts it serves itself, cannot be shown in a frame, and its forms post only to its own origin.
### Cloud-init datasource

View file

@ -8,8 +8,12 @@ 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'"
# scripts only from this site: no inline script or event handler runs (inline styles still do);
# the XML editor (Ace) and the API docs (Redoc) start their workers from blob: URLs
CONTENT_SECURITY_POLICY = (
"script-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; "
"frame-ancestors 'none'"
)
def same_origin_referer(request, default):

View file

@ -1,9 +1,18 @@
"""Every response carries the Content-Security-Policy directives that need no
change to the templates' inline scripts and styles."""
"""Every response carries the Content-Security-Policy, and no template has
what its script-src 'self' would block: an inline script, an inline event
handler or a javascript: URL."""
from django.test import TestCase
import re
from pathlib import Path
from django.conf import settings
from django.test import SimpleTestCase, TestCase
from django.urls import reverse
# a <script> without src that is not json_script data
INLINE_SCRIPT = re.compile(r"<script(?![^>]*\bsrc=)(?![^>]*application/json)[^>]*>")
INLINE_HANDLER = re.compile(r"\son[a-z]+\s*=", re.I)
class ContentSecurityPolicyTests(TestCase):
def test_pages_and_redirects_carry_the_policy(self):
@ -11,5 +20,19 @@ class ContentSecurityPolicyTests(TestCase):
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'"
policy,
"script-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
)
class TemplatesRunNoInlineScriptTests(SimpleTestCase):
def test_no_template_has_an_inline_script_handler_or_javascript_url(self):
base = Path(settings.BASE_DIR)
templates = [*base.glob("*/templates/**/*.html"), *base.glob("templates/**/*.html")]
self.assertTrue(templates)
for path in templates:
source = path.read_text()
with self.subTest(template=str(path.relative_to(base))):
self.assertIsNone(INLINE_SCRIPT.search(source))
self.assertIsNone(INLINE_HANDLER.search(source))
self.assertNotIn("javascript:", source)

View file

@ -3,7 +3,7 @@ from django.urls import include, path, re_path
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
SpectacularSwaggerSplitView,
)
from appsettings.views import appsettings
from console.views import console
@ -35,7 +35,7 @@ urlpatterns = [
path('api/v1/', include("webvirtcloud.urls-api")),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
re_path(r"^swagger\.(?P<format>json|yaml)$", SpectacularAPIView.as_view(), name="schema-json"),
path("swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="schema-swagger-ui"),
path("swagger/", SpectacularSwaggerSplitView.as_view(url_name="schema"), name="schema-swagger-ui"),
path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="schema-redoc"),
]