From 98a49efdbe42c1b47cc9cca5d0d4aca8b8709ab1 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 8 Sep 2026 09:10:44 -0400 Subject: [PATCH] fix: sandbox served course assets to prevent stored XSS Studio course assets are served by the contentserver with the uploader-supplied Content-Type and no neutralizing headers, on a route mounted on the Studio (CMS) origin. An author with file-upload permission can upload an HTML or SVG file containing script; when another user opens the asset URL it runs same-origin with the Studio session, enabling privilege escalation (self-granting course-admin, or Django superuser via same-origin /admin/ when a global-staff user views it). Send `Content-Security-Policy: sandbox` on every course-asset response so the asset loads in an opaque origin and cannot script against the Studio/LMS session. A per-course escape-hatch flag, course_assets.allow_unsafe_asset_rendering, lets operators temporarily disable sandboxing for a course whose content must be migrated first. Closes GHSA-c6xg-fh3c-vvhh. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 2efdce0760561db2bcd3a2895c93480d01f0a308) --- .../contentserver/test/test_contentserver.py | 54 +++++++++++++++++++ .../core/djangoapps/contentserver/views.py | 33 ++++++++++++ 2 files changed, 87 insertions(+) diff --git a/openedx/core/djangoapps/contentserver/test/test_contentserver.py b/openedx/core/djangoapps/contentserver/test/test_contentserver.py index df29b64d2781..d24cd6b3c1f5 100644 --- a/openedx/core/djangoapps/contentserver/test/test_contentserver.py +++ b/openedx/core/djangoapps/contentserver/test/test_contentserver.py @@ -15,10 +15,13 @@ from django.test import RequestFactory from django.test.client import Client from django.test.utils import override_settings +from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel from xmodule.assetstore.assetmgr import AssetManager from xmodule.contentstore.content import VERSIONED_ASSETS_PREFIX, StaticContent from xmodule.contentstore.django import contentstore @@ -376,6 +379,57 @@ def test_vary_header_sent(self): assert resp.status_code == 200 assert 'Origin' == resp['Vary'] + def test_asset_served_with_csp_sandbox(self): + """ + By default the contentserver sends ``Content-Security-Policy: sandbox`` on + course asset responses, so an uploaded asset (e.g. an HTML or SVG file) is + loaded in an opaque origin and cannot run JavaScript against the Studio/LMS + session. + """ + resp = self.client.get(self.url_unlocked) + assert resp.status_code == 200 + assert resp['Content-Security-Policy'] == 'sandbox' + + @override_waffle_flag(views.ALLOW_UNSAFE_ASSET_RENDERING, active=True) + def test_asset_sandbox_disabled_when_flag_enabled_globally(self): + """ + Enabling the ``course_assets.allow_unsafe_asset_rendering`` opt-out flag + globally removes the sandbox header from asset responses. + """ + resp = self.client.get(self.url_unlocked) + assert resp.status_code == 200 + assert 'Content-Security-Policy' not in resp + + @staticmethod + def _enable_unsafe_asset_rendering_for(course_key): + """Add an enabled per-course opt-out override for the sandbox flag.""" + WaffleFlagCourseOverrideModel.objects.create( + waffle_flag=views.ALLOW_UNSAFE_ASSET_RENDERING.name, + course_id=course_key, + override_choice=WaffleFlagCourseOverrideModel.ALL_CHOICES.on, + enabled=True, + ) + + def test_asset_sandbox_disabled_by_per_course_override(self): + """ + A per-course opt-out override for THIS course removes the sandbox header + from its assets. + """ + self._enable_unsafe_asset_rendering_for(self.course_key) + resp = self.client.get(self.url_unlocked) + assert resp.status_code == 200 + assert 'Content-Security-Policy' not in resp + + def test_asset_sandbox_unaffected_by_other_course_override(self): + """ + A per-course opt-out for a DIFFERENT course must not disable sandboxing + here -- this course's assets are still served with the sandbox header. + """ + self._enable_unsafe_asset_rendering_for(CourseKey.from_string('course-v1:Other+Other+Other')) + resp = self.client.get(self.url_unlocked) + assert resp.status_code == 200 + assert resp['Content-Security-Policy'] == 'sandbox' + @patch('openedx.core.djangoapps.contentserver.models.CourseAssetCacheTtlConfig.get_cache_ttl') def test_cache_headers_with_ttl_unlocked(self, mock_get_cache_ttl): """ diff --git a/openedx/core/djangoapps/contentserver/views.py b/openedx/core/djangoapps/contentserver/views.py index cbdb4124fef7..15e6c2a5287c 100644 --- a/openedx/core/djangoapps/contentserver/views.py +++ b/openedx/core/djangoapps/contentserver/views.py @@ -196,6 +196,18 @@ def process_request(request): response['Content-Type'] = content.content_type response['X-Frame-Options'] = 'ALLOW' + # Serve every course asset under a Content-Security-Policy sandbox. + # ``sandbox`` is a document directive: it only takes effect when the asset + # is loaded as a document -- a top-level navigation to the asset URL, or an + #