Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix scorm xblock x frame options #35080

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
41 changes: 33 additions & 8 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
import importlib.util
import json
import os
import sys

from corsheaders.defaults import default_headers as corsheaders_default_headers
from datetime import timedelta
Expand Down Expand Up @@ -903,10 +902,9 @@
}

################################# Middleware ###################################

MIDDLEWARE = [
'openedx.core.lib.x_forwarded_for.middleware.XForwardedForMiddleware',
'edx_django_utils.security.csp.middleware.content_security_policy_middleware',
'openedx.core.lib.content_security_policy.middleware.content_security_policy_middleware',

'crum.CurrentRequestUserMiddleware',

Expand Down Expand Up @@ -972,8 +970,10 @@

'openedx.core.djangoapps.theming.middleware.CurrentSiteThemeMiddleware',

# use Django built in clickjacking protection
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# use custom extension of Django built in clickjacking protection. This
# allows the permissiveness of embedded frame usage (the degree of
# clickjacking protection) to vary based on route
'openedx.core.lib.x_frame_options.middleware.EdxXFrameOptionsMiddleware',

'waffle.middleware.WaffleMiddleware',

Expand All @@ -994,9 +994,36 @@

EXTRA_MIDDLEWARE_CLASSES = []

# Clickjacking protection can be disabled by setting this to 'ALLOW'
################# Clickjacking protection ###################
"""
X-FRAME-OPTIONS headers are set by default to `DENY`.
This means that the page cannot be displayed in a frame, regardless
of the site attempting to do so. This is a security measure to prevent clickjacking attacks.

In some cases, like for the SCORM xblock, we need to allow SAMEORIGIN as well
as Microfrontend MFE URLs to render the content. For this, a Content-Security-Policy (CSP) is needed.
To implement this only for endpoints where it is needed, we have a custom
middleware that allows us to set a specific CSP for URLs matching a regex.
This will override the default CSP which you can set via the `CSP_STATIC_ENFORCE` setting.

Since this needs access to the MFE URLs set via config, which are not available when this file is executed,
these custom CSPs are set in the return value of a callback function passed via the `GET_CUSTOM_CSPS` setting.
"""
X_FRAME_OPTIONS = 'DENY'


def _get_custom_csps():
# pylint: disable=import-error
from django.conf import settings
course_authoring_url = getattr(settings, 'COURSE_AUTHORING_MICROFRONTEND_URL', None)
return [
['.*/media/scorm/.*', f"frame-ancestors 'self' {course_authoring_url}"],
]

GET_CUSTOM_CSPS = _get_custom_csps

################# Privace Preferences ###################

# Platform for Privacy Preferences header
P3P_HEADER = 'CP="Open EdX does not have a P3P policy."'

Expand Down Expand Up @@ -1248,8 +1275,6 @@

STATIC_URL_BASE = '/static/'

X_FRAME_OPTIONS = 'DENY'

# .. setting_name: GIT_REPO_EXPORT_DIR
# .. setting_default: '/edx/var/edxapp/export_course_repos'
# .. setting_description: When courses are exported to git, either with the export_git management command or the git
Expand Down
41 changes: 35 additions & 6 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@
# pylint: disable=invalid-name

import importlib.util
import socket
import sys
import os
import re

import django
from corsheaders.defaults import default_headers as corsheaders_default_headers
Expand Down Expand Up @@ -93,6 +95,37 @@
# IDA for which the social auth flow uses DOT (Django OAuth Toolkit).
IDA_LOGOUT_URI_LIST = []

################# Clickjacking protection ###################
"""
X-FRAME-OPTIONS headers are set by default to `DENY`.
This means that the page cannot be displayed in a frame, regardless
of the site attempting to do so. This is a security measure to prevent clickjacking attacks.

In some cases, like for the SCORM xblock, we need to allow SAMEORIGIN as well
as Microfrontend MFE URLs to render the content. For this, a Content-Security-Policy (CSP) is needed.
To implement this only for endpoints where it is needed, we have a custom
middleware that allows us to set a specific CSP for URLs matching a regex.
This will override the default CSP which you can set via the `CSP_STATIC_ENFORCE` setting.

Since this needs access to the MFE URLs set via config, which are not available when this file is executed,
these custom CSPs are set in the return value of a callback function passed via the `GET_CUSTOM_CSPS` setting.
"""

# Clickjacking protection can be disabled by setting this to 'ALLOW' or adjusted by setting this to 'SAMEORIGIN'.
# It is not advised to set this to 'ALLOW' without a Content Security Policy header.
X_FRAME_OPTIONS = 'DENY'


def _get_custom_csps():
from django.conf import settings
learning_url = getattr(settings, 'LEARNING_MICROFRONTEND_URL', None)
return [
['.*/media/scorm/.*', f"frame-ancestors 'self' {learning_url}"],
['.*/xblock/.*', f"frame-ancestors 'self' {learning_url}"]
]

GET_CUSTOM_CSPS = _get_custom_csps

# Features
FEATURES = {
# .. toggle_name: FEATURES['DISPLAY_DEBUG_INFO_TO_STAFF']
Expand Down Expand Up @@ -2249,10 +2282,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
CREDIT_NOTIFICATION_CACHE_TIMEOUT = 5 * 60 * 60

################################# Middleware ###################################

MIDDLEWARE = [
'openedx.core.lib.x_forwarded_for.middleware.XForwardedForMiddleware',
'edx_django_utils.security.csp.middleware.content_security_policy_middleware',
'openedx.core.lib.content_security_policy.middleware.content_security_policy_middleware',

'crum.CurrentRequestUserMiddleware',

Expand Down Expand Up @@ -2335,7 +2367,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
# for expiring inactive sessions
'openedx.core.djangoapps.session_inactivity_timeout.middleware.SessionInactivityTimeout',

# use Django built in clickjacking protection
# use custom extension of Django built in clickjacking protection
'django.middleware.clickjacking.XFrameOptionsMiddleware',

# to redirected unenrolled students to the course info page
Expand Down Expand Up @@ -2363,9 +2395,6 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
'openedx.core.djangoapps.site_configuration.middleware.SessionCookieDomainOverrideMiddleware',
]

# Clickjacking protection can be disbaled by setting this to 'ALLOW'
X_FRAME_OPTIONS = 'DENY'

# Platform for Privacy Preferences header
P3P_HEADER = 'CP="Open EdX does not have a P3P policy."'

Expand Down
39 changes: 39 additions & 0 deletions openedx/core/lib/content_security_policy/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
Content-Security-Policy Middleware
**********************************

.. The preferred way to prevent clickjacking is to use the Content Security Policy headers.
.. This middleware adds the Content-Security-Policy headers to the response.
.. It allows overriding the default Content-Security-Policy header for specific urls.

- Add the middleware ``'openedx.core.lib.content_security_policy.middleware.content_security_policy_middleware',``
near the beginning of your ``MIDDLEWARE`` list.
- To add a Content-Security-Policy header for all endpoints, set the ``CSP_STATIC_ENFORCE`` setting to
the desired value for the header; this excludes only the reporting options.
- If desired, the ``CSP_STATIC_REPORT_ONLY``, ``CSP_STATIC_REPORTING_URI``, and ``CSP_STATIC_REPORTING_NAME``
settings can be added to enable reporting of violations.
- If you want different CSP headers for specific URLs, define a function
like in the example ``_get_custom_csps`` below and then assign this function
to the ``GET_CUSTOM_CSPS`` setting.
- This defines a callback function that will be executed by the middleware.
The function should return a list of value pairs like ``[['.*/example-regex', 'example-csp-header-string']]``,
where each pair contains a regex pattern
and a CSP header value to be applied to URLs that match the pattern.
- If no ``CSP_STATIC_ENFORCE`` setting is defined, the middleware will only add the header for the URLs matching
the patterns specified in the ``GET_CUSTOM_CSPS`` function return.
- If the ``CSP_STATIC_ENFORCE`` setting is defined as well, the custom CSP headers will take precedence
for the specified URLs.
- The reason a callback function is used is that django configuration settings like ``LEARNING_MICROFRONTEND_URL``
are not available at the time the settings are loaded, so the function is called at runtime.

Example for ``_get_custom_csps`` callback function:
---------------------------------------------------

```
def _get_custom_csps():
from django.conf import settings
learning_url = getattr(settings, 'LEARNING_MICROFRONTEND_URL', None)
return [
['.*/media/scorm/.*', f"frame-ancestors 'self' {learning_url}"],
['.*/xblock/.*', f"frame-ancestors 'self' {learning_url}"]
]
```
Empty file.
149 changes: 149 additions & 0 deletions openedx/core/lib/content_security_policy/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""
Middleware to override CSP headers.
"""

import re

from django.conf import settings


def _load_headers(override=None) -> dict:
"""
Return a dict of headers to append to every response, based on settings.
"""
# .. setting_name: CSP_STATIC_ENFORCE
# .. setting_default: None
# .. setting_description: Content-Security-Policy header to attach to all responses.
# This should include everything but the ``report-to`` or ``report-uri`` clauses; those
# will be appended automatically according to the ``CSP_STATIC_REPORTING_NAME`` and
# ``CSP_STATIC_REPORTING_URI`` settings. Newlines are permitted and will be replaced with spaces.
# A trailing `;` is also permitted.
# .. setting_warning: Setting the CSP header to too strict a value can cause your pages to
# break. It is strongly recommended that deployers start by using ``CSP_STATIC_REPORT_ONLY`` (along
# with the reporting settings) and only move or copy the policies into ``CSP_STATIC_ENFORCE`` after
# confirming that the received CSP reports only represent false positives. (The report-only
# and enforcement headers may be used at the same time.)
enforce_policies = override or getattr(settings, 'CSP_STATIC_ENFORCE', None)

# .. setting_name: CSP_STATIC_REPORT_ONLY
# .. setting_default: None
# .. setting_description: Content-Security-Policy-Report-Only header to attach to
# all responses. See ``CSP_STATIC_ENFORCE`` for details.
report_policies = getattr(settings, 'CSP_STATIC_REPORT_ONLY', None)

# .. setting_name: CSP_STATIC_REPORTING_URI
# .. setting_default: None
# .. setting_description: URL of reporting server. This will be used for both Level 2 and
# Level 3 reports. If there are any semicolons or commas in the URL, they must be URL-encoded.
# Level 3 reporting is only enabled if ``CSP_STATIC_REPORTING_NAME`` is also set.
reporting_uri = getattr(settings, 'CSP_STATIC_REPORTING_URI', None)

# .. setting_name: CSP_STATIC_REPORTING_NAME
# .. setting_default: None
# .. setting_description: Used for CSP Level 3 reporting. This sets the name to use in the
# report-to CSP field and the Reporting-Endpoints header. If omitted, then Level 3 CSP
# reporting will not be enabled. If present, this must be a string starting with an ASCII
# letter and can contain ASCII letters, numbers, hyphen, underscore, and some other characters.
# See https://www.rfc-editor.org/rfc/rfc8941.html#section-3.3.4 for full grammar.
reporting_endpoint_name = getattr(settings, 'CSP_STATIC_REPORTING_NAME', None)

if not enforce_policies and not report_policies:
return {}

headers = {}

reporting_suffix = ''
if reporting_uri:
reporting_suffix = f"; report-uri {reporting_uri}"
if reporting_endpoint_name:
headers['Reporting-Endpoints'] = f'{reporting_endpoint_name}="{reporting_uri}"'
reporting_suffix += f"; report-to {reporting_endpoint_name}"

def clean_header(value):
# Collapse any internal whitespace that contains a newline. This allows
# writing the setting value as a multi-line string, which is useful for
# CSP -- the values can be quite long.
value = re.sub("\\s*\n\\s*", " ", value).strip()
# Remove any trailing semicolon, which we allow (for convenience).
# The CSP spec does not allow trailing semicolons or empty directives.
value = re.sub("[;\\s]+$", "", value)
return value

if enforce_policies:
headers['Content-Security-Policy'] = clean_header(enforce_policies) + reporting_suffix

if report_policies:
headers['Content-Security-Policy-Report-Only'] = clean_header(report_policies) + reporting_suffix

return headers


def _append_headers(response_headers, more_headers):
"""
Append to the response headers. If a header already exists, assume it is
permitted to be multi-valued (comma-separated), and update the existing value.

Arguments:
response_headers: response.headers (or any dict-like object), to be modified
more_headers: Dict of header names to values
"""
for k, v in more_headers.items():
if existing := response_headers.get(k):
response_headers[k] = f"{existing}, {v}"
else:
response_headers[k] = v


class MiddlewareNotUsed(Exception):
"""This middleware is not used in this server configuration"""


def _get_override(request, url_specific_csps):
"""
Check url_specific_csps list for a match to the request url.
If a regex is included that matches the request url, that means that the CSP header
should be overwritten with the provided string for this url.
url_specific_csps: [[regex, custom_csp], ...]
"""
if not url_specific_csps or not isinstance(url_specific_csps, list):
return None
# TODO: handle errors for when env variable has wrong format
for csp in url_specific_csps:
if not isinstance(csp, list) or len(csp) != 2:
continue
regex, custom_csp = csp
if re.search(regex, request.path):
return custom_csp
return None


def content_security_policy_middleware(get_response):
"""
Return middleware handler based on CSP headers.
These are specified in the environment variables `CSP_STATIC_ENFORCE`,
`CSP_STATIC_REPORT_ONLY`, `CSP_STATIC_REPORTING_URI`, and `CSP_STATIC_REPORTING_NAME`.

It is possible to override the CSP headers for specific URLs by setting the
`GET_CUSTOM_CSPS` environment variable to callback function returning a list of pairs. Each pair should
contain a regex and a string. If the regex matches the request URL, the
CSP header part specified in `CSP_STATIC_ENFORCE` will be overwritten with the provided string.
jesperhodge marked this conversation as resolved.
Show resolved Hide resolved
"""
csp_headers = _load_headers()
get_csp_override = getattr(settings, 'GET_CUSTOM_CSPS', None)
url_specific_csps = get_csp_override() if get_csp_override else None
if not csp_headers and not url_specific_csps:
raise MiddlewareNotUsed() # tell Django to skip this middleware

def middleware_handler(request):
response = get_response(request)
# Reporting-Endpoints, CSP, and CSP-RO can all be multi-valued
# (comma-separated) headers, though the CSP spec says "SHOULD NOT"
# for the latter two.
override = _get_override(request, url_specific_csps)

csp_headers = _load_headers(override=override)
if csp_headers:
_append_headers(response.headers, csp_headers)
return response

return middleware_handler
Empty file.
Loading
Loading