Skip to content

Refs #36532 - Added CSP view decorators. #19680

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
33 changes: 16 additions & 17 deletions django/middleware/csp.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from http import HTTPStatus

from django.conf import settings
from django.utils.csp import CSP, LazyNonce, build_policy
from django.utils.deprecation import MiddlewareMixin
Expand All @@ -14,23 +12,24 @@ def process_request(self, request):
request._csp_nonce = LazyNonce()

def process_response(self, request, response):
# In DEBUG mode, exclude CSP headers for specific status codes that
# trigger the debug view.
exempted_status_codes = {
HTTPStatus.NOT_FOUND,
HTTPStatus.INTERNAL_SERVER_ERROR,
}
if settings.DEBUG and response.status_code in exempted_status_codes:
return response

nonce = get_nonce(request)
for header, config in [
(CSP.HEADER_ENFORCE, settings.SECURE_CSP),
(CSP.HEADER_REPORT_ONLY, settings.SECURE_CSP_REPORT_ONLY),
for config, header, disabled in [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nitpick, could we keep the previous order, adding the disabled at the end?

Suggested change
for config, header, disabled in [
for header, config, disabled in [

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I switched it to match the order it is being used in the conditional within this loop. This wasn't an arbitrary change I just felt it read better with the 3 conditionals after following the same order.

That order could be discussed too, but I feel like config makes sense first. The header not in response and the not disabled could be swapped with the assumption that using the decorators would happen more often than a custom header set in the view.

I'm open to however you'd like to tweak these but thought I'd share my thought process.

(
getattr(response, "_csp_config", None) or settings.SECURE_CSP,
CSP.HEADER_ENFORCE,
getattr(response, "_csp_disabled", False),
),
(
getattr(response, "_csp_config_ro", None)
or settings.SECURE_CSP_REPORT_ONLY,
CSP.HEADER_REPORT_ONLY,
getattr(response, "_csp_disabled_ro", False),
),
]:
# If headers are already set on the response, don't overwrite them.
# This allows for views to set their own CSP headers as needed.
if config and header not in response:
# Only set CSP headers if they are not present on the response,
# and if CSP is not disabled via the `@csp_disabled` decorator.
# This allows views to customize or disable CSP headers as needed.
if config and header not in response and not disabled:
response.headers[str(header)] = build_policy(config, nonce)

return response
3 changes: 3 additions & 0 deletions django/views/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from django.utils.module_loading import import_string
from django.utils.regex_helper import _lazy_re_compile
from django.utils.version import get_docs_version
from django.views.decorators.csp import csp_disabled
from django.views.decorators.debug import coroutine_functions_to_sensitive_variables

# Minimal Django templates engine to render the error templates
Expand Down Expand Up @@ -59,6 +60,7 @@ def __repr__(self):
return repr(self._wrapped)


@csp_disabled
def technical_500_response(request, exc_type, exc_value, tb, status_code=500):
"""
Create a technical server error response. The last three arguments are
Expand Down Expand Up @@ -606,6 +608,7 @@ def get_exception_traceback_frames(self, exc_value, tb):
tb = tb.tb_next


@csp_disabled
def technical_404_response(request, exception):
"""Create a technical 404 error response. `exception` is the Http404."""
try:
Expand Down
67 changes: 67 additions & 0 deletions django/views/decorators/csp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from functools import wraps

from asgiref.sync import iscoroutinefunction


def csp_override(config, enforced=True, report_only=True):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you consider providing two independent decorators? perhaps this is a nicer API?
What I like is that there is a clear separation of concerns, and if both config need tweak, both decorators can be used. What's unclear is whether we could reuse code between the two definitions... I'm torn about code dup with the benefit of clear separation vs DRY and an arguably less clean API.

I'm inclined to split them given that we have two separated settings for these. What do you think?

Suggested change
def csp_override(config, enforced=True, report_only=True):
def csp_override(config):
def decorator(view_func):
@wraps(view_func)
async def _wrapped_async_view(request, *args, **kwargs):
response = await view_func(request, *args, **kwargs)
response._csp_config = config
return response
@wraps(view_func)
def _wrapped_sync_view(request, *args, **kwargs):
response = view_func(request, *args, **kwargs)
response._csp_config = config
return response
# Determine whether to wrap as async or sync function
if iscoroutinefunction(view_func):
return _wrapped_async_view
return _wrapped_sync_view
return decorator
def csp_override_report_only(config):
def decorator(view_func):
@wraps(view_func)
async def _wrapped_async_view(request, *args, **kwargs):
response = await view_func(request, *args, **kwargs)
response._csp_config_ro = config
return response
@wraps(view_func)
def _wrapped_sync_view(request, *args, **kwargs):
response = view_func(request, *args, **kwargs)
response._csp_config_ro = config
return response
# Determine whether to wrap as async or sync function
if iscoroutinefunction(view_func):
return _wrapped_async_view
return _wrapped_sync_view
return decorator

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was slightly mentioned in the original PR here:
#18215 (review)

It's hard to think of what the most common use case would be. For disabling the CSP on a view altogether I would imagine it would be more common to want to disable both the enforced and report-only. So having a single decorator makes that nice, otherwise you'd have to stack 2 decorators.

For the overrides I'm not sure. You may be overriding the CSP for the report-only header perhaps, but then why not use the setting? It's likely you'd want to override both as well I imagine since there's something in the view + template that needs the override and needs to override both.

I do think there's a valid argument that they are separate headers so they should be separate decorators. But I also think the context of the view + template typically sharing the same needs for both headers also sways the argument a bit towards being a single decorator.

Curious what your further thoughts are.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your follow up comments. I've been thinking about this and I have also chatted with Sarah to get a third maintainer's perspective. Since the comment is long and relevant for future readers, I will post as a PR-level comment instead of a diff-level comments since the latter are easier to miss.

def _set_config(response, config, enforced, report_only):
if enforced:
response._csp_config = config
if report_only:
response._csp_config_ro = config
return response

def decorator(view_func):
@wraps(view_func)
async def _wrapped_async_view(request, *args, **kwargs):
response = await view_func(request, *args, **kwargs)
response = _set_config(response, config, enforced, report_only)
return response

@wraps(view_func)
def _wrapped_sync_view(request, *args, **kwargs):
response = view_func(request, *args, **kwargs)
response = _set_config(response, config, enforced, report_only)
return response

# Determine whether to wrap as async or sync function
if iscoroutinefunction(view_func):
return _wrapped_async_view
return _wrapped_sync_view

return decorator


def csp_disabled(enforced=True, report_only=True):
def _set_disabled(response, enforced, report_only):
if enforced:
response._csp_disabled = enforced
if report_only:
response._csp_disabled_ro = report_only
return response

def decorator(view_func):
@wraps(view_func)
async def _wrapped_async_view(request, *args, **kwargs):
response = await view_func(request, *args, **kwargs)
response = _set_disabled(response, enforced, report_only)
return response

@wraps(view_func)
def _wrapped_sync_view(request, *args, **kwargs):
response = view_func(request, *args, **kwargs)
response = _set_disabled(response, enforced, report_only)
return response

# Determine whether to wrap as async or sync function
if iscoroutinefunction(view_func):
return _wrapped_async_view
return _wrapped_sync_view

# Check if called directly or with arguments
if callable(enforced):
# When no args passed, `enforced` is the view func.
return decorator(enforced)

# Called with arguments, return the actual decorator
return decorator
95 changes: 95 additions & 0 deletions docs/ref/csp.txt
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,101 @@ with the CSP specification.
secure, random nonce that is generated for each request. See detailed
explanation in :ref:`csp-nonce`.

Decorators
==========

.. module:: django.views.decorators.csp

The examples below assume you are using function-based views. If you
are working with class-based views, you can refer to :ref:`Decorating
class-based views<decorating-class-based-views>`.

.. warning::

Weakening or excluding a CSP policy on any page can compromise the
security of the entire site. Due to the "same origin" policy, an
attacker can exploit a vulnerability on one page to access other parts
of the site.

.. function:: csp_disabled(enforced=True, report_only=True)(view)

This decorator marks a view as being disabled from the Content Security Policy
protection ensured by the middleware. Example::

from django.http import HttpResponse
from django.views.decorators.csp import csp_disabled


@csp_disabled
def my_view(request):
return HttpResponse("This view will not have either CSP headers")

This decorator takes optional boolean arguments ``enforced`` and
``report_only`` to customize which header is disabled. By default, both
headers are disabled. Example::

from django.http import HttpResponse
from django.views.decorators.csp import csp_disabled


@csp_disabled(report_only=False)
def my_view(request):
return HttpResponse("This view will only have an enforced CSP header")


@csp_disabled(enforced=False)
def another_view(request):
return HttpResponse("This view will only have a report-only CSP header")

.. function:: csp_override(config, enforced=True, report_only=True)(view)

This decorator allows you to override the Content Security Policy for a
specific view. It takes a dictionary as an argument, similar to the
``SECURE_CSP`` and ``SECURE_CSP_REPORT_ONLY`` settings. This decorator also
takes optional boolean arguments ``enforced`` and ``report_only`` to
customize which header is overridden. By default, both headers are
overridden. Example::

from django.http import HttpResponse
from django.utils.csp import CSP
from django.views.decorators.csp import csp_override


@csp_override(
{
"default-src": [CSP.SELF],
"img-src": [CSP.SELF, "data:"],
}
)
def my_view(request):
return HttpResponse("Both CSP headers will be overridden")


@csp_override(
{
"default-src": [CSP.SELF],
"img-src": [CSP.SELF, "data:"],
},
report_only=False,
)
def enforced_only_view(request):
return HttpResponse("Only the enforced CSP header will be overridden")


@csp_override(
{
"default-src": [CSP.SELF],
"img-src": [CSP.SELF, "data:"],
},
enforced=False,
)
def report_only_view(request):
return HttpResponse("Only the report-only CSP header will be overridden")

These decorators provide fine-grained control over the Content Security Policy
on a per-view basis, allowing you to customize the policy as needed for specific
parts of your application.

.. _csp-nonce:

Nonce usage
Expand Down
4 changes: 4 additions & 0 deletions docs/releases/6.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ The resulting ``Content-Security-Policy`` header would be set to:

default-src 'self'; script-src 'self' 'nonce-SECRET'; img-src 'self' https:

Per-view policy customization with can be achieved via the
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Per-view policy customization with can be achieved via the
Per-view policy customization can be achieved via the

:func:`~django.views.decorators.csp.csp_disabled` and
:func:`~django.views.decorators.csp.csp_override` decorators.

To get started, follow the :doc:`CSP how-to guide </howto/csp>`. For in-depth
guidance, see the :ref:`CSP security overview <security-csp>` and the
:doc:`reference docs </ref/csp>`.
Expand Down
2 changes: 2 additions & 0 deletions docs/topics/async.txt
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ view functions:
* :func:`~django.views.decorators.cache.cache_control`
* :func:`~django.views.decorators.cache.never_cache`
* :func:`~django.views.decorators.common.no_append_slash`
* :func:`~django.views.decorators.csp.csp_disabled`
* :func:`~django.views.decorators.csp.csp_override`
* :func:`~django.views.decorators.csrf.csrf_exempt`
* :func:`~django.views.decorators.csrf.csrf_protect`
* :func:`~django.views.decorators.csrf.ensure_csrf_cookie`
Expand Down
Loading