Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions openedx/core/djangoapps/contentserver/test/test_contentserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
33 changes: 33 additions & 0 deletions openedx/core/djangoapps/contentserver/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# <iframe>/<object>/<embed> -- which are exactly the contexts where an
# uploaded HTML or SVG file would otherwise execute script in the Studio/LMS
# origin. There the asset is placed in an opaque origin with scripting
# disabled, so it cannot script against the session. The
# ``course_assets.allow_unsafe_asset_rendering`` flag disables this
# per-course for content that must be migrated first.
if not ALLOW_UNSAFE_ASSET_RENDERING.is_enabled(safe_course_key):
response['Content-Security-Policy'] = 'sandbox'

# Set any caching headers, and do any response cleanup needed. Based on how much
# middleware we have in place, there's no easy way to use the built-in Django
# utilities and properly sanitize and modify a response to ensure that it is as
Expand Down Expand Up @@ -291,6 +303,27 @@ def is_content_locked(content):
)


# .. toggle_name: course_assets.allow_unsafe_asset_rendering
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: When enabled for a course, allows that course's uploaded
# assets to be served WITHOUT the ``Content-Security-Policy: sandbox`` header.
# By default (flag off) the contentserver sandboxes every course asset response,
# so an uploaded asset (for example an HTML or SVG file with embedded scripts) is
# loaded in an opaque origin and cannot execute JavaScript against the Studio/LMS
# session, preventing stored XSS and privilege escalation via uploaded assets.
# Enable this flag for a specific course only as a temporary measure if that
# course has legitimate asset content that breaks under sandboxing, until the
# content can be migrated to a safer authoring mechanism.
# .. toggle_warning: Enabling this re-exposes the course to stored XSS via
# uploaded assets. Prefer migrating the offending content over enabling it.
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2026-09-02
ALLOW_UNSAFE_ASSET_RENDERING = CourseWaffleFlag(
'course_assets.allow_unsafe_asset_rendering', module_name=__name__,
)


def is_user_authorized(request, content, location):
"""
Determines whether or not the user for this request is authorized to view the given asset.
Expand Down
Loading