diff --git a/csp_advanced/__init__.py b/csp_advanced/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/csp_advanced/admin.py b/csp_advanced/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/csp_advanced/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/csp_advanced/apps.py b/csp_advanced/apps.py new file mode 100644 index 0000000..fffe2ef --- /dev/null +++ b/csp_advanced/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class CspAdvancedConfig(AppConfig): + name = 'csp_advanced' diff --git a/csp_advanced/csp.py b/csp_advanced/csp.py new file mode 100644 index 0000000..142d6c8 --- /dev/null +++ b/csp_advanced/csp.py @@ -0,0 +1,130 @@ +from itertools import chain + + +class InvalidCSPError(ValueError): + pass + + +class CSPCompiler(object): + CSP_LISTS = { + # Fetch directives: + 'connect-src', + 'child-src', + 'default-src', + 'font-src', + 'frame-src', + 'img-src', + 'manifest-src', + 'media-src', + 'object-src', + 'script-src', + 'style-src', + 'worker-src', + + # Navigation directives: + 'form-action', + 'frame-ancestors', + + # Document directives: + 'base-uri', + 'plugin-types', + } + + CSP_BOOLEAN = { + 'upgrade-insecure-requests', + 'block-all-mixed-content', + } + + CSP_FETCH_SPECIAL = { + 'self', + 'none', + 'unsafe-inline', + 'unsafe-eval', + 'strict-dynamic', + } + + CSP_PREFIX_SPECIAL = ( + 'nonce-', + 'sha256-', + 'sha384-', + 'sha512-' + ) + + CSP_SANDBOX_VALID = { + 'allow-forms', + 'allow-modals', + 'allow-orientation-lock', + 'allow-pointer-lock', + 'allow-popups', + 'allow-popups-to-escape-sandbox', + 'allow-presentation', + 'allow-same-origin', + 'allow-scripts', + 'allow-top-navigation', + } + + CSP_REQUIRE_SRI_VALID = { + 'script', + 'style', + 'script style', + } + + def __init__(self, csp_dict): + self.csp = csp_dict + + def compile(self): + pieces = [] + for name, value in self.csp.iteritems(): + if name in self.CSP_LISTS: + if value: + pieces.append(self.compile_list(name, value)) + elif name in self.CSP_BOOLEAN: + if value: + pieces.append(name) + elif name == 'sandbox': + if value: + pieces.append(self.compile_sandbox(value)) + elif name == 'report-uri': + pieces.append(self.compile_report_uri(value)) + elif name == 'require-sri-for': + pieces.append(self.compile_require_sri_for(value)) + else: + raise InvalidCSPError('Unknown directive: %s' % (name,)) + return '; '.join(pieces) + + def compile_list(self, name, value_list): + self.ensure_list(name, value_list) + values = [name] + for value in value_list: + if value in self.CSP_FETCH_SPECIAL or value.startswith(self.CSP_PREFIX_SPECIAL): + values.append("'%s'" % value) + else: + values.append(value) + return ' '.join(values) + + def compile_sandbox(self, values): + self.ensure_list('sandbox', values) + for value in values: + if value not in self.CSP_SANDBOX_VALID: + raise InvalidCSPError('Unknown sandbox value: %s' % (value,)) + return ' '.join(chain(['sandbox'], values)) + + def compile_report_uri(self, value): + self.ensure_str('report-uri', value) + return 'report-uri %s' % value + + def compile_require_sri_for(self, value): + self.ensure_str('require-sri-for', value) + if value not in self.CSP_REQUIRE_SRI_VALID: + raise InvalidCSPError('Unknown require-sri-for value: %s' % (value,)) + return 'require-sri-for %s' % value + + @staticmethod + def ensure_list(name, value): + if not isinstance(value, (list, tuple, set)): + raise InvalidCSPError('Values for %s must be list-like type, not %s', (name, type(value))) + + @staticmethod + def ensure_str(name, value): + if not isinstance(value, basestring): + raise InvalidCSPError('Values for %s must be a string type, not %s', (name, type(value))) diff --git a/csp_advanced/middleware.py b/csp_advanced/middleware.py new file mode 100644 index 0000000..e69de29 diff --git a/csp_advanced/migrations/__init__.py b/csp_advanced/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/csp_advanced/models.py b/csp_advanced/models.py new file mode 100644 index 0000000..bd4b2ab --- /dev/null +++ b/csp_advanced/models.py @@ -0,0 +1,5 @@ +from __future__ import unicode_literals + +from django.db import models + +# Create your models here. diff --git a/csp_advanced/tests.py b/csp_advanced/tests.py new file mode 100644 index 0000000..9002a4a --- /dev/null +++ b/csp_advanced/tests.py @@ -0,0 +1,75 @@ +from collections import OrderedDict + +from django.test import SimpleTestCase + +from csp import CSPCompiler, InvalidCSPError + + +class CSPCompileTest(SimpleTestCase): + def test_fetch(self): + self.assertEqual(CSPCompiler({ + 'script-src': ['self', 'https://dmoj.ca', 'nonce-123'], + }).compile(), "script-src 'self' https://dmoj.ca 'nonce-123'") + + with self.assertRaises(InvalidCSPError): + CSPCompiler({ + 'script-src': 'https://dmoj.ca', + }).compile() + + def test_sandbox(self): + self.assertEqual(CSPCompiler({ + 'sandbox': ['allow-same-origin', 'allow-scripts'], + }).compile(), "sandbox allow-same-origin allow-scripts") + + with self.assertRaises(InvalidCSPError): + CSPCompiler({ + 'sandbox': ['allow-invalid', 'allow-scripts'], + }).compile() + + with self.assertRaises(InvalidCSPError): + CSPCompiler({ + 'sandbox': 'allow-scripts', + }).compile() + + def test_report_uri(self): + self.assertEqual(CSPCompiler({ + 'report-uri': '/dev/null', + }).compile(), "report-uri /dev/null") + + with self.assertRaises(InvalidCSPError): + CSPCompiler({'report-uri': []}).compile() + + def test_require_sri_for(self): + self.assertEqual(CSPCompiler({ + 'require-sri-for': 'script style', + }).compile(), "require-sri-for script style") + + with self.assertRaises(InvalidCSPError): + CSPCompiler({'require-sri-for': []}).compile() + + with self.assertRaises(InvalidCSPError): + CSPCompiler({'require-sri-for': 'bad'}).compile() + + def test_upgrade_insecure_requests(self): + self.assertEqual(CSPCompiler({ + 'upgrade-insecure-requests': True, + }).compile(), "upgrade-insecure-requests") + + self.assertEqual(CSPCompiler({ + 'upgrade-insecure-requests': False, + }).compile(), '') + + def test_integration(self): + self.assertEqual(CSPCompiler(OrderedDict([ + ('style-src', ['self']), + ('script-src', ['self', 'https://dmoj.ca']), + ('frame-src', ['none']), + ('plugin-types', ['application/pdf']), + ('block-all-mixed-content', True), + ('upgrade-insecure-requests', False), + ('sandbox', ['allow-scripts']), + ('report-uri', '/dev/null') + ])).compile(), + "style-src 'self'; script-src 'self' https://dmoj.ca; frame-src 'none'; " + "plugin-types application/pdf; block-all-mixed-content; sandbox allow-scripts; " + "report-uri /dev/null") diff --git a/csp_advanced/views.py b/csp_advanced/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/csp_advanced/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.