mozilla / django-csp

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

Allow enforced CSP and Report-Only at the same time #36

Closed qll closed 1 week ago

qll commented 10 years ago

The CSP 1.0 specification allows this explicitly in the sixth paragraph of Section 3.3: http://www.w3.org/TR/CSP/#processing-model An example is given, too: "For example, if a server operator is using one policy but wishes to experiment with a stricter policy, the server operator can monitor the stricter policy while enforcing the original policy."

This would require bigger changes to the way the middleware works (maybe a flag as a parameter to the decorators? ... something like only_report=True). If this is in scope for the middleware, I am willing to propose an implementation soon.

jsocol commented 10 years ago

Definitely in scope, would be happy to support this, but it is going to require a big change, at least in terms of API, we should figure out how we want it to look first. Tagging in @graingert and @mozfreddyb.

One option would be to add a second, parallel set of settings, e.g.:

CSP_DEFAULT_SRC = '...'
CSP_RO_DEFAULT_SRC = '...'
CSP_IMG_SRC = '...'
CSP_RO_IMG_SRC = '...'

Then utils.from_settings would either need a companion (and probably a re-name) or a lot more complexity. The CSP_REPORT_ONLY boolean could still be used to flip the normal settings (i.e. without _RO) to report-only mode, or users could just define the _RO settings instead.

It's verbose, I don't love it. The decorator would still need a keyword to decide which policy is being updated (or both?). But it is a way to do site-wide alternate policies. Doing that via decorator would be painful.

Maybe instead of x number of settings, it makes sense to change it to one setting in a dict, in the style of other complex Django settings? e.g.

CSP_POLICY = {
    'default': {
        'DEFAULT_SRC': "'self'",
        'IMG_SRC': ["'self'", 'img-host.example.com'],
        'REPORT_ONLY': False,
    },
    'test_new_policy': {
        ...,
        'REPORT_ONLY': True,
    }
}

# Optional setting, defaults to 'default'
CSP_POLICIES = ['default', 'test_new_policy']

It's a bigger API change, but maybe lets things be cleaner when building policies? And it condenses, rather than expands, the namespace.

Alternate proposals?

graingert commented 10 years ago

I think you want two policy dicts so as to reflect how the underlying headers operate:

CSP_REPORT_ONLY_POLICY = {
    'DEFAULT_SRC': "'*'",
}
CSP_POLICY = {
     'DEFAULT_SRC': "'self'",
}
jsocol commented 10 years ago

CSP technically does allow multiple policies. Not sure exactly why you'd want to, but allowing n>=1 policy definitions is more in line with that part of the spec.

graingert commented 10 years ago

In that case something like:

CSP_POLICIES = [
        {
            'DEFAULT_SRC': "'*'",
        },
        {
             'DEFAULT_SRC': "'self'",
             'REPORT_ONLY': True
        }
]

Rather than using a dict and defining order in another setting

graingert commented 10 years ago

Also the CSP specification is invalid according to http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2

Multiple message-header fields with the same field-name MAY be present in a message if and only if the entire field-value for that header field is defined as a comma-separated list [i.e., #(values)]. It MUST be possible to combine the multiple header fields into one "field-name: field-value" pair, without changing the semantics of the message, by appending each subsequent field-value to the first, each separated by a comma. The order in which header fields with the same field-name are received is therefore significant to the interpretation of the combined field value, and thus a proxy MUST NOT change the order of these field values when a message is forwarded.

jsocol commented 10 years ago

There is no precedent for an ordered list of dicts in the django settings. A dict allows these to be named, and a separate setting makes it easy to flip them on and off or reorder.

jsocol commented 10 years ago

It's not our job here to resolve issues between RFC2616 and CSP.

mozfreddyb commented 10 years ago

I like @jsocol's original proposal, even though it is a bit verbose, but I prefer explicitly here. It would need some work for current users of django-csp, but there's little we can do?!

qll commented 10 years ago

Yep, I like the nested dict idea, too. However, why use "DEFAULT_SRC" rather than "default-src"? The latter would copy the original header more closely.

jsocol commented 10 years ago

@mozfreddyb do you mean a nested dict or a parallel set of settings?

@qll copying the style of other settings (e.g. DATABASES and CACHES) but you're right, in this case copying the names directly from the header is a more compelling way to do it.

mozfreddyb commented 10 years ago

Nested dict as in your very first comment, @jsocol.

DylanYoung commented 5 years ago

Love the nested dict idea. You should add support to the decorators as well to replace or append policies based on name.

Knowing that there is support in the spec for multiple policies makes me question whether the CSP decorator (@csp) is adequately clear and explicit? It could be append CSP or replace CSP. I'd suggest clearing the existing CSP should be done through one and only one very clear path (@csp_exempt) to avoid user error in a security-critical component of the app (I would rename this: @csp_clear or @csp_purge for clarity).

Does it also make sense to think about plugging into the sites contrib app to allow per site CSP configurations in the DB? (for now, this could be name-based and hardcoded in settings, but eventually dynamic db-based CSPs could be supported...)

And finally a thought on backwards compatibility for users:

You can leave the current settings, just load them into the policy dict under the 'default' key. If the 'default' key is also explicitly defined, throw an error. Set CSP_POLICIES to ['default'] by default ;)

