mozilla / django-csp

Content Security Policy for Django.
https://django-csp.readthedocs.io/en/latest/
BSD 3-Clause "New" or "Revised" License
568 stars 102 forks source link

Decorators depending on request method types #169

Open erdimeola opened 3 years ago

erdimeola commented 3 years ago

Hi, hope everyone is healthy and safe.

I have multiple views which are working with multiple request methods. For some of these views, I'ld like to update/replace/exclude the csp only for the request method. For example;

@csp_exempt(methods=["POST"])
def view(request):
    if request.method == "GET":
        result = do_get_with_default_csp(request)
    elif request.method == "POST":
        result = do_post_with_csp_excluded(request)
    else:
        raise InvalidRequestException()
    return result

@csp_update(methods=["GET"], SCRIPT_SRC=["url"])
def another_view(request):
    if request.method == "GET":
        result = do_get_with_updated_csp(request)
    elif request.method == "POST":
        result = do_post_with_default_csp(request)
    else:
        raise InvalidRequestException()
    return result

I hope I'm not the only one in need of such feature.

DylanYoung commented 2 years ago

I think it will be simple to add this after #36 has landed. Though I have to admit, I'm confused about the value proposition here. Can you explain a little bit about why you want to do this @erdimeola?

some1ataplace commented 1 year ago

To achieve this behavior, you can create two new decorators called csp_exclude and csp_update that modify the behavior of the CSPMiddleware on a per-view basis based on the HTTP request method.

from functools import wraps
from django.http import HttpResponse
from django.http import StreamingHttpResponse
from django_csp.middleware import CSPMiddleware

def csp_exclude(methods=[]):
    def decorator(func):
        @wraps(func)
        def inner_func(request, args, **kwargs):
            if request.method in methods:
                response = func(request,args, kwargs)
                return response
            else:
                # Exclude CSP headers for this request method
                response = HttpResponse()
                setattr(response, '_csp_exempt', True)
                return response

        return inner_func

    return decorator

def csp_update(methods=[], kwargs):
    def decorator(func):
        @wraps(func)
        def inner_func(request, *args, **kwargs):
            if request.method in methods:

                middleware = CSPMiddleware(get_response=None)
                policy = middleware.build_policy(base_policy={})
                policy.update(kwargs)
                # Set the updated policy on the response object
                response = func(request, args, **kwargs)
                response._csp_update = policy
                return response
            else:
                response = func(request,args, **kwargs)
                return response

        return inner_func

    return decorator

# Usage Example
@csp_update(methods=['POST'], kwargs={'script-src': ["'self'", "https://example.com/)
def my_view(request):
    if request.method == 'POST':
        # perform some actions/operations...
        return HttpResponse("POST request successful!")
    else:
        return HttpResponse("GET request successful!")

This code applies the csp_update decorator to the my_view function, which modifies the behavior of CSPMiddleware to add the https://example.com/ as a source for the script-src directive for only POST requests. When a POST request is made to this view, the specified CSP changes will be applied, and the view will return an HttpResponse object with a message indicating a successful POST request. For all other request methods, the view will return an HttpResponse object with a message indicating a successful GET request.

from django.test import RequestFactory, TestCase
from django.http import HttpResponse
from django_csp.middleware import CSPMiddleware
from myapp.views import my_view, csp_update

class CSPMiddlewareTest(TestCase):
    def setUp(self):
        self.factory = RequestFactory()

    def test_csp_update(self):
        # Create a request object
        request = self.factory.post('/my-view/')
        kwargs = {'script-src': ["'self'", "https://example.com/%22]%7D

        # Create a response object with the CSP header
        response = HttpResponse()
        middleware = CSPMiddleware(get_response=None)
        policy = middleware.build_policy(base_policy={})
        policy.update(kwargs)
        response['Content-Security-Policy'] = middleware._build_policy(policy)

        # Call the view decorated with @csp_update and check that the returned response object has the updated CSP header
        view = csp_update(methods=['POST'], kwargs=kwargs)(my_view)
        response = view(request)
        self.assertEqual(response['Content-Security-Policy'], middleware._build_policy(policy))

    def test_csp_exclude(self):
        # Create a request object with a method that is exempt from CSP
        request = self.factory.get('/my-view/')

        # Create a response object with the CSP header
        response = HttpResponse()
        middleware = CSPMiddleware(get_response=None)
        response['Content-Security-Policy'] = middleware._build_policy({})

        # Set the exempt_urls attribute to include the URL path of the request object
        middleware.exempt_urls = ['/my-view/']

        # Verify that the process_request method returns None since the URL path of the request is exempt from CSP
        self.assertIsNone(middleware.process_request(request))

To create decorators for django-csp that depend on request method types, you can define two new decorators: csp_exempt and csp_update.

Here is the code for the csp_exempt decorator:

from functools import wraps
from django_csp.utils import get_csp_from_request

def csp_exempt(methods=None):
    """
    Decorator to exempt a view from CSP enforcement for specific request methods.
    """
    if not methods:
        methods = []

    def decorator(view_func):
        @wraps(view_func)
        def _wrapped_view(request, *args, **kwargs):
            if request.method in methods:
                # Remove CSP from the request object
                request.csp = None
            return view_func(request, *args, **kwargs)

        return _wrapped_view

    return decorator

This decorator takes an optional methods argument, which is a list of HTTP methods that should be exempt from CSP enforcement. If methods is not provided, then all methods are exempt. The decorator then checks if the current request method is in the methods list, and if so, it removes the CSP from the request object.

Here is the code for the csp_update decorator:

from functools import wraps
from django_csp.utils import get_csp_from_request

def csp_update(methods=None, **kwargs):
    """
    Decorator to update CSP directives for specific request methods.
    """
    if not methods:
        methods = []

    def decorator(view_func):
        @wraps(view_func)
        def _wrapped_view(request, *args, **kwargs):
            if request.method in methods:
                # Update the CSP for the current request method
                csp = get_csp_from_request(request)
                for directive, values in kwargs.items():
                    csp[directive] = values
            return view_func(request, *args, **kwargs)

        return _wrapped_view

    return decorator

This decorator takes an optional methods argument, which is a list of HTTP methods that should have their CSP directives updated. If methods is not provided, then all methods are updated.

from django.http import HttpResponse
from django.views import View

from csp_decorators import csp_exempt, csp_update

@csp_exempt(['GET', 'HEAD'])
@csp_update(['POST'], script_src=['https://example.com/])
class MyView(View):
    def get(self, request, *args, kwargs):
        content = "This is a GET request"
        return HttpResponse(content)

    def post(self, request, *args, **kwargs):
        content = "This is a POST request"
        return HttpResponse(content)

In this example, we're defining a MyView class-based view that's exempt from CSP enforcement for GET and HEAD requests using csp_exempt(['GET', 'HEAD']) decorator. We're also updating the script-src directive for POST requests using the csp_update(['POST'], script_src=['https://example.com/]) decorator.

Inside the MyView class, we define two methods, get and post, that return HTTP responses with some plain text content.

from django.test import RequestFactory, TestCase
from django.http import HttpResponse
from django.urls import reverse
from django_csp.middleware import CSPMiddleware
from myapp.views import MyView

class CSPDecoratorsTest(TestCase):
    def setUp(self):
        self.factory = RequestFactory()

    def test_csp_exempt(self):
        # Create a GET request object and pass it through the csp_exempt decorator, which should remove the CSP from the request object
        get_request = self.factory.get(reverse('my-view'))
        get_response = MyView.as_view()(get_request)
        self.assertEqual(get_response.status_code, 200)
        self.assertFalse(hasattr(get_request, 'csp'))

        # Create a POST request object and pass it through the csp_exempt decorator, which should remove the CSP from the request object
        post_request = self.factory.post(reverse('my-view'))
        post_response = MyView.as_view()(post_request)
        self.assertEqual(post_response.status_code, 200)
        self.assertFalse(hasattr(post_request, 'csp'))

    def test_csp_update(self):
        # Create a POST request object and pass it through the csp_update decorator, which should add a nonce to the CSP of the request object
        post_request = self.factory.post(reverse('my-view'))
        post_response = MyView.as_view()(post_request)
        self.assertEqual(post_response.status_code, 200)
        self.assertTrue(hasattr(post_request, 'csp'))
        self.assertIn('nonce-', post_request.csp)
erdimeola commented 1 year ago

Sorry for the late answer.

I think it will be simple to add this after #36 has landed. Though I have to admit, I'm confused about the value proposition here. Can you explain a little bit about why you want to do this @erdimeola?

Payment pages. If GET request return payment page, and POST request gets the information and redirects to 3d secure pages (as html), we need to apply some exemptions or updates to CSP for the payment to start.

@some1ataplace I just updated the decorators.py in github code to check method. This worked for me. Code is below;

example usage: @csp_exempt(methods=["POST"])

from functools import wraps

def valid_method(allowed_methods, request):
    """
        Params:
            allowed_methods:    list or string of method to make the update
            request:            the request for the csp

        Returns:
            True if the method in decorator parameter matches

        Check if any method restriction is available. If not, update is valid.
        If a list of methods is given, iterate through methods to check if request method is in
        restriction
        If only a method is given, check if the request method is the matching one
    """
    if allowed_methods is None:
        return True
    if isinstance(request, tuple):
        request = request[0]
    result = False
    if isinstance(allowed_methods, list):
        for method in allowed_methods:
            if request.method == method:
                result = True
                break
    elif isinstance(allowed_methods, str):
        if request.method == allowed_methods:
            result = True
    return result

def csp_exempt(methods=None, **kwargs):
    """
        csp_exempt
    """
    def decorator(function):
        @wraps(function)
        def _wrapped(*a, **kw):
            response = function(*a, **kw)
            if valid_method(methods, a):
                response._csp_exempt = True
            return response
        return _wrapped
    return decorator

def csp_update(methods=None, **kwargs):
    """
        csp_update
    """
    update = dict((k.lower().replace('_', '-'), v) for k, v in kwargs.items())

    def decorator(function):
        @wraps(function)
        def _wrapped(*a, **kw):
            response = function(*a, **kw)
            if valid_method(methods, a):
                response._csp_update = update
            return response
        return _wrapped
    return decorator

def csp_replace(methods=None, **kwargs):
    """
        csp_replace
    """
    replace = dict((k.lower().replace('_', '-'), v) for k, v in kwargs.items())

    def decorator(function):
        @wraps(function)
        def _wrapped(*a, **kw):
            response = function(*a, **kw)
            if valid_method(methods, a):
                response._csp_replace = replace
            return response
        return _wrapped
    return decorator

def csp(methods=None, **kwargs):
    """
        csp
    """
    config = dict(
        (k.lower().replace('_', '-'), [v] if isinstance(v, str) else v)
        for k, v
        in kwargs.items()
    )

    def decorator(function):
        @wraps(function)
        def _wrapped(*a, **kw):
            response = function(*a, **kw)
            if valid_method(methods, a):
                response._csp_config = config
            return response
        return _wrapped
    return decorator