DylanYoung commented 5 years ago

Oh and this issue is super old. Would you like me to take this on?

EnTeQuAk commented 5 years ago

@DylanYoung if you have the time for it, please go for it.

Regarding what you said...

(I would rename this: @csp_clear or @csp_purge for clarity).

Personally, I don't think that's necessary. @...except follows various Django APIs and should be explicit enough imho.

I also don't think we need support for django.contrib.sites - I'd leave that to specific projects to handle dynamically. We shouldn't solve all problems in this library, that'd be out of scope.

Yeah, I like your idea of backwards compatibility. We could first introduce the new policy dictionary but also allow loading of it, if it's specified. That way we can safely deprecate old settings and transition to the new settings format.

Looking forward to reviewing your pull request!

h1771t commented 4 years ago

any update on this? Would be nice to have this in django core, which waits on resolve of this ticket. thanks.

DylanYoung commented 4 years ago

@hiren1771 Thanks for the ping. My time got swallowed by life recently, but I'll see if I can get to this soon.

DylanYoung commented 4 years ago

Got a little start on it. Not quite functional yet.

Couple notes:

NEW settings:

CSP_POLICY_DEFINITIONS (dict) CSP_UPDATE_TEMPLATE (str) CSP_POLICIES (list, tuple)

The template is used for appending policies. It will be used as the "base" of the new policy. Can be set to None to use the provided policy "as-is".

I opted to make csp_update also allow appending. Check out the new definition (9fe25d9) and let me know if it makes sense to y'all.

Legacy settings are still supported and there's a toggle in the code to change their precedence after an initial deprecation period.

h1771t commented 4 years ago

thanks, is this ticket still the right approach to use, considering this comment from the django ticket: https://code.djangoproject.com/ticket/15727#comment:22

DylanYoung commented 4 years ago

@hiren1771 Should be fine I think. Once it's coded, they can fold it into core. I'll keep that in mind while I'm refactoring though.

h1771t commented 4 years ago

Ah I see, this module is also a middleware, and so is SecurityMiddleware. If there is anything I can help with, let me know. I have never worked on django code base but can do general python. Thanks again for picking this ticket up.

DylanYoung commented 4 years ago

@hiren1771 Sweet. I think I'll have a working version tomorrow (maybe you could give it a test and see if it all works and makes sense to you?), but there will be few final decisions to be made.

Pinging @jsocol @graingert @mozfreddyb to try to get some of those discussions rolling. In particular, there are a couple of "per policy" settings that aren't directives, report-only being one. Do we want to differentiate these somehow from the actual directives?

As far as back compatibility goes, I think the easiest thing is to continue supporting mixed case, underscores, and hyphens, then just normalise in the backend. Does that make sense to everyone? (This way we can keep using kwargs too).

DylanYoung commented 4 years ago

Also, I think I need a bit of clarification on the spec. My reading is that multiple comma delineated values are permitted, but only one of each of the two headers. Is that correct?

If not, then I think we have to impose that restriction anyways as I don't think Django's response supports multiple headers with the same key.

DylanYoung commented 4 years ago

All tests are passing now. Haven't written tests for the multi-policy case yet so there might still be some bugs lurking in there. Still have some cleanup to do, but we're close :)

DylanYoung commented 4 years ago

A few notes before I forget and for reference when I go to do the docs:

csp_update can currently append, but csp_replace can not (it's trivial to make it so though if desired) They both take an optional list of names which defaults to ['default'] and any single-policy kwargs will be applied to policies with that key (this preserves back-compat and allows bulk updates). They also take a csp_definitions object (which currently does not support the "single-policy" mode)

The csp decorator works a little differently. Its first argument is an iterable of policies, which can be either a two-tuple (name, csp), a csp dictionary (random name will be applied), or a name which will be looked up both in the currently applied policy definitions and in the base config. kwargs can be passed in for back-compat as well; they will also be assigned a random name.

There is also a new csp_order (open to renaming) decorator that reorders existing policies based on index or name and can also trim off extra policies (though always has at least one: use csp_exempt to remove a policy). Maybe csp_select would be a better name? It works with an iterable of mixed names and indexes.

DylanYoung commented 4 years ago

FYI: I opted to make the "pseudo directives" identical to the old settings but lowercase. I think it's useful to be able to visually disambiguate them via the '-' versus '_' distinction.

(In fact I could even use it in the code instead the lookup dictionary... might be cleaner. Yeah; I'll clean that up. I think there's even some old mechanics in there for when I was going to change the names that will be unnecessary now. Identify pseudo settings: bool(kwarg.lower() in DIRECTIVES) Get all pseudo settings: d for d in DIRECTIVES if '_' in d

Yes yes, that will be much nicer :D )

mozfreddyb commented 4 years ago

Also, I think I need a bit of clarification on the spec. My reading is that multiple comma delineated values are permitted, but only one of each of the two headers. Is that correct?

I don't think I understand your question. Can you rephrase, give an example or a link to the spec text you're talking about? :)

graingert commented 4 years ago

The headers are concatenated like any other multi value header

On Mon, 9 Mar 2020, 08:04 Frederik Braun, notifications@github.com wrote:

Also, I think I need a bit of clarification on the spec. My reading is that multiple comma delineated values are permitted, but only one of each of the two headers. Is that correct?

I don't think I understand your question. Can you rephrase, give an example or a link to the spec text you're talking about? :)

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mozilla/django-csp/issues/36?email_source=notifications&email_token=AADFATBKG4IKFYHVDTFJV6TRGSPKRA5CNFSM4AJ6MI52YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEOGBVOI#issuecomment-596384441, or unsubscribe https://github.com/notifications/unsubscribe-auth/AADFATF7VWKO2Y6PRT4WKFDRGSPKRANCNFSM4AJ6MI5Q .

DylanYoung commented 4 years ago

@graingert That's what I thought. Thanks for the confirmation. Note that there are headers that are permitted multiple times: https://stackoverflow.com/questions/4371328/are-duplicate-http-response-headers-acceptable

Mostly I just wanted to confirm that preserving the order between multiple report-only and regular policies wasn't desired / allowed by the spec. I don't think Django supports it anyways (after a quick look at the response implementation), so the point is kind of moot anyways :D

DylanYoung commented 4 years ago

Any thoughts on the optional space between commas? We can remove it to save a few bytes on the wire. Or keep it in for legibility. Or toggle it based on the DEBUG setting.

h1771t commented 4 years ago

Any thoughts on the optional space between commas? We can remove it to save a few bytes on the wire. Or keep it in for legibility. Or toggle it based on the DEBUG setting.

Does it matter, are the headers not compressed anyway?

DylanYoung commented 4 years ago

@hiren1771 Good point. I'm not 100% on that, but they probably are. I'm just going to leave that as is for now.

liavkoren commented 4 years ago

Hi, I'm just curious what the status of this work is? Is there stuff that needs more hands?

g-k commented 2 years ago

Hi, I'm just curious what the status of this work is? Is there stuff that needs more hands?

Good question. It looks like Casey is pretty far along.

@DylanYoung thanks for working on this! Is #179 your most recent work or do you have any unpushed changes? Was there anything else you were planning to add to the branch?

DylanYoung commented 2 years ago

Sorry all! I almost got this done and then had some life stuff that took me away from it. I'll review my current work tomorrow and let you know. I believe there were just a few last questions to be answered about how we want the API to function and then it should be pretty well there!

Best,

Casey

On Fri, 30 Jul 2021 at 11:30, Greg Guthe @.***> wrote:

Hi, I'm just curious what the status of this work is? Is there stuff that needs more hands?

Good question. It looks like Dylan is pretty far along.

@DylanYoung https://github.com/DylanYoung thanks for working on this! Is #179 https://github.com/mozilla/django-csp/pull/179 your most recent work or do you have any unpushed changes? Was there anything else you were planning to add to the branch?

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mozilla/django-csp/issues/36#issuecomment-889932297, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABMG3FDP45565UKLLNBVBXLT2KZRZANCNFSM4AJ6MI5Q .

g-k commented 2 years ago

@DylanYoung did you get a chance to review your work? do you have any unpushed changes? Thanks!

DylanYoung commented 2 years ago

I took a look at my work and added some notes to the PR for when I get back to it. If anyone has any thoughts on my comments so far, feel free to reply. I think the next step is for me to add some more detailed unit tests and that should clarify how things are working now so we/you can decide if any major design changes are needed (in particular, I got a little bogged down in the decorators and maybe made them a bit overly flexible).

CSP technically does allow multiple policies. Not sure exactly why you'd want to, but allowing n>=1 policy definitions is more in line with that part of the spec.

As for this comment, I'm not sure Django supports this? It's still nice to have multiple policies defined in a central spot though, as then the decorators can just reference them by name.

EDIT: I'm silly. This is already supported in a backhanded way. It's handled by merging the policies together.

DylanYoung commented 2 years ago

I haven't yet checked whether I have any further work on my local (it's on my other computer). At most I think I started on some doc updates. I'll check shortly.

DylanYoung commented 2 years ago

I've rebased this on the latest master, worked out a bunch of kinks, and written a handful of tests. Still needs doc updates, branch cleanup/squash down, and few last design decisions. I've simplified things significantly and instead of complicating the behaviour of the existing decorators, I've added two new ones: csp_select and csp_append which selects policies from those that are available (essentially changing CSP_POLICIES on a per-view basis) and appends a new policy to the currently selected policies, respectively.

Only major functional change remaining is to fold in CSP_EXCLUDE_URL_PREFIXES and then doc updates and I think it should be ready to go.

DylanYoung commented 2 years ago

CSP_EXCLUDE_URL_PREFIXES is folded in now.

Needs:

Nice-to-have:

Decisions:

some1ataplace commented 1 year ago

@DylanYoung - I did not test any of this code and it may contain inaccuracies/errors but this hopefully this helps you get ideas and fulfills your checklist.

Background on report-only:

Content Security Policy (CSP) report-only mode is a feature of content security policies that allows you to test a new policy without actually enforcing it. When you enable CSP report-only mode, the browser will not block any content that violates the policy. Rather, it will send reports summarizing policy violations to a specified report-uri.

In other words, CSP report-only mode provides a way to monitor and debug your content security policy before putting it into full enforcement mode. It enables the web administrators to evaluate the potential impact and effectiveness of their CSP configuration by identifying which resources would be blocked if the policy were in enforcement mode.

The report-uri is the URL to which violation reports should be sent; it is specified as a directive within the CSP header. Developers can use these reports to fine-tune their policy to avoid breaking existing code while still maintaining a secure content delivery. Reporting helps web admins to discover and address any CSP issues with application functionality or 3rd party domains used on the website.

Possible implementation of the updated csp_update view to allow updating report_only:

from django.shortcuts import render, redirect
from django.views.decorators.http import require_POST
from csp.models import CSPReport
from django.utils import timezone
from datetime import timedelta

@require_POST
def csp_update(request):
    """View for updating the CSP policy and report-only mode"""

    # Check if the request contains report-only field and its value is True
    report_only = request.POST.get('report_only') == 'True'

    # Check if the request contains a CSP policy and its value is valid JSON
    csp_policy = request.POST.get('policy')
    if csp_policy:
        try:
            json.loads(csp_policy)
        except ValueError:
            return render(request, 'csp/update.html', {
                'error': 'Invalid CSP policy JSON'
            })

        # save the new policy with the report-only settings
        csp_report = CSPReport.objects.create(
            policy=csp_policy,
            report_only=report_only,
            created_at=timezone.now()
        )

        # delete old reports if they are too old
        too_old = timezone.now() - timedelta(days=30)
        CSPReport.objects.filter(created_at__lt=too_old).delete()

    # redirect back to update page with success message
    return redirect('csp_update_view', success=True)

This implementation checks whether the request contains a report_only field and if it is set to True. If it is, then the middleware is enabled in report-only mode. It also validates the provided CSP policy JSON and saves it along with the report-only settings. Additionally, it deletes old reports that are older than 30 days to prevent the database from growing too large over time.

Finally, the view redirects back to the update page with a success message indicating that the policy has been successfully updated.


Random Unit tests

from django.test import TestCase
from csp.utils import build_policy

class UtilsTestCase(TestCase):
    def test_build_policy_empty_dict(self):
        """Test build_policy with an empty dict"""
        policy = build_policy({})
        self.assertEqual(policy, "")

    def test_build_policy_default_src(self):
        """Test build_policy with a default-src directive"""
        policy = build_policy({"default-src": ["https://example.com/%22]%7D)
        self.assertEqual(policy, "default-src https://example.com/;%22)

    def test_build_policy_multiple_directives(self):
        """Test build_policy with multiple directives"""
        policy = build_policy({
            "default-src": ["https://example.com/"],
            "script-src": ["https://scripts.example.com/"],
            "frame-src": ["https://frames.example.com/"],
        })
        expected_policy = "default-src https://example.com/; " \
            "script-src https://scripts.example.com/; " \
            "frame-src https://frames.example.com/;"
        self.assertEqual(policy, expected_policy)

    def test_build_policy_unsupported_directive_key(self):
       """Test build_policy with an unsupported directive key"""
        with self.assertRaises(ValueError):
            build_policy({"unsupported-src": ["https://example.com/%22]%7D)

    def test_build_policy_unsupported_directive_value(self):
        """Test build_policy with an unsupported directive value"""
        with self.assertRaises(ValueError):
            build_policy({"default-src": ["example.com/"]})

    def test_build_policy_invalid_directive_value(self):
        """Test build_policy with an invalid directive value type"""
        with self.assertRaises(TypeError):
            build_policy({"default-src": "https://example.com/%22%7D)

Util tests for each function in utils.py

def test_normalize_config(self):
    """ 
    Test _normalize_config function
    """
    # Test with empty config
    config = {}
    expected_output = {}
    output = _normalize_config(config)
    self.assertEqual(output, expected_output)

    # Test with incomplete config
    config = {"default-src": ["'self'"], "frame-src": ["'none'"]}
    expected_output = {
        "default-src": ["'self'"],
        "frame-src": ["'none'"],
        "script-src": ["'self'"],
        "style-src": ["'self'"],
        "img-src": ["'self'"],
        "media-src": ["'self'"],
        "font-src": ["'self'"],
        "connect-src": ["'self'"],
        "object-src": ["'none'"],
        "sandbox": [],
        "report-uri": [],
    }
    output = _normalize_config(config)
    self.assertEqual(output, expected_output)

    # Test with complete config
    config = {
        "default-src": ["'self'"],
        "frame-src": ["'none'"],
        "script-src": ["'self'", "https://example.com"],
        "style-src": ["'self'"],
        "img-src": ["'self'"],
        "media-src": ["'self'"],
        "font-src": ["'self'"],
        "connect-src": ["'self'"],
        "object-src": ["'none'"],
        "sandbox": [],
        "report-uri": ["https://example.com/report"],
    }
    expected_output = config
    output = _normalize_config(config)
    self.assertEqual(output, expected_output)
from django.test import TestCase
from csp.utils import build_policy

class BuildPolicyTestCase(TestCase):
    def test_build_policy(self):
        policy = {
            'default-src': ["'self'"],
            'script-src': ["'self'", 'ajax.googleapis.com'],
            'style-src': ["'self'"],
            'font-src': ["'self'", 'ajax.googleapis.com', 'fonts.gstatic.com'],
            'img-src': ["'self'", 'ajax.googleapis.com'],
            'connect-src': ["'self'", 'ajax.googleapis.com'],
            'media-src': ["'self'"],
            'object-src': ["'none'"],
            'frame-src': ["'self'"],
            'sandbox': ['allow-same-origin'],
            'report-uri': ['/csp-violation-report/'],
        }

        result = build_policy(policy)

        expected = (
            "default-src 'self'; "
            "script-src 'self' ajax.googleapis.com; "
            "style-src 'self'; "
            "font-src 'self' ajax.googleapis.com fonts.gstatic.com; "
            "img -src 'self' ajax.googleapis.com; "
            "connect-src 'self' ajax.googleapis.com; "
            "media-src 'self'; "
            "object-src 'none'; "
            "frame-src 'self'; "
            "sandbox allow-same-origin; "
            "report-uri /csp-violation-report/;"
        )

        self.assertEqual(result, expected)
from django.test import TestCase
from csp.utils import _update_policy

class UpdatePolicyTestCase(TestCase):

    def setUp(self):
        self.policy = {
            'default-src': ["'self'"],
            'script-src': ["'none'"],
            'object-src': ["'self'"],
        }

    def test_empty_update(self):
        expected = self.policy.copy()
        result = _update_policy(self.policy, {})
        self.assertDictEqual(result, expected)

    def test_one_source_update(self):
        update = {'script-src': ["'self'"]}
        expected = {
            'default-src': ["'self'"],
            'script-src': ["'self'"],
            'object-src': ["'self'"],
        }
        result = _update_policy(self.policy, update)
        self.assertDictEqual(result, expected)

    def test_multiple_sources_update(self):
        update = {
            'default-src': ["'none'"],
            'frame-src': ["'self'", 'https://example.com/'],
        }
        expected = {
            'default-src': ["'none'"],
            'script-src': ["'none'"],
            'object-src': ["'self'"],
            'frame-src': ["'self'", 'https://example.com/'],
        }
        result = _update_policy(self.policy, update)
        self.assertDictEqual(result, expected)
from django.test import TestCase
from csp.utils import _replace_policy

class ReplacePolicyTestCase(TestCase):
    def test_replace_policy(self):
        old_policy = "default-src 'none';"
        new_policy = "default-src 'self'; script-src 'self';"
        result = _replace_policy(old_policy, new_policy)
        expected = "default-src 'self'; script-src 'self';"
        self.assertEqual(result, expected)

        old_policy = "default-src 'self'; script-src 'self';"
        new_policy = "connect-src 'self'; img-src 'self';"
        result = _replace_policy(old_policy, new_policy)
        expected = "connect-src 'self'; img-src 'self';"
        self.assertEqual(result, expected)
from django.test import TestCase
from csp.utils import _compile_policy

class CompilePolicyTestCase(TestCase):

    def test_compile_policy(self):

        policy = {
            'default-src': [],
            'script-src': ['self', 'unsafe-hashes'],
            'style-src': ['self'],
        }

        expected_result = "default-src 'none'; script-src 'self' 'unsafe-hashes'; style-src 'self'"

        result = _compile_policy(policy)

        self.assertEqual(expected_result, result)
from django.test import TestCase
from csp.utils import kwarg_to_directive

class KwargsToDirectiveTestCase(TestCase):
    def test_kwarg_to_directive(self):
        kwarg_dict = {'default_src': ("'self'", 'example.com', '*.example.com'),
                      'script_src': ("'self'", 'example.org', '*.example.org')}

        expected_directive_str = "default-src 'self' example.com *.example.com; script-src 'self' example.org *.example.org"
        actual_directive_str = kwarg_to_directive(kwarg_dict)

        self.assertEqual(actual_directive_str, expected_directive_str)
from django.test import TestCase
from csp.utils import PolicyNames

class PolicyNamesTest(TestCase):
    def test_policynames_default(self):
        pn = PolicyNames()

        self.assertEqual(pn.script_src, "'self'")
        self.assertEqual(pn.style_src, "'self'")
        self.assertEqual(pn.img_src, "'self'")
        self.assertEqual(pn.connect_src, "'self'")
        self.assertEqual(pn.font_src, "'self'")
        self.assertEqual(pn.base_uri, "'self'")
        self.assertEqual(pn.frame_src, "'self'")
        self.assertEqual(pn.media_src, "'self'")
        self.assertEqual(pn.object_src, "'self'")
        self.assertEqual(pn.plugin_types, "")
        self.assertEqual(pn.report_uri, "/csp-report/")
        self.assertEqual(pn.visible_annotations, "")
        self.assertEqual(pn.use_script_nonce, False)
        self.assertEqual(pn.use_style_nonce, False)
        self.assertEqual(pn.nonce_random, False)

    def test_policynames_custom(self):
        pn = PolicyNames({
            "img_src": "https://example.com/",
            "style_src": "'self' https://cdn.example.com/'",
            "connect_src": "'self' ws://example.com'",
            "font_src": "'self' https://fonts.example.com/'",
            "frame_src": "'self' https://iframe.example.com/'",
            "media_src": "'self' https://media.example.com/'",
            "object-src": "'none'",
            "report_uri": "/my-csp-report"
        })

        self.assertEqual(pn.script_src, "'self'")
        self.assertEqual(pn.style_src, "'self' https://cdn.example.com/%27%22)
        self.assertEqual(pn.img_src, "https://example.com/%22)
        self.assertEqual(pn.connect_src, "'self' ws://example.com'")
        self.assertEqual(pn.font_src, "'self' https://fonts.example.com/%27%22)
        self.assertEqual(pn.base_uri, "'self'")
        self.assertEqual(pn.frame_src, "'self' https://iframe.example.com/%27%22)
        self.assertEqual(pn.media_src, "'self' https://media.example.com/%27%22)
        self.assertEqual(pn.object_src, "'none'")
        self.assertEqual(pn.plugin_types, "")
        self.assertEqual(pn.report_uri, "/my-csp-report")
        self.assertEqual(pn.visible_annotations, "")
from csp.utils import _clean_input_policy
import unittest

class TestCleanInputPolicy(unittest.TestCase):

    def test_clean_input_policy_1(self):
        """Test empty string input"""
        output = _clean_input_policy("")
        self.assertEqual(output, "")

    def test_clean_input_policy_2(self):
        """Test input with only spaces"""
        output = _clean_input_policy("     ")
        self.assertEqual(output, "")

    def test_clean_input_policy_3(self):
        """Test input with valid policy"""
        input_policy = "default-src 'self'; script-src 'self' https://example.com"
        output = _clean_input_policy(input_policy)
        self.assertEqual(output, input_policy)

    def test_clean_input_policy_4(self):
        """Test input with invalid policy"""
        input_policy = "default-src 'self'; invalid-policy; script-src 'self' https://example.com"
        output = _clean_input_policy(input_policy)
        expected_output = "default-src 'self'; script-src 'self' https://example.com"
        self.assertEqual(output, expected_output)
import unittest
from csp.utils import _kwargs_to_directives

class TestKwargsToDirectives(TestCase):
    def test_kwargs_to_directives_success(self):
        input_kwargs = {'height': 100, 'width': 200, 'color': 'red'}
        expected_output = ['height=100', 'width=200', 'color=red']
        self.assertEqual(_kwargs_to_directives(input_kwargs), expected_output)

    def test_kwargs_to_directives_empty_kwargs(self):
        input_kwargs = {}
        expected_output = []
        self.assertEqual(_kwargs_to_directives(input_kwargs), expected_output)

    def test_kwargs_to_directives_non_string_value(self):
        input_kwargs = {'height': 100, 'width': 200, 'color': True}
        expected_output = ['height=100', 'width=200', 'color=True']
        self.assertEqual(_kwargs_to_directives(input_kwargs), expected_output)

    def test_kwargs_to_directives_null_value(self):
        input_kwargs = {'height': 100, 'width': None, 'color': 'red'}
        expected_output = ['height=100', 'width=None', 'color=red']
        self.assertEqual(_kwargs_to_directives(input_kwargs), expected_output)

    def test_kwargs_to_directives_invalid_key(self):
        input_kwargs = {'height': 100, 'width': 200, 123: 'red'}
        with self.assertRaises(TypeError):
            _kwargs_to_directives(input_kwargs)

    def test_kwargs_to_directives_invalid_value(self):
        input_kwargs = {'height': 100, 'width': '200', 'color': None}
        expected_output = ['height=100', 'width=200', 'color=None']
        self.assertEqual(_kwargs_to_directives(input_kwargs), expected_output)
from django.test import TestCase
from csp.utils import _policies_from_names_and_kwargs

class TestUtils(TestCase):
    def test_policies_from_names_and_kwargs(self):
        names = ['default-src', 'script-src']
        kwargs = {'default-src': ["'self'"], 'script-src': ["'self'", 'example.com']}
        policies = _policies_from_names_and_kwargs(names, kwargs)
        expected_policies = {
            'default-src': ["'self'"],
            'script-src': ["'self'", 'example.com']
        }
        self.assertDictEqual(policies, expected_policies)
from django.test import TestCase
from csp.utils import _policies_from_args_and_kwargs

class TestUtils(TestCase):
    def test_policies_from_args_and_kwargs(self):
        args = ('default-src', "'self'")
        kwargs = {'script-src': ["'self'", 'example.com']}
        policies = _policies_from_args_and_kwargs(args, kwargs)
        expected_policies = {
            'default-src': ["'self'"],
            'script-src': ["'self'", 'example.com']
        }
        self.assertDictEqual(policies, expected_policies)
from django.test import TestCase
from csp.utils import _default_attr_mapper

class TestUtils(TestCase):
    def test_default_attr_mapper(self):
        obj = {'foo': 'bar', 'baz': 'qux'}
        attrs = ['foo', 'baz']
        result = _default_attr_mapper(obj, attrs)
        expected_result = {'foo': 'bar', 'baz': 'qux'}
        self.assertDictEqual(result, expected_result)
from django.test import TestCase
from csp.utils import _bool_attr_mapper

class TestUtils(TestCase):
    def test_bool_attr_mapper(self):
        obj = {'foo': True, 'bar': False}
        attrs = ['foo', 'bar']
        result = _bool_attr_mapper(obj, attrs)
        expected_result = {'foo': 'true', 'bar': 'false'}
        self.assertDictEqual(result, expected_result)
import asyncio
from django.test import TestCase
from csp.utils import _async_attr_mapper

class TestUtils(TestCase):
    async def test_async_attr_mapper(self):
        async def async_func():
            await asyncio.sleep(1)
            return 'async_value'

        obj = {'foo': async_func()}
        attrs = ['foo']
        result = await _async_attr_mapper(obj, attrs)
        expected_result = {'foo': 'async_value'}
        self.assertDictEqual(result, expected_result)
from django.test import TestCase
from csp.utils import _unwrap_script

class TestUtils(TestCase):
    def test_unwrap_script(self):
        input_html = '<script>console.log("Hello, world!")</script>'
        expected_output = 'console.log("Hello, world!")'
        result = _unwrap_script(input_html)
        self.assertEqual(result, expected_output)
from django.test import TestCase
from csp.utils import build_script_tag

class TestUtils(TestCase):
    def test_build_script_tag(self):
        input_src = 'https://example.com/script.js'
        expected_output = '<script src="https://example.com/script.js" nonce="abc123"></script>'
        result = build_script_tag(input_src, 'abc123')
        self.assertEqual(result, expected_output)
import os
import re
import unittest
import requests

class TestDependencyUpdates(unittest.TestCase):

    def test_dependency_updates(self):
        repo_path = "https://api.github.com/repos/DylanYoung/django-csp/branches/GH-36"
        response = requests.get(repo_path).json()
        dependencies = response.get("dependencies", {})

        for dep in dependencies:
            current_version = dependencies.get(dep).get("version")
            latest_version = self.get_latest_version(dep)

            message = f"{dep} dependency not updated to latest version"
            self.assertEqual(current_version, latest_version, message)

    def get_latest_version(self, dependency):
        package_name = re.sub(r'[^\w\s]','', dependency.lower())
        package_path = f"https://pypi.org/pypi/{package_name}/json"
        response = requests.get(package_path).json()

        version_numbers = []
        for version_number in response.get("releases", {}).keys():
            version_numbers.append(version_number)
        version_numbers.sort(reverse=True)

        return version_numbers[0]

block-all-mixed-content

The block-all-mixed-content directive code in the django-csp library can be found in the csp.middleware.CSPMiddleware class in the middleware.py file.

Here's an example of how to set the block-all-mixed-content directive in the CSP header using the CSPMiddleware class:

from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import cached_property
from csp.utils import build_policy_string

class CSPMiddleware(MiddlewareMixin):
    @cached_property
    def get_response(self, request):
        csp_header = build_policy_string({
            'default-src': ["'self'"],
            'block-all-mixed-content': True,
        })
        response['Content-Security-Policy'] = csp_header

In this example, the block-all-mixed-content directive is included in the CSP header policy string as a boolean value. This directive instructs the browser to block all HTTP content that is loaded over HTTPS.

class CSPMiddleware(MiddlewareMixin):
    """
    Middleware to add Content-Security-Policy headers to responses.

    See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
    """

    def init(self, get_response=None, kwargs):
        self.get_response = get_response
        self.config = kwargs.get('config', {})
        self.config['report_uri'] = kwargs.get('report_uri', self.config.get('report_uri', None))
        self.config['browser_sniff'] = kwargs.get('browser_sniff', False)
        self.config.update(DEFAULT_SRC=None, SCRIPT_SRC=None, OBJECT_SRC=None, STYLE_SRC=None, IMG_SRC=None, MEDIA_SRC=None,
                            FRAME_SRC=None, FONT_SRC=None, CONNECT_SRC=None, BASE_URI=None, FORM_ACTION=None,
                            REPORT_ONLY=None, BLOCK_ALL_MIXED_CONTENT=None, UPGRADE_INSECURE_REQUESTS=None, SANDBOX=None)

 if 'block_all_mixed_content' in self.config:
            warnings.warn(
                'The "block_all_mixed_content" directive has been deprecated by the latest Content Security Policy '
                'standard. Please remove this directive from your Content Security Policy. To block all mixed content in modern browsers, use the "block-all" directive instead',
                DeprecationWarning)
            self.config.pop('block_all_mixed_content', None)

    def call(self, request):
        response = self.get_response(request)
        if not response.has_header('Content-Security-Policy'):
            csp_header = self.build_header(request)
            response['Content-Security-Policy'] = csp_header
        return response

    def build_header(self, request):
        """
        Build a Content Security Policy header value.
        """
        builder = CSPBuilder(self.config, request)
        return builder.build()

It is generally a good idea to follow current best practices when it comes to web security. The block-all-mixed-content directive is now deprecated because it can sometimes lead to compatibility issues with certain websites that incorporate both http and https resources.

In its place, the block-all directive should be used to block all mixed content by default. This directive will ensure that browsers block insecure content automatically while allowing secure content to load normally.

Therefore, deprecating the block-all-mixed-content directive and replacing it with the block-all directive is a good security measure and aligns with the latest security standards.


Outdated Code

To locate outdated compatibility code in the django-csp library, you can search for code that references deprecated or removed features, such as django.conf.urls.defaults, django.utils.importlib, or django.core.urlresolvers. You can also look for code that uses older syntax or patterns that have been replaced with newer, more efficient alternatives.

It is generally a good idea to remove outdated code in any software project, including the django-csp package. Outdated code often refers to code that is no longer maintained, has been replaced with better alternatives or is no longer compatible with newer versions of the software platform or libraries. This code can cause problems like security vulnerabilities, performance issues, and functionality problems. Additionally, removing outdated code can improve the maintainability of the codebase, make it easier to introduce new features, and make it more understandable for developers who are new to the project. However, careful consideration and testing should be taken before removing any code to ensure that it will not break existing functionality or cause other issues.


Doing something like the original comments of this thread

from csp.middleware import CSPMiddleware
from csp.utils import build_policy

class MyCSPMiddleware(CSPMiddleware):

    def __init__(self, get_response=None):
        self.policies = []
        # CSP_POLICIES is a list of dictionaries
        for policy in settings.CSP_POLICIES:
            self.add_policy(policy)
        super().__init__(get_response)

    def add_policy(self, policy):
        """Add a policy to be enforced"""
        report_only = policy.pop('REPORT_ONLY', False)
        csp_policy = build_policy(policy)
        self.policies.append((report_only, csp_policy))

    def process_response(self, request, response):
        """Add CSP headers to the response"""
        for report_only, policy in self.policies:
            response._csp_policy = policy
            response.report_only = report_only
        return super().process_response(request, response)

With this implementation, the MyCSPMiddleware class extends the CSPMiddleware class provided by django-csp. In the constructor, the CSP_POLICIES list defined in settings.py is processed and each policy is added to a list of policies to be enforced. The add_policy() method appends policies to the policy list by creating a new policy object using the csp.utils.build_policy() method.

In the process_response() method, the policies are added to the response object by setting the _csp_policy attribute to the policy object, and the report_only attribute to the value of the REPORT_ONLY parameter in the policy dictionary. Finally, the process_response() method of the parent class is called to complete the response processing.

This implementation allows for multiple CSP policies to be defined in the CSP_POLICIES settings in settings.py, and these policies will be added to the response headers for every request processed by the middleware.

CSP_POLICIES = [
    {
        'default-src': ['self'],
        'img-src': ['*', 'data:'],
        'script-src': ['self', 'ajax.googleapis.com'],
        'style-src': ['self']
    },
    {
        'default-src': ['none'],
        'connect-src': ['self'],
        'img-src': ['self', 'data:'],
        'script-src': ['self'],
        'style-src': ['self']
    }
]

MIDDLEWARE = [
    # ...
    'path.to.MyCSPMiddleware',
    # ...
]

In this example, CSP_POLICIES is a list of two policy dictionaries, each defining a different policy. Each policy dictionary contains directives and sources that specify the allowed sources for various types of content. In this example, the first policy allows content from the local domain, Google's AJAX API, and data URIs for images. The second policy allows content only from the local domain and does not allow any content by default.

The `MyCSPMiddleware middleware is added to the MIDDLEWARE setting to process the policies defined in CSP_POLICIES and add them to the response headers.

With this configuration, any responses generated by Django will contain the CSP header specifying the policies defined in CSP_POLICIES. Any violations against these policies will be logged as specified in the policy. This helps to reduce the risk of cross-site scripting (XSS) attacks and safeguarding the application from other potential security exploits.

robhudson commented 1 month ago

I've opened a PR that addresses this in #219 and am looking for feedback. This is a backwards incompatible change and we plan to release this with a major version bump to 4.0 to signify this. I feel the settings change should be straight-forward and hopefully not a difficult upgrade path.

To @DylanYoung:

I would very much welcome any review and feedback. Thank you.

some1ataplace commented 1 week ago

@robhudson @stevejalim Thank you for all your efforts on this. It has been a long time coming.

I see that you responded to this ticket in hopes of getting content security policy built into django core:

https://code.djangoproject.com/ticket/15727

Just putting that link here for visibility, since it is not the easiest thing to find.