From d2a460b9c38ac77eb988f07d218fd0204afe4730 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Fri, 21 Jun 2024 17:18:41 -0500 Subject: [PATCH 01/15] Update tox.ini for Django 3.2, pypy, gh * Specify how to install Django 3.2. These tests are currently running against later Django versions. * Add basepython entries for pypy. This fixes running tox locally. * Fix the cpython version mapping in [gh-actions]. The github action tests for cpython versions are running against the latest Django, instead of the set of possible Django versions. --- tox.ini | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index a5b476c..7ee7f1d 100644 --- a/tox.ini +++ b/tox.ini @@ -32,10 +32,13 @@ basepython = 3.10: python3.10 3.11: python3.11 3.12: python3.12 - pypy3: pypy3 + pypy38: pypy3.8 + pypy39: pypy3.9 + pypy310: pypy3.10 deps = pytest + 3.2.x: Django>=3.2,<3.3 4.2.x: Django>=4.2,<4.3 5.0.x: Django>=5.0.1,<5.1 main: https://github.com/django/django/archive/main.tar.gz @@ -45,11 +48,11 @@ deps = # Running tox in GHA without redefining it all in a GHA matrix: # https://github.com/ymyzk/tox-gh-actions python = - 3.8: py38 - 3.9: py39 - 3.10: py310 - 3.11: py311 - 3.12: py312 + 3.8: 3.8 + 3.9: 3.9 + 3.10: 3.10 + 3.11: 3.11 + 3.12: 3.12 pypy-3.8: pypy38 pypy-3.9: pypy39 pypy-3.10: pypy310 From f4f83ad202c89af0d5d973d36bc73d2fa4197578 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Fri, 21 Jun 2024 17:46:15 -0500 Subject: [PATCH 02/15] Remove pypy from Django 3.2 testing There are a few test failures with pypy and Django 3.2. Since 3.2 is out of long-term support, drop these from the test matrix. --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 7ee7f1d..fde744a 100644 --- a/tox.ini +++ b/tox.ini @@ -4,12 +4,12 @@ envlist = {3.10,3.11,3.12,pypy310}-main {3.10,3.11,3.12,pypy310}-5.0.x {3.8,3.9,3.10,3.11,3.12,pypy38,pypy39,pypy310}-4.2.x - {3.8,3.9,3.10,pypy38,pypy39,pypy310}-3.2.x + {3.8,3.9,3.10}-3.2.x # Don't run coverage when testing with pypy: # see https://github.com/nedbat/coveragepy/issues/1382 -[testenv:pypy310-main,pypy310-5.0.x,{pypy38,pypy39,pypy310}-4.2.x,{pypy38,pypy39,pypy310}-3.2.x] +[testenv:pypy310-main,pypy310-5.0.x,{pypy38,pypy39,pypy310}-4.2.x] commands = pip install --upgrade pip pip install -e .[tests] From 441fdf9a159d3639e0ea801035ca9f472105080c Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Fri, 21 Jun 2024 19:53:17 -0500 Subject: [PATCH 03/15] Add mypy for type checking --- docs/conf.py | 3 ++- pyproject.toml | 15 +++++++++++++++ tox.ini | 6 ++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index dc1ebe9..df209e5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,6 +11,7 @@ # serve to show the default. import pkg_resources +from typing import Dict # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -171,7 +172,7 @@ # -- Options for LaTeX output -------------------------------------------------- -latex_elements = { +latex_elements: Dict[str, str] = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). diff --git a/pyproject.toml b/pyproject.toml index 677c19d..dc21778 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,14 @@ optional-dependencies.tests = [ "pytest-django", "pytest-ruff", ] +optional-dependencies.typing = [ + "jinja2>=2.9.6", + "pytest", + "pytest-django", + "mypy", + "types-setuptools", + "django-stubs[compatible-mypy]", +] urls."Bug Tracker" = "https://github.com/mozilla/django-csp/issues" urls.Changelog = "https://github.com/mozilla/django-csp/blob/main/CHANGES" urls.Documentation = "http://django-csp.readthedocs.org/" @@ -66,3 +74,10 @@ find = { namespaces = false } [tool.pytest.ini_options] addopts = "-vs --tb=short --ruff --ruff-format" DJANGO_SETTINGS_MODULE = "csp.tests.settings" + +[tool.mypy] +plugins = ["mypy_django_plugin.main"] +exclude = ['^build/lib'] + +[tool.django-stubs] +django_settings_module = "csp.tests.settings" diff --git a/tox.ini b/tox.ini index fde744a..02f709b 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = {3.10,3.11,3.12,pypy310}-5.0.x {3.8,3.9,3.10,3.11,3.12,pypy38,pypy39,pypy310}-4.2.x {3.8,3.9,3.10}-3.2.x + {3.8,3.9,3.10,3.11,3.12,pypy38,pypy39,pypy310}-types # Don't run coverage when testing with pypy: @@ -15,6 +16,11 @@ commands = pip install -e .[tests] pytest {toxinidir}/csp +[testenv:{3.8,3.9,3.10,3.11,3.12,pypy38,pypy39,pypy310}-types] +commands = + pip install --upgrade pip + pip install -e .[typing] + mypy --cache-dir {temp_dir}/.mypy_cache {toxinidir}/csp [testenv] setenv = From 3acd7b7ee60ccbedd15b12629cea2fc254d2bc42 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Mon, 24 Jun 2024 09:56:17 -0500 Subject: [PATCH 04/15] Handle case where config is None --- csp/decorators.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/csp/decorators.py b/csp/decorators.py index 3d71b6c..ccd6d18 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -74,16 +74,19 @@ def csp(config=None, REPORT_ONLY=False, **kwargs): if config is None and kwargs: raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp")) - config = {k: [v] if isinstance(v, str) else v for k, v in config.items()} + if config is None: + processed_config = {} + else: + processed_config = {k: [v] if isinstance(v, str) else v for k, v in config.items()} def decorator(f): @wraps(f) def _wrapped(*a, **kw): resp = f(*a, **kw) if REPORT_ONLY: - resp._csp_config_ro = config + resp._csp_config_ro = processed_config else: - resp._csp_config = config + resp._csp_config = processed_config return resp return _wrapped From 585444119835cb077b143dd6a0b685eb244cd602 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Mon, 24 Jun 2024 09:56:44 -0500 Subject: [PATCH 05/15] Use getattr, setattr for dynamic attribute access mypy complains when reading or setting a attribute that is not defined on the class, such as HttpRequest.csp_nonce. This updates the code to use getattr and setattr to access these dynamically added attributes and for Django settings. --- csp/checks.py | 14 +++++++------- csp/decorators.py | 16 ++++++++-------- csp/middleware.py | 17 +++++++++++------ csp/templatetags/csp.py | 6 ++++-- csp/tests/test_context_processors.py | 2 +- csp/tests/test_decorators.py | 26 +++++++++++++------------- csp/tests/test_middleware.py | 14 +++++++------- csp/tests/utils.py | 2 +- 8 files changed, 52 insertions(+), 45 deletions(-) diff --git a/csp/checks.py b/csp/checks.py index 8a79fa6..7e55c80 100644 --- a/csp/checks.py +++ b/csp/checks.py @@ -45,16 +45,16 @@ def migrate_settings(): config = { "DIRECTIVES": {}, } - REPORT_ONLY = False - if hasattr(settings, "CSP_REPORT_ONLY"): - REPORT_ONLY = settings.CSP_REPORT_ONLY + REPORT_ONLY = getattr(settings, "CSP_REPORT_ONLY", False) - if hasattr(settings, "CSP_EXCLUDE_URL_PREFIXES"): - config["EXCLUDE_URL_PREFIXES"] = settings.CSP_EXCLUDE_URL_PREFIXES + _EXCLUDE_URL_PREFIXES = getattr(settings, "CSP_EXCLUDE_URL_PREFIXES", None) + if _EXCLUDE_URL_PREFIXES is not None: + config["EXCLUDE_URL_PREFIXES"] = _EXCLUDE_URL_PREFIXES - if hasattr(settings, "CSP_REPORT_PERCENTAGE"): - config["REPORT_PERCENTAGE"] = round(settings.CSP_REPORT_PERCENTAGE * 100) + _REPORT_PERCENTAGE = getattr(settings, "CSP_REPORT_PERCENTAGE", None) + if _REPORT_PERCENTAGE is not None: + config["REPORT_PERCENTAGE"] = round(_REPORT_PERCENTAGE * 100) include_nonce_in = getattr(settings, "CSP_INCLUDE_NONCE_IN", []) diff --git a/csp/decorators.py b/csp/decorators.py index ccd6d18..085a7f1 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -15,9 +15,9 @@ def decorator(f): def _wrapped(*a, **kw): resp = f(*a, **kw) if REPORT_ONLY: - resp._csp_exempt_ro = True + setattr(resp, "_csp_exempt_ro", True) else: - resp._csp_exempt = True + setattr(resp, "_csp_exempt", True) return resp return _wrapped @@ -41,9 +41,9 @@ def decorator(f): def _wrapped(*a, **kw): resp = f(*a, **kw) if REPORT_ONLY: - resp._csp_update_ro = config + setattr(resp, "_csp_update_ro", config) else: - resp._csp_update = config + setattr(resp, "_csp_update", config) return resp return _wrapped @@ -60,9 +60,9 @@ def decorator(f): def _wrapped(*a, **kw): resp = f(*a, **kw) if REPORT_ONLY: - resp._csp_replace_ro = config + setattr(resp, "_csp_replace_ro", config) else: - resp._csp_replace = config + setattr(resp, "_csp_replace", config) return resp return _wrapped @@ -84,9 +84,9 @@ def decorator(f): def _wrapped(*a, **kw): resp = f(*a, **kw) if REPORT_ONLY: - resp._csp_config_ro = processed_config + setattr(resp, "_csp_config_ro", processed_config) else: - resp._csp_config = processed_config + setattr(resp, "_csp_config", processed_config) return resp return _wrapped diff --git a/csp/middleware.py b/csp/middleware.py index 11bf799..f0733d3 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -23,13 +23,16 @@ class CSPMiddleware(MiddlewareMixin): def _make_nonce(self, request): # Ensure that any subsequent calls to request.csp_nonce return the same value - if not getattr(request, "_csp_nonce", None): - request._csp_nonce = base64.b64encode(os.urandom(16)).decode("ascii") - return request._csp_nonce + stored_nonce = getattr(request, "_csp_nonce", None) + if isinstance(stored_nonce, str): + return stored_nonce + nonce = base64.b64encode(os.urandom(16)).decode("ascii") + setattr(request, "_csp_nonce", nonce) + return nonce def process_request(self, request): nonce = partial(self._make_nonce, request) - request.csp_nonce = SimpleLazyObject(nonce) + setattr(request, "csp_nonce", SimpleLazyObject(nonce)) def process_response(self, request, response): # Check for debug view @@ -45,7 +48,8 @@ def process_response(self, request, response): # Only set header if not already set and not an excluded prefix and not exempted. is_not_exempt = getattr(response, "_csp_exempt", False) is False no_header = HEADER not in response - prefixes = getattr(settings, "CONTENT_SECURITY_POLICY", {}).get("EXCLUDE_URL_PREFIXES", ()) + policy = getattr(settings, "CONTENT_SECURITY_POLICY", None) or {} + prefixes = policy.get("EXCLUDE_URL_PREFIXES", None) or () is_not_excluded = not request.path_info.startswith(prefixes) if all((no_header, is_not_exempt, is_not_excluded)): response[HEADER] = csp @@ -55,7 +59,8 @@ def process_response(self, request, response): # Only set header if not already set and not an excluded prefix and not exempted. is_not_exempt = getattr(response, "_csp_exempt_ro", False) is False no_header = HEADER_REPORT_ONLY not in response - prefixes = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", {}).get("EXCLUDE_URL_PREFIXES", ()) + policy = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", None) or {} + prefixes = policy.get("EXCLUDE_URL_PREFIXES", None) or () is_not_excluded = not request.path_info.startswith(prefixes) if all((no_header, is_not_exempt, is_not_excluded)): response[HEADER_REPORT_ONLY] = csp_ro diff --git a/csp/templatetags/csp.py b/csp/templatetags/csp.py index d31a921..bb8e200 100644 --- a/csp/templatetags/csp.py +++ b/csp/templatetags/csp.py @@ -31,12 +31,14 @@ def __init__(self, nodelist, **kwargs): self.script_attrs[k] = self._get_token_value(v) def _get_token_value(self, t): - return _unquote(t.token) if getattr(t, "token", None) else None + if hasattr(t, "token") and t.token: + return _unquote(t.token) + return None def render(self, context): output = self.nodelist.render(context).strip() request = context.get("request") - nonce = request.csp_nonce if hasattr(request, "csp_nonce") else "" + nonce = getattr(request, "csp_nonce", "") self.script_attrs.update({"nonce": nonce, "content": output}) return build_script_tag(**self.script_attrs) diff --git a/csp/tests/test_context_processors.py b/csp/tests/test_context_processors.py index 666f53f..0203571 100644 --- a/csp/tests/test_context_processors.py +++ b/csp/tests/test_context_processors.py @@ -17,7 +17,7 @@ def test_nonce_context_processor(): response = HttpResponse() mw.process_response(request, response) - assert context["CSP_NONCE"] == request.csp_nonce + assert context["CSP_NONCE"] == getattr(request, "csp_nonce") def test_nonce_context_processor_with_middleware_disabled(): diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index d75819d..7dc4a38 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -17,7 +17,7 @@ def view(request): return HttpResponse() response = view(RequestFactory().get("/")) - assert response._csp_exempt is True + assert getattr(response, "_csp_exempt") is True assert not hasattr(response, "_csp_exempt_ro") @@ -28,7 +28,7 @@ def view(request): response = view(RequestFactory().get("/")) assert not hasattr(response, "_csp_exempt") - assert response._csp_exempt_ro is True + assert getattr(response, "_csp_exempt_ro") is True @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["foo.com"]}}) @@ -49,13 +49,13 @@ def view_with_decorator(request): return HttpResponse() response = view_with_decorator(request) - assert response._csp_update == {"img-src": ["bar.com", NONCE]} + assert getattr(response, "_csp_update") == {"img-src": ["bar.com", NONCE]} mw.process_request(request) - assert request.csp_nonce # Here to trigger the nonce creation. + assert getattr(request, "csp_nonce") # Here to trigger the nonce creation. mw.process_response(request, response) assert HEADER_REPORT_ONLY not in response.headers policy_list = sorted(response[HEADER].split("; ")) - assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{request.csp_nonce}'"] + assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{getattr(request, 'csp_nonce')}'"] response = view_without_decorator(request) mw.process_response(request, response) @@ -82,13 +82,13 @@ def view_with_decorator(request): return HttpResponse() response = view_with_decorator(request) - assert response._csp_update_ro == {"img-src": ["bar.com", NONCE]} + assert getattr(response, "_csp_update_ro") == {"img-src": ["bar.com", NONCE]} mw.process_request(request) - assert request.csp_nonce # Here to trigger the nonce creation. + assert getattr(request, "csp_nonce") # Here to trigger the nonce creation. mw.process_response(request, response) assert HEADER not in response.headers policy_list = sorted(response[HEADER_REPORT_ONLY].split("; ")) - assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{request.csp_nonce}'"] + assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{getattr(request, 'csp_nonce')}'"] response = view_without_decorator(request) mw.process_response(request, response) @@ -115,7 +115,7 @@ def view_with_decorator(request): return HttpResponse() response = view_with_decorator(request) - assert response._csp_replace == {"img-src": ["bar.com"]} + assert getattr(response, "_csp_replace") == {"img-src": ["bar.com"]} mw.process_response(request, response) assert HEADER_REPORT_ONLY not in response.headers policy_list = sorted(response[HEADER].split("; ")) @@ -156,7 +156,7 @@ def view_with_decorator(request): return HttpResponse() response = view_with_decorator(request) - assert response._csp_replace_ro == {"img-src": ["bar.com"]} + assert getattr(response, "_csp_replace_ro") == {"img-src": ["bar.com"]} mw.process_response(request, response) assert HEADER not in response.headers policy_list = sorted(response[HEADER_REPORT_ONLY].split("; ")) @@ -196,7 +196,7 @@ def view_with_decorator(request): return HttpResponse() response = view_with_decorator(request) - assert response._csp_config == {"img-src": ["foo.com"], "font-src": ["bar.com"]} + assert getattr(response, "_csp_config") == {"img-src": ["foo.com"], "font-src": ["bar.com"]} mw.process_response(request, response) assert HEADER_REPORT_ONLY not in response.headers policy_list = sorted(response[HEADER].split("; ")) @@ -227,7 +227,7 @@ def view_with_decorator(request): return HttpResponse() response = view_with_decorator(request) - assert response._csp_config_ro == {"img-src": ["foo.com"], "font-src": ["bar.com"]} + assert getattr(response, "_csp_config_ro") == {"img-src": ["foo.com"], "font-src": ["bar.com"]} mw.process_response(request, response) assert HEADER not in response.headers policy_list = sorted(response[HEADER_REPORT_ONLY].split("; ")) @@ -249,7 +249,7 @@ def view_with_decorator(request): return HttpResponse() response = view_with_decorator(request) - assert response._csp_config == {"img-src": ["foo.com"], "font-src": ["bar.com"]} + assert getattr(response, "_csp_config") == {"img-src": ["foo.com"], "font-src": ["bar.com"]} mw.process_response(request, response) policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["font-src bar.com", "img-src foo.com"] diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index f8fad35..3c3178a 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -72,7 +72,7 @@ def test_dont_replace(): def test_use_config(): request = rf.get("/") response = HttpResponse() - response._csp_config = {"default-src": ["example.com"]} + setattr(response, "_csp_config", {"default-src": ["example.com"]}) mw.process_response(request, response) assert response[HEADER] == "default-src example.com" @@ -80,7 +80,7 @@ def test_use_config(): def test_use_update(): request = rf.get("/") response = HttpResponse() - response._csp_update = {"default-src": ["example.com"]} + setattr(response, "_csp_update", {"default-src": ["example.com"]}) mw.process_response(request, response) assert response[HEADER] == "default-src 'self' example.com" @@ -89,7 +89,7 @@ def test_use_update(): def test_use_replace(): request = rf.get("/") response = HttpResponse() - response._csp_replace = {"img-src": ["bar.com"]} + setattr(response, "_csp_replace", {"img-src": ["bar.com"]}) mw.process_response(request, response) policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'", "img-src bar.com"] @@ -114,7 +114,7 @@ def test_debug_notfound_exempt(): def test_nonce_created_when_accessed(): request = rf.get("/") mw.process_request(request) - nonce = str(request.csp_nonce) + nonce = str(getattr(request, "csp_nonce")) response = HttpResponse() mw.process_response(request, response) assert nonce in response[HEADER] @@ -133,9 +133,9 @@ def test_nonce_regenerated_on_new_request(): request2 = rf.get("/") mw.process_request(request1) mw.process_request(request2) - nonce1 = str(request1.csp_nonce) - nonce2 = str(request2.csp_nonce) - assert request1.csp_nonce != request2.csp_nonce + nonce1 = str(getattr(request1, "csp_nonce")) + nonce2 = str(getattr(request2, "csp_nonce")) + assert nonce1 != nonce2 response1 = HttpResponse() response2 = HttpResponse() diff --git a/csp/tests/utils.py b/csp/tests/utils.py index 2d96b1c..3896219 100644 --- a/csp/tests/utils.py +++ b/csp/tests/utils.py @@ -33,7 +33,7 @@ def process_templates(self, tpl, expected): ctx = self.make_context(request) return ( self.make_template(tpl).render(ctx).strip(), - expected.format(request.csp_nonce), + expected.format(getattr(request, "csp_nonce")), ) From abf8c1bd743c2f81669f84bd763e697fe2438cc1 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Mon, 24 Jun 2024 15:48:34 -0500 Subject: [PATCH 06/15] Use tuples where requested Both startswith() and parser.parse_statements take a tuple rather than a list. --- csp/extensions/__init__.py | 2 +- csp/middleware.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/csp/extensions/__init__.py b/csp/extensions/__init__.py index 2e152b8..36bfd91 100644 --- a/csp/extensions/__init__.py +++ b/csp/extensions/__init__.py @@ -26,7 +26,7 @@ def parse(self, parser): # now we parse the body of the script block up to `endscript` and # drop the needle (which would always be `endscript` in that case) - body = parser.parse_statements(["name:endscript"], drop_needle=True) + body = parser.parse_statements(("name:endscript",), drop_needle=True) # now return a `CallBlock` node that calls our _render_script # helper method on this extension. diff --git a/csp/middleware.py b/csp/middleware.py index f0733d3..48abbe4 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -50,7 +50,7 @@ def process_response(self, request, response): no_header = HEADER not in response policy = getattr(settings, "CONTENT_SECURITY_POLICY", None) or {} prefixes = policy.get("EXCLUDE_URL_PREFIXES", None) or () - is_not_excluded = not request.path_info.startswith(prefixes) + is_not_excluded = not request.path_info.startswith(tuple(prefixes)) if all((no_header, is_not_exempt, is_not_excluded)): response[HEADER] = csp @@ -61,7 +61,7 @@ def process_response(self, request, response): no_header = HEADER_REPORT_ONLY not in response policy = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", None) or {} prefixes = policy.get("EXCLUDE_URL_PREFIXES", None) or () - is_not_excluded = not request.path_info.startswith(prefixes) + is_not_excluded = not request.path_info.startswith(tuple(prefixes)) if all((no_header, is_not_exempt, is_not_excluded)): response[HEADER_REPORT_ONLY] = csp_ro From 035844aa67e68307f32ad9602298fef428358fe5 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Mon, 24 Jun 2024 09:34:40 -0500 Subject: [PATCH 07/15] Add type hints --- csp/apps.py | 6 +- csp/checks.py | 11 ++- csp/constants.py | 6 +- csp/context_processors.py | 9 ++- csp/contrib/rate_limiting.py | 9 ++- csp/decorators.py | 32 +++++---- csp/extensions/__init__.py | 10 ++- csp/middleware.py | 15 ++-- csp/templatetags/csp.py | 16 +++-- csp/tests/environment.py | 3 +- csp/tests/test_checks.py | 8 +-- csp/tests/test_constants.py | 2 +- csp/tests/test_context_processors.py | 4 +- csp/tests/test_contrib.py | 8 +-- csp/tests/test_decorators.py | 78 ++++++++++---------- csp/tests/test_jinja_extension.py | 14 ++-- csp/tests/test_middleware.py | 30 ++++---- csp/tests/test_templatetags.py | 14 ++-- csp/tests/test_utils.py | 102 +++++++++++++-------------- csp/tests/utils.py | 39 +++++++--- csp/utils.py | 25 ++++--- pyproject.toml | 1 + 22 files changed, 260 insertions(+), 182 deletions(-) diff --git a/csp/apps.py b/csp/apps.py index caf9f45..38e3103 100644 --- a/csp/apps.py +++ b/csp/apps.py @@ -7,5 +7,7 @@ class CspConfig(AppConfig): name = "csp" - def ready(self): - checks.register(check_django_csp_lt_4_0, checks.Tags.security) + def ready(self) -> None: + # Ignore known issue typeddjango/django-stubs #2232 + # The overload of CheckRegistry.register as a function is incomplete + checks.register(check_django_csp_lt_4_0, checks.Tags.security) # type: ignore diff --git a/csp/checks.py b/csp/checks.py index 7e55c80..9c15b0a 100644 --- a/csp/checks.py +++ b/csp/checks.py @@ -1,10 +1,15 @@ +from __future__ import annotations import pprint +from typing import Dict, Tuple, Any, Optional, Sequence, TYPE_CHECKING, List from django.conf import settings from django.core.checks import Error from csp.constants import NONCE +if TYPE_CHECKING: + from django.apps.config import AppConfig + OUTDATED_SETTINGS = [ "CSP_CHILD_SRC", @@ -40,9 +45,9 @@ ] -def migrate_settings(): +def migrate_settings() -> Tuple[Dict[str, Any], bool]: # This function is used to migrate settings from the old format to the new format. - config = { + config: Dict[str, Any] = { "DIRECTIVES": {}, } @@ -70,7 +75,7 @@ def migrate_settings(): return config, REPORT_ONLY -def check_django_csp_lt_4_0(app_configs, **kwargs): +def check_django_csp_lt_4_0(app_configs: Optional[Sequence[AppConfig]], **kwargs: Any) -> List[Error]: check_settings = OUTDATED_SETTINGS + ["CSP_REPORT_ONLY", "CSP_EXCLUDE_URL_PREFIXES", "CSP_REPORT_PERCENTAGE"] if any(hasattr(settings, setting) for setting in check_settings): # Try to build the new config. diff --git a/csp/constants.py b/csp/constants.py index b8e08cf..5504339 100644 --- a/csp/constants.py +++ b/csp/constants.py @@ -1,3 +1,5 @@ +from typing import Any, Type + HEADER = "Content-Security-Policy" HEADER_REPORT_ONLY = "Content-Security-Policy-Report-Only" @@ -15,12 +17,12 @@ class Nonce: _instance = None - def __new__(cls, *args, **kwargs): + def __new__(cls: Type["Nonce"], *args: Any, **kwargs: Any) -> "Nonce": if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance - def __repr__(self): + def __repr__(self) -> str: return "csp.constants.NONCE" diff --git a/csp/context_processors.py b/csp/context_processors.py index 666da2c..4c34e0a 100644 --- a/csp/context_processors.py +++ b/csp/context_processors.py @@ -1,4 +1,11 @@ -def nonce(request): +from __future__ import annotations +from typing import Dict, Literal, TYPE_CHECKING + +if TYPE_CHECKING: + from django.http import HttpRequest + + +def nonce(request: HttpRequest) -> Dict[Literal["CSP_NONCE"], str]: nonce = request.csp_nonce if hasattr(request, "csp_nonce") else "" return {"CSP_NONCE": nonce} diff --git a/csp/contrib/rate_limiting.py b/csp/contrib/rate_limiting.py index 4cc4f90..f26e86e 100644 --- a/csp/contrib/rate_limiting.py +++ b/csp/contrib/rate_limiting.py @@ -1,3 +1,5 @@ +from __future__ import annotations +from typing import TYPE_CHECKING import random from django.conf import settings @@ -5,12 +7,15 @@ from csp.middleware import CSPMiddleware from csp.utils import build_policy +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + class RateLimitedCSPMiddleware(CSPMiddleware): """A CSP middleware that rate-limits the number of violation reports sent to report-uri by excluding it from some requests.""" - def build_policy(self, request, response): + def build_policy(self, request: HttpRequest, response: HttpResponse) -> str: config = getattr(response, "_csp_config", None) update = getattr(response, "_csp_update", None) replace = getattr(response, "_csp_replace", {}) @@ -28,7 +33,7 @@ def build_policy(self, request, response): return build_policy(config=config, update=update, replace=replace, nonce=nonce) - def build_policy_ro(self, request, response): + def build_policy_ro(self, request: HttpRequest, response: HttpResponse) -> str: config = getattr(response, "_csp_config_ro", None) update = getattr(response, "_csp_update_ro", None) replace = getattr(response, "_csp_replace_ro", {}) diff --git a/csp/decorators.py b/csp/decorators.py index 085a7f1..890d819 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -1,7 +1,13 @@ from functools import wraps +from typing import Callable, Optional, Any, Dict, List +from django.http import HttpRequest, HttpResponse +# A generic Django view function +_VIEW_T = Callable[[HttpRequest], HttpResponse] +_VIEW_DECORATOR_T = Callable[[_VIEW_T], _VIEW_T] -def csp_exempt(REPORT_ONLY=None): + +def csp_exempt(REPORT_ONLY: Optional[bool] = None) -> _VIEW_DECORATOR_T: if callable(REPORT_ONLY): raise RuntimeError( "Incompatible `csp_exempt` decorator usage. This decorator now requires arguments, " @@ -10,9 +16,9 @@ def csp_exempt(REPORT_ONLY=None): "information." ) - def decorator(f): + def decorator(f: _VIEW_T) -> _VIEW_T: @wraps(f) - def _wrapped(*a, **kw): + def _wrapped(*a: Any, **kw: Any) -> HttpResponse: resp = f(*a, **kw) if REPORT_ONLY: setattr(resp, "_csp_exempt_ro", True) @@ -32,13 +38,13 @@ def _wrapped(*a, **kw): ) -def csp_update(config=None, REPORT_ONLY=False, **kwargs): +def csp_update(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T: if config is None and kwargs: raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp_update")) - def decorator(f): + def decorator(f: _VIEW_T) -> _VIEW_T: @wraps(f) - def _wrapped(*a, **kw): + def _wrapped(*a: Any, **kw: Any) -> HttpResponse: resp = f(*a, **kw) if REPORT_ONLY: setattr(resp, "_csp_update_ro", config) @@ -51,13 +57,13 @@ def _wrapped(*a, **kw): return decorator -def csp_replace(config=None, REPORT_ONLY=False, **kwargs): +def csp_replace(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T: if config is None and kwargs: raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp_replace")) - def decorator(f): + def decorator(f: _VIEW_T) -> _VIEW_T: @wraps(f) - def _wrapped(*a, **kw): + def _wrapped(*a: Any, **kw: Any) -> HttpResponse: resp = f(*a, **kw) if REPORT_ONLY: setattr(resp, "_csp_replace_ro", config) @@ -70,18 +76,18 @@ def _wrapped(*a, **kw): return decorator -def csp(config=None, REPORT_ONLY=False, **kwargs): +def csp(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T: if config is None and kwargs: raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp")) if config is None: - processed_config = {} + processed_config: Dict[str, List[Any]] = {} else: processed_config = {k: [v] if isinstance(v, str) else v for k, v in config.items()} - def decorator(f): + def decorator(f: _VIEW_T) -> _VIEW_T: @wraps(f) - def _wrapped(*a, **kw): + def _wrapped(*a: Any, **kw: Any) -> HttpResponse: resp = f(*a, **kw) if REPORT_ONLY: setattr(resp, "_csp_config_ro", processed_config) diff --git a/csp/extensions/__init__.py b/csp/extensions/__init__.py index 36bfd91..45c4de4 100644 --- a/csp/extensions/__init__.py +++ b/csp/extensions/__init__.py @@ -1,14 +1,20 @@ +from __future__ import annotations +from typing import Callable, TYPE_CHECKING, Any + from jinja2 import nodes from jinja2.ext import Extension from csp.utils import SCRIPT_ATTRS, build_script_tag +if TYPE_CHECKING: + from jinja2.parser import Parser + class NoncedScript(Extension): # a set of names that trigger the extension. tags = {"script"} - def parse(self, parser): + def parse(self, parser: Parser) -> nodes.Node: # the first token is the token that started the tag. In our case # we only listen to ``'script'`` so this will be a name token with # `script` as value. We get the line number so that we can give @@ -32,7 +38,7 @@ def parse(self, parser): # helper method on this extension. return nodes.CallBlock(self.call_method("_render_script", kwargs=kwargs), [], [], body).set_lineno(lineno) - def _render_script(self, caller, **kwargs): + def _render_script(self, caller: Callable[[], str], **kwargs: Any) -> str: ctx = kwargs.pop("ctx") request = ctx.get("request") kwargs["nonce"] = request.csp_nonce diff --git a/csp/middleware.py b/csp/middleware.py index 48abbe4..0a33ec7 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -1,7 +1,9 @@ +from __future__ import annotations import base64 import http.client as http_client import os from functools import partial +from typing import TYPE_CHECKING from django.conf import settings from django.utils.deprecation import MiddlewareMixin @@ -10,6 +12,9 @@ from csp.constants import HEADER, HEADER_REPORT_ONLY from csp.utils import build_policy +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + class CSPMiddleware(MiddlewareMixin): """ @@ -21,7 +26,7 @@ class CSPMiddleware(MiddlewareMixin): """ - def _make_nonce(self, request): + def _make_nonce(self, request: HttpRequest) -> str: # Ensure that any subsequent calls to request.csp_nonce return the same value stored_nonce = getattr(request, "_csp_nonce", None) if isinstance(stored_nonce, str): @@ -30,11 +35,11 @@ def _make_nonce(self, request): setattr(request, "_csp_nonce", nonce) return nonce - def process_request(self, request): + def process_request(self, request: HttpRequest) -> None: nonce = partial(self._make_nonce, request) setattr(request, "csp_nonce", SimpleLazyObject(nonce)) - def process_response(self, request, response): + def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: # Check for debug view exempted_debug_codes = ( http_client.INTERNAL_SERVER_ERROR, @@ -67,14 +72,14 @@ def process_response(self, request, response): return response - def build_policy(self, request, response): + def build_policy(self, request: HttpRequest, response: HttpResponse) -> str: config = getattr(response, "_csp_config", None) update = getattr(response, "_csp_update", None) replace = getattr(response, "_csp_replace", None) nonce = getattr(request, "_csp_nonce", None) return build_policy(config=config, update=update, replace=replace, nonce=nonce) - def build_policy_ro(self, request, response): + def build_policy_ro(self, request: HttpRequest, response: HttpResponse) -> str: config = getattr(response, "_csp_config_ro", None) update = getattr(response, "_csp_update_ro", None) replace = getattr(response, "_csp_replace_ro", None) diff --git a/csp/templatetags/csp.py b/csp/templatetags/csp.py index bb8e200..a28bfc1 100644 --- a/csp/templatetags/csp.py +++ b/csp/templatetags/csp.py @@ -1,18 +1,24 @@ +from __future__ import annotations +from typing import TYPE_CHECKING, Optional from django import template from django.template.base import token_kwargs from csp.utils import build_script_tag +if TYPE_CHECKING: + from django.template.base import NodeList, FilterExpression, Token, Parser + from django.template.context import Context + register = template.Library() -def _unquote(s): +def _unquote(s: str) -> str: """Helper func that strips single and double quotes from inside strings""" return s.replace('"', "").replace("'", "") @register.tag(name="script") -def script(parser, token): +def script(parser: Parser, token: Token) -> "NonceScriptNode": # Parse out any keyword args token_args = token.split_contents() kwargs = token_kwargs(token_args[1:], parser) @@ -24,18 +30,18 @@ def script(parser, token): class NonceScriptNode(template.Node): - def __init__(self, nodelist, **kwargs): + def __init__(self, nodelist: NodeList, **kwargs: FilterExpression) -> None: self.nodelist = nodelist self.script_attrs = {} for k, v in kwargs.items(): self.script_attrs[k] = self._get_token_value(v) - def _get_token_value(self, t): + def _get_token_value(self, t: FilterExpression) -> Optional[str]: if hasattr(t, "token") and t.token: return _unquote(t.token) return None - def render(self, context): + def render(self, context: Context) -> str: output = self.nodelist.render(context).strip() request = context.get("request") nonce = getattr(request, "csp_nonce", "") diff --git a/csp/tests/environment.py b/csp/tests/environment.py index 9a218c5..a5d188d 100644 --- a/csp/tests/environment.py +++ b/csp/tests/environment.py @@ -1,6 +1,7 @@ from jinja2 import Environment +from typing import Any -def environment(**options): +def environment(**options: Any) -> Environment: env = Environment(**options) return env diff --git a/csp/tests/test_checks.py b/csp/tests/test_checks.py index 9c5e8df..9fc52cd 100644 --- a/csp/tests/test_checks.py +++ b/csp/tests/test_checks.py @@ -10,7 +10,7 @@ CSP_REPORT_ONLY=False, CSP_DEFAULT_SRC=["'self'", "example.com"], ) -def test_migrate_settings(): +def test_migrate_settings() -> None: config, report_only = migrate_settings() assert config == { "REPORT_PERCENTAGE": 25, @@ -26,7 +26,7 @@ def test_migrate_settings(): CSP_SCRIPT_SRC=["'self'", "example.com", "'unsafe-inline'"], CSP_INCLUDE_NONCE_IN=["script-src"], ) -def test_migrate_settings_report_only(): +def test_migrate_settings_report_only() -> None: config, report_only = migrate_settings() assert config == { "DIRECTIVES": { @@ -40,7 +40,7 @@ def test_migrate_settings_report_only(): @override_settings( CSP_DEFAULT_SRC=["'self'", "example.com"], ) -def test_check_django_csp_lt_4_0(): +def test_check_django_csp_lt_4_0() -> None: errors = check_django_csp_lt_4_0(None) assert len(errors) == 1 error = errors[0] @@ -48,5 +48,5 @@ def test_check_django_csp_lt_4_0(): assert "update your settings to use the new format" in error.msg -def test_check_django_csp_lt_4_0_no_config(): +def test_check_django_csp_lt_4_0_no_config() -> None: assert check_django_csp_lt_4_0(None) == [] diff --git a/csp/tests/test_constants.py b/csp/tests/test_constants.py index 3c3944d..0a563ac 100644 --- a/csp/tests/test_constants.py +++ b/csp/tests/test_constants.py @@ -1,7 +1,7 @@ from csp import constants -def test_nonce(): +def test_nonce() -> None: assert constants.Nonce() == constants.Nonce() assert constants.NONCE == constants.Nonce() assert repr(constants.Nonce()) == "csp.constants.NONCE" diff --git a/csp/tests/test_context_processors.py b/csp/tests/test_context_processors.py index 0203571..164e35a 100644 --- a/csp/tests/test_context_processors.py +++ b/csp/tests/test_context_processors.py @@ -9,7 +9,7 @@ mw = CSPMiddleware(response()) -def test_nonce_context_processor(): +def test_nonce_context_processor() -> None: request = rf.get("/") mw.process_request(request) context = nonce(request) @@ -20,7 +20,7 @@ def test_nonce_context_processor(): assert context["CSP_NONCE"] == getattr(request, "csp_nonce") -def test_nonce_context_processor_with_middleware_disabled(): +def test_nonce_context_processor_with_middleware_disabled() -> None: request = rf.get("/") context = nonce(request) diff --git a/csp/tests/test_contrib.py b/csp/tests/test_contrib.py index 5c16fec..c62e767 100644 --- a/csp/tests/test_contrib.py +++ b/csp/tests/test_contrib.py @@ -11,7 +11,7 @@ @override_settings(CONTENT_SECURITY_POLICY={"REPORT_PERCENTAGE": 10, "DIRECTIVES": {"report-uri": "x"}}) -def test_report_percentage(): +def test_report_percentage() -> None: times_seen = 0 for _ in range(5000): request = rf.get("/") @@ -24,7 +24,7 @@ def test_report_percentage(): @override_settings(CONTENT_SECURITY_POLICY_REPORT_ONLY={"REPORT_PERCENTAGE": 10, "DIRECTIVES": {"report-uri": "x"}}) -def test_report_percentage_report_only(): +def test_report_percentage_report_only() -> None: times_seen = 0 for _ in range(5000): request = rf.get("/") @@ -37,7 +37,7 @@ def test_report_percentage_report_only(): @override_settings(CONTENT_SECURITY_POLICY=None) -def test_no_csp(): +def test_no_csp() -> None: request = rf.get("/") response = HttpResponse() mw.process_response(request, response) @@ -45,7 +45,7 @@ def test_no_csp(): @override_settings(CONTENT_SECURITY_POLICY_REPORT_ONLY=None) -def test_no_csp_ro(): +def test_no_csp_ro() -> None: request = rf.get("/") response = HttpResponse() mw.process_response(request, response) diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index 7dc4a38..6e20b79 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -1,3 +1,6 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from django.http import HttpResponse from django.test import RequestFactory @@ -8,12 +11,15 @@ from csp.middleware import CSPMiddleware from csp.tests.utils import response +if TYPE_CHECKING: + from django.http import HttpRequest + mw = CSPMiddleware(response()) -def test_csp_exempt(): +def test_csp_exempt() -> None: @csp_exempt() - def view(request): + def view(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view(RequestFactory().get("/")) @@ -21,9 +27,9 @@ def view(request): assert not hasattr(response, "_csp_exempt_ro") -def test_csp_exempt_ro(): +def test_csp_exempt_ro() -> None: @csp_exempt(REPORT_ONLY=True) - def view(request): + def view(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view(RequestFactory().get("/")) @@ -32,10 +38,10 @@ def view(request): @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["foo.com"]}}) -def test_csp_update(): +def test_csp_update() -> None: request = RequestFactory().get("/") - def view_without_decorator(request): + def view_without_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_without_decorator(request) @@ -45,7 +51,7 @@ def view_without_decorator(request): assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_update({"img-src": ["bar.com", NONCE]}) - def view_with_decorator(request): + def view_with_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_with_decorator(request) @@ -65,10 +71,10 @@ def view_with_decorator(request): @override_settings(CONTENT_SECURITY_POLICY=None, CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"img-src": ["foo.com"]}}) -def test_csp_update_ro(): +def test_csp_update_ro() -> None: request = RequestFactory().get("/") - def view_without_decorator(request): + def view_without_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_without_decorator(request) @@ -78,7 +84,7 @@ def view_without_decorator(request): assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_update({"img-src": ["bar.com", NONCE]}, REPORT_ONLY=True) - def view_with_decorator(request): + def view_with_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_with_decorator(request) @@ -98,10 +104,10 @@ def view_with_decorator(request): @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["foo.com"]}}) -def test_csp_replace(): +def test_csp_replace() -> None: request = RequestFactory().get("/") - def view_without_decorator(request): + def view_without_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_without_decorator(request) @@ -111,7 +117,7 @@ def view_without_decorator(request): assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_replace({"img-src": ["bar.com"]}) - def view_with_decorator(request): + def view_with_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_with_decorator(request) @@ -128,7 +134,7 @@ def view_with_decorator(request): assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_replace({"img-src": None}) - def view_removing_directive(request): + def view_removing_directive(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_removing_directive(request) @@ -139,10 +145,10 @@ def view_removing_directive(request): @override_settings(CONTENT_SECURITY_POLICY=None, CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"img-src": ["foo.com"]}}) -def test_csp_replace_ro(): +def test_csp_replace_ro() -> None: request = RequestFactory().get("/") - def view_without_decorator(request): + def view_without_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_without_decorator(request) @@ -152,7 +158,7 @@ def view_without_decorator(request): assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_replace({"img-src": ["bar.com"]}, REPORT_ONLY=True) - def view_with_decorator(request): + def view_with_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_with_decorator(request) @@ -169,7 +175,7 @@ def view_with_decorator(request): assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_replace({"img-src": None}, REPORT_ONLY=True) - def view_removing_directive(request): + def view_removing_directive(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_removing_directive(request) @@ -179,10 +185,10 @@ def view_removing_directive(request): assert policy_list == ["default-src 'self'"] -def test_csp(): +def test_csp() -> None: request = RequestFactory().get("/") - def view_without_decorator(request): + def view_without_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_without_decorator(request) @@ -192,7 +198,7 @@ def view_without_decorator(request): assert policy_list == ["default-src 'self'"] @csp({"img-src": ["foo.com"], "font-src": ["bar.com"]}) - def view_with_decorator(request): + def view_with_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_with_decorator(request) @@ -209,10 +215,10 @@ def view_with_decorator(request): assert policy_list == ["default-src 'self'"] -def test_csp_ro(): +def test_csp_ro() -> None: request = RequestFactory().get("/") - def view_without_decorator(request): + def view_without_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_without_decorator(request) @@ -223,7 +229,7 @@ def view_without_decorator(request): @csp({"img-src": ["foo.com"], "font-src": ["bar.com"]}, REPORT_ONLY=True) @csp({}) # CSP with no directives effectively removes the header. - def view_with_decorator(request): + def view_with_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_with_decorator(request) @@ -240,12 +246,12 @@ def view_with_decorator(request): assert policy_list == ["default-src 'self'"] -def test_csp_string_values(): +def test_csp_string_values() -> None: # Test backwards compatibility where values were strings request = RequestFactory().get("/") @csp({"img-src": "foo.com", "font-src": "bar.com"}) - def view_with_decorator(request): + def view_with_decorator(request: HttpRequest) -> HttpResponse: return HttpResponse() response = view_with_decorator(request) @@ -258,41 +264,41 @@ def view_with_decorator(request): # Deprecation tests -def test_csp_exempt_error(): +def test_csp_exempt_error() -> None: with pytest.raises(RuntimeError) as excinfo: - - @csp_exempt - def view(request): + # Ignore type error since we're checking for the exception raised for 3.x syntax + @csp_exempt # type: ignore + def view(request: HttpRequest) -> HttpResponse: return HttpResponse() assert "Incompatible `csp_exempt` decorator usage" in str(excinfo.value) -def test_csp_update_error(): +def test_csp_update_error() -> None: with pytest.raises(RuntimeError) as excinfo: @csp_update(IMG_SRC="bar.com") - def view(request): + def view(request: HttpRequest) -> HttpResponse: return HttpResponse() assert "Incompatible `csp_update` decorator arguments" in str(excinfo.value) -def test_csp_replace_error(): +def test_csp_replace_error() -> None: with pytest.raises(RuntimeError) as excinfo: @csp_replace(IMG_SRC="bar.com") - def view(request): + def view(request: HttpRequest) -> HttpResponse: return HttpResponse() assert "Incompatible `csp_replace` decorator arguments" in str(excinfo.value) -def test_csp_error(): +def test_csp_error() -> None: with pytest.raises(RuntimeError) as excinfo: @csp(IMG_SRC=["bar.com"]) - def view(request): + def view(request: HttpRequest) -> HttpResponse: return HttpResponse() assert "Incompatible `csp` decorator arguments" in str(excinfo.value) diff --git a/csp/tests/test_jinja_extension.py b/csp/tests/test_jinja_extension.py index 227feb3..b9497c6 100644 --- a/csp/tests/test_jinja_extension.py +++ b/csp/tests/test_jinja_extension.py @@ -2,7 +2,7 @@ class TestJinjaExtension(ScriptExtensionTestBase): - def test_script_tag_injects_nonce(self): + def test_script_tag_injects_nonce(self) -> None: tpl = """ {% script %} var hello='world'; @@ -12,7 +12,7 @@ def test_script_tag_injects_nonce(self): expected = """""" self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_script_with_src_ignores_body(self): + def test_script_with_src_ignores_body(self) -> None: tpl = """ {% script src="foo" %} var hello='world'; @@ -23,7 +23,7 @@ def test_script_with_src_ignores_body(self): self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_script_tag_sets_attrs_correctly(self): + def test_script_tag_sets_attrs_correctly(self) -> None: tpl = """ {% script id='jeff' defer=True %} var hello='world'; @@ -36,7 +36,7 @@ def test_script_tag_sets_attrs_correctly(self): self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_async_attribute_with_falsey(self): + def test_async_attribute_with_falsey(self) -> None: tpl = """ {% script id="jeff" async=False %} var hello='world'; @@ -46,7 +46,7 @@ def test_async_attribute_with_falsey(self): self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_async_attribute_with_truthy(self): + def test_async_attribute_with_truthy(self) -> None: tpl = """ {% script id="jeff" async=True %} var hello='world'; @@ -56,7 +56,7 @@ def test_async_attribute_with_truthy(self): self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_nested_script_tags_are_removed(self): + def test_nested_script_tags_are_removed(self) -> None: """Let users wrap their code in script tags for the sake of their development environment""" tpl = """ @@ -70,7 +70,7 @@ def test_nested_script_tags_are_removed(self): self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_regex_captures_script_content_including_brackets(self): + def test_regex_captures_script_content_including_brackets(self) -> None: """ Ensure that script content get captured properly. Especially when using angle brackets.""" diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index 3c3178a..e2e7e54 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -14,7 +14,7 @@ rf = RequestFactory() -def test_add_header(): +def test_add_header() -> None: request = rf.get("/") response = HttpResponse() mw.process_response(request, response) @@ -25,7 +25,7 @@ def test_add_header(): CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["example.com"]}}, CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"default-src": [SELF]}}, ) -def test_both_headers(): +def test_both_headers() -> None: request = rf.get("/") response = HttpResponse() mw.process_response(request, response) @@ -33,16 +33,16 @@ def test_both_headers(): assert HEADER_REPORT_ONLY in response -def test_exempt(): +def test_exempt() -> None: request = rf.get("/") response = HttpResponse() - response._csp_exempt = True + setattr(response, "_csp_exempt", True) mw.process_response(request, response) assert HEADER not in response @override_settings(CONTENT_SECURITY_POLICY={"EXCLUDE_URL_PREFIXES": ["/inlines-r-us"]}) -def text_exclude(): +def test_exclude() -> None: request = rf.get("/inlines-r-us/foo") response = HttpResponse() mw.process_response(request, response) @@ -53,7 +53,7 @@ def text_exclude(): CONTENT_SECURITY_POLICY=None, CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"default-src": [SELF]}}, ) -def test_report_only(): +def test_report_only() -> None: request = rf.get("/") response = HttpResponse() mw.process_response(request, response) @@ -61,7 +61,7 @@ def test_report_only(): assert HEADER + "-Report-Only" in response -def test_dont_replace(): +def test_dont_replace() -> None: request = rf.get("/") response = HttpResponse() response[HEADER] = "default-src example.com" @@ -69,7 +69,7 @@ def test_dont_replace(): assert response[HEADER] == "default-src example.com" -def test_use_config(): +def test_use_config() -> None: request = rf.get("/") response = HttpResponse() setattr(response, "_csp_config", {"default-src": ["example.com"]}) @@ -77,7 +77,7 @@ def test_use_config(): assert response[HEADER] == "default-src example.com" -def test_use_update(): +def test_use_update() -> None: request = rf.get("/") response = HttpResponse() setattr(response, "_csp_update", {"default-src": ["example.com"]}) @@ -86,7 +86,7 @@ def test_use_update(): @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["foo.com"]}}) -def test_use_replace(): +def test_use_replace() -> None: request = rf.get("/") response = HttpResponse() setattr(response, "_csp_replace", {"img-src": ["bar.com"]}) @@ -96,7 +96,7 @@ def test_use_replace(): @override_settings(DEBUG=True) -def test_debug_errors_exempt(): +def test_debug_errors_exempt() -> None: request = rf.get("/") response = HttpResponseServerError() mw.process_response(request, response) @@ -104,14 +104,14 @@ def test_debug_errors_exempt(): @override_settings(DEBUG=True) -def test_debug_notfound_exempt(): +def test_debug_notfound_exempt() -> None: request = rf.get("/") response = HttpResponseNotFound() mw.process_response(request, response) assert HEADER not in response -def test_nonce_created_when_accessed(): +def test_nonce_created_when_accessed() -> None: request = rf.get("/") mw.process_request(request) nonce = str(getattr(request, "csp_nonce")) @@ -120,7 +120,7 @@ def test_nonce_created_when_accessed(): assert nonce in response[HEADER] -def test_no_nonce_when_not_accessed(): +def test_no_nonce_when_not_accessed() -> None: request = rf.get("/") mw.process_request(request) response = HttpResponse() @@ -128,7 +128,7 @@ def test_no_nonce_when_not_accessed(): assert "nonce-" not in response[HEADER] -def test_nonce_regenerated_on_new_request(): +def test_nonce_regenerated_on_new_request() -> None: request1 = rf.get("/") request2 = rf.get("/") mw.process_request(request1) diff --git a/csp/tests/test_templatetags.py b/csp/tests/test_templatetags.py index 45e96d5..308175d 100644 --- a/csp/tests/test_templatetags.py +++ b/csp/tests/test_templatetags.py @@ -2,7 +2,7 @@ class TestDjangoTemplateTag(ScriptTagTestBase): - def test_script_tag_injects_nonce(self): + def test_script_tag_injects_nonce(self) -> None: tpl = """ {% load csp %} {% script %}var hello='world';{% endscript %}""" @@ -11,7 +11,7 @@ def test_script_tag_injects_nonce(self): self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_script_with_src_ignores_body(self): + def test_script_with_src_ignores_body(self) -> None: tpl = """ {% load csp %} {% script src="foo" %} @@ -22,7 +22,7 @@ def test_script_with_src_ignores_body(self): self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_script_tag_sets_attrs_correctly(self): + def test_script_tag_sets_attrs_correctly(self) -> None: tpl = """ {% load csp %} {% script type="application/javascript" id="jeff" defer=True%} @@ -33,7 +33,7 @@ def test_script_tag_sets_attrs_correctly(self): self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_async_attribute_with_falsey(self): + def test_async_attribute_with_falsey(self) -> None: tpl = """ {% load csp %} {% script src="foo.com/bar.js" async=False %} @@ -43,7 +43,7 @@ def test_async_attribute_with_falsey(self): self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_async_attribute_with_truthy(self): + def test_async_attribute_with_truthy(self) -> None: tpl = """ {% load csp %} {% script src="foo.com/bar.js" async=True %} @@ -54,7 +54,7 @@ def test_async_attribute_with_truthy(self): self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_nested_script_tags_are_removed(self): + def test_nested_script_tags_are_removed(self) -> None: """Lets end users wrap their code in script tags for the sake of their development environment""" tpl = """ @@ -69,7 +69,7 @@ def test_nested_script_tags_are_removed(self): self.assert_template_eq(*self.process_templates(tpl, expected)) - def test_regex_captures_script_content_including_brackets(self): + def test_regex_captures_script_content_including_brackets(self) -> None: """ Ensure that script content get captured properly. Especially when using angle brackets.""" diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index e8077a4..d76f49a 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -5,196 +5,196 @@ from csp.utils import build_policy, default_config, DEFAULT_DIRECTIVES -def policy_eq(a, b): +def policy_eq(a: str, b: str) -> None: parts_a = sorted(a.split("; ")) parts_b = sorted(b.split("; ")) assert parts_a == parts_b, f"{a!r} != {b!r}" -def literal(s): +def literal(s: str) -> str: return s lazy_literal = lazy(literal, str) -def test_default_config_none(): +def test_default_config_none() -> None: assert default_config(None) is None -def test_default_config_empty(): +def test_default_config_empty() -> None: # Test `default_config` with an empty dict returns defaults. assert default_config({}) == DEFAULT_DIRECTIVES -def test_default_config_drops_unknown(): +def test_default_config_drops_unknown() -> None: # Test `default_config` drops unknown keys. config = {"foo-src": ["example.com"]} assert default_config(config) == DEFAULT_DIRECTIVES -def test_default_config(): +def test_default_config() -> None: # Test `default_config` keeps config along with defaults. config = {"img-src": ["example.com"]} assert default_config(config) == {**DEFAULT_DIRECTIVES, **config} -def test_empty_policy(): +def test_empty_policy() -> None: policy = build_policy() policy_eq("default-src 'self'", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": None}}) -def test_default_src_none(): +def test_default_src_none() -> None: policy = build_policy() policy_eq("", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["example.com", "example2.com"]}}) -def test_default_src(): +def test_default_src() -> None: policy = build_policy() policy_eq("default-src example.com example2.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"script-src": ["example.com"]}}) -def test_script_src(): +def test_script_src() -> None: policy = build_policy() policy_eq("default-src 'self'; script-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"script-src-attr": ["example.com"]}}) -def test_script_src_attr(): +def test_script_src_attr() -> None: policy = build_policy() policy_eq("default-src 'self'; script-src-attr example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"script-src-elem": ["example.com"]}}) -def test_script_src_elem(): +def test_script_src_elem() -> None: policy = build_policy() policy_eq("default-src 'self'; script-src-elem example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"object-src": ["example.com"]}}) -def test_object_src(): +def test_object_src() -> None: policy = build_policy() policy_eq("default-src 'self'; object-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"prefetch-src": ["example.com"]}}) -def test_prefetch_src(): +def test_prefetch_src() -> None: policy = build_policy() policy_eq("default-src 'self'; prefetch-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"style-src": ["example.com"]}}) -def test_style_src(): +def test_style_src() -> None: policy = build_policy() policy_eq("default-src 'self'; style-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"style-src-attr": ["example.com"]}}) -def test_style_src_attr(): +def test_style_src_attr() -> None: policy = build_policy() policy_eq("default-src 'self'; style-src-attr example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"style-src-elem": ["example.com"]}}) -def test_style_src_elem(): +def test_style_src_elem() -> None: policy = build_policy() policy_eq("default-src 'self'; style-src-elem example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["example.com"]}}) -def test_img_src(): +def test_img_src() -> None: policy = build_policy() policy_eq("default-src 'self'; img-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"media-src": ["example.com"]}}) -def test_media_src(): +def test_media_src() -> None: policy = build_policy() policy_eq("default-src 'self'; media-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"frame-src": ["example.com"]}}) -def test_frame_src(): +def test_frame_src() -> None: policy = build_policy() policy_eq("default-src 'self'; frame-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"font-src": ["example.com"]}}) -def test_font_src(): +def test_font_src() -> None: policy = build_policy() policy_eq("default-src 'self'; font-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"connect-src": ["example.com"]}}) -def test_connect_src(): +def test_connect_src() -> None: policy = build_policy() policy_eq("default-src 'self'; connect-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"sandbox": ["allow-scripts"]}}) -def test_sandbox(): +def test_sandbox() -> None: policy = build_policy() policy_eq("default-src 'self'; sandbox allow-scripts", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"sandbox": []}}) -def test_sandbox_empty(): +def test_sandbox_empty() -> None: policy = build_policy() policy_eq("default-src 'self'; sandbox", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"report-uri": "/foo"}}) -def test_report_uri(): +def test_report_uri() -> None: policy = build_policy() policy_eq("default-src 'self'; report-uri /foo", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"report-uri": lazy_literal("/foo")}}) -def test_report_uri_lazy(): +def test_report_uri_lazy() -> None: policy = build_policy() policy_eq("default-src 'self'; report-uri /foo", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"report-to": "some_endpoint"}}) -def test_report_to(): +def test_report_to() -> None: policy = build_policy() policy_eq("default-src 'self'; report-to some_endpoint", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["example.com"]}}) -def test_update_img(): +def test_update_img() -> None: policy = build_policy(update={"img-src": "example2.com"}) policy_eq("default-src 'self'; img-src example.com example2.com", policy) -def test_update_missing_setting(): +def test_update_missing_setting() -> None: """update should work even if the setting is not defined.""" policy = build_policy(update={"img-src": "example.com"}) policy_eq("default-src 'self'; img-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["example.com"]}}) -def test_replace_img(): +def test_replace_img() -> None: policy = build_policy(replace={"img-src": "example2.com"}) policy_eq("default-src 'self'; img-src example2.com", policy) -def test_replace_missing_setting(): +def test_replace_missing_setting() -> None: """replace should work even if the setting is not defined.""" policy = build_policy(replace={"img-src": "example.com"}) policy_eq("default-src 'self'; img-src example.com", policy) -def test_config(): +def test_config() -> None: policy = build_policy(config={"default-src": [NONE], "img-src": [SELF]}) policy_eq("default-src 'none'; img-src 'self'", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ("example.com",)}}) -def test_update_string(): +def test_update_string() -> None: """ GitHub issue #40 - given project settings as a tuple, and an update/replace with a string, concatenate correctly. @@ -204,7 +204,7 @@ def test_update_string(): @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ("example.com",)}}) -def test_replace_string(): +def test_replace_string() -> None: """ Demonstrate that GitHub issue #40 doesn't affect replacements """ @@ -213,67 +213,67 @@ def test_replace_string(): @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"form-action": ["example.com"]}}) -def test_form_action(): +def test_form_action() -> None: policy = build_policy() policy_eq("default-src 'self'; form-action example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"base-uri": ["example.com"]}}) -def test_base_uri(): +def test_base_uri() -> None: policy = build_policy() policy_eq("default-src 'self'; base-uri example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"child-src": ["example.com"]}}) -def test_child_src(): +def test_child_src() -> None: policy = build_policy() policy_eq("default-src 'self'; child-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"frame-ancestors": ["example.com"]}}) -def test_frame_ancestors(): +def test_frame_ancestors() -> None: policy = build_policy() policy_eq("default-src 'self'; frame-ancestors example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"navigate-to": ["example.com"]}}) -def test_navigate_to(): +def test_navigate_to() -> None: policy = build_policy() policy_eq("default-src 'self'; navigate-to example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"manifest-src": ["example.com"]}}) -def test_manifest_src(): +def test_manifest_src() -> None: policy = build_policy() policy_eq("default-src 'self'; manifest-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"worker-src": ["example.com"]}}) -def test_worker_src(): +def test_worker_src() -> None: policy = build_policy() policy_eq("default-src 'self'; worker-src example.com", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"plugin-types": ["application/pdf"]}}) -def test_plugin_types(): +def test_plugin_types() -> None: policy = build_policy() policy_eq("default-src 'self'; plugin-types application/pdf", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"require-sri-for": ["script"]}}) -def test_require_sri_for(): +def test_require_sri_for() -> None: policy = build_policy() policy_eq("default-src 'self'; require-sri-for script", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"require-trusted-types-for": ["'script'"]}}) -def test_require_trusted_types_for(): +def test_require_trusted_types_for() -> None: policy = build_policy() policy_eq("default-src 'self'; require-trusted-types-for 'script'", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"trusted-types": ["strictPolicy", "laxPolicy", "default", "'allow-duplicates'"]}}) -def test_trusted_types(): +def test_trusted_types() -> None: policy = build_policy() policy_eq( "default-src 'self'; trusted-types strictPolicy laxPolicy default 'allow-duplicates'", @@ -282,24 +282,24 @@ def test_trusted_types(): @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"upgrade-insecure-requests": True}}) -def test_upgrade_insecure_requests(): +def test_upgrade_insecure_requests() -> None: policy = build_policy() policy_eq("default-src 'self'; upgrade-insecure-requests", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"block-all-mixed-content": True}}) -def test_block_all_mixed_content(): +def test_block_all_mixed_content() -> None: policy = build_policy() policy_eq("default-src 'self'; block-all-mixed-content", policy) -def test_nonce(): +def test_nonce() -> None: policy = build_policy(nonce="abc123") policy_eq("default-src 'self' 'nonce-abc123'", policy) @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": [SELF], "script-src": [SELF, NONCE], "style-src": [SELF, NONCE]}}) -def test_nonce_in_value(): +def test_nonce_in_value() -> None: policy = build_policy(nonce="abc123") policy_eq( "default-src 'self'; script-src 'self' 'nonce-abc123'; style-src 'self' 'nonce-abc123'", @@ -308,12 +308,12 @@ def test_nonce_in_value(): @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": [NONCE]}}) -def test_only_nonce_in_value(): +def test_only_nonce_in_value() -> None: policy = build_policy(nonce="abc123") policy_eq("default-src 'nonce-abc123'", policy) -def test_boolean_directives(): +def test_boolean_directives() -> None: for directive in ["upgrade-insecure-requests", "block-all-mixed-content"]: with override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {directive: True}}): policy = build_policy() diff --git a/csp/tests/utils.py b/csp/tests/utils.py index 3896219..d5e97c8 100644 --- a/csp/tests/utils.py +++ b/csp/tests/utils.py @@ -1,12 +1,21 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Dict, Optional, TYPE_CHECKING, Callable, Any, Tuple, Union + from django.http import HttpResponse from django.template import Context, Template, engines from django.test import RequestFactory +from django.utils.functional import SimpleLazyObject from csp.middleware import CSPMiddleware +if TYPE_CHECKING: + from django.http import HttpRequest + from django.template.backends.base import _EngineTemplate + -def response(*args, headers=None, **kwargs): - def get_response(req): +def response(*args: Any, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> Callable[[HttpRequest], HttpResponse]: + def get_response(req: HttpRequest) -> HttpResponse: response = HttpResponse(*args, **kwargs) if headers: for k, v in headers.items(): @@ -21,33 +30,41 @@ def get_response(req): rf = RequestFactory() -class ScriptTestBase: - def assert_template_eq(self, tpl1, tpl2): +class ScriptTestBase(ABC): + def assert_template_eq(self, tpl1: str, tpl2: str) -> None: aaa = tpl1.replace("\n", "").replace(" ", "") bbb = tpl2.replace("\n", "").replace(" ", "") assert aaa == bbb, f"{aaa} != {bbb}" - def process_templates(self, tpl, expected): + def process_templates(self, tpl: str, expected: str) -> Tuple[str, str]: request = rf.get("/") mw.process_request(request) + nonce = getattr(request, "csp_nonce") + assert isinstance(nonce, SimpleLazyObject) ctx = self.make_context(request) return ( - self.make_template(tpl).render(ctx).strip(), - expected.format(getattr(request, "csp_nonce")), + self.make_template(tpl).render(ctx).strip(), # type: ignore + expected.format(nonce), ) + @abstractmethod + def make_context(self, request: HttpRequest) -> Union[dict[str, Any], Context]: ... + + @abstractmethod + def make_template(self, tpl: str) -> Union[_EngineTemplate, Template]: ... + class ScriptTagTestBase(ScriptTestBase): - def make_context(self, request): + def make_context(self, request: HttpRequest) -> Context: return Context({"request": request}) - def make_template(self, tpl): + def make_template(self, tpl: str) -> Template: return Template(tpl) class ScriptExtensionTestBase(ScriptTestBase): - def make_context(self, request): + def make_context(self, request: HttpRequest) -> dict[str, HttpRequest]: return {"request": request} - def make_template(self, tpl): + def make_template(self, tpl: str) -> _EngineTemplate: return JINJA_ENV.from_string(tpl) diff --git a/csp/utils.py b/csp/utils.py index e02ed0e..fe35633 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -2,6 +2,7 @@ import re from collections import OrderedDict from itertools import chain +from typing import Any, Dict, Optional, Union, Callable from django.conf import settings from django.utils.encoding import force_str @@ -49,8 +50,10 @@ "block-all-mixed-content": None, # Deprecated. } +_DIRECTIVES = Dict[str, Any] -def default_config(csp): + +def default_config(csp: Optional[_DIRECTIVES]) -> Optional[_DIRECTIVES]: if csp is None: return None # Make a copy of the passed in config to avoid mutating it, and also to drop any unknown keys. @@ -60,7 +63,13 @@ def default_config(csp): return config -def build_policy(config=None, update=None, replace=None, nonce=None, report_only=False): +def build_policy( + config: Optional[_DIRECTIVES] = None, + update: Optional[_DIRECTIVES] = None, + replace: Optional[_DIRECTIVES] = None, + nonce: Optional[str] = None, + report_only: bool = False, +) -> str: """Builds the policy as a string from the settings.""" if config is None: @@ -126,14 +135,14 @@ def build_policy(config=None, update=None, replace=None, nonce=None, report_only return "; ".join([f"{k} {val}".strip() for k, val in policy_parts.items()]) -def _default_attr_mapper(attr_name, val): +def _default_attr_mapper(attr_name: str, val: str) -> str: if val: return f' {attr_name}="{val}"' else: return "" -def _bool_attr_mapper(attr_name, val): +def _bool_attr_mapper(attr_name: str, val: bool) -> str: # Only return the bare word if the value is truthy # ie - defer=False should actually return an empty string if val: @@ -142,7 +151,7 @@ def _bool_attr_mapper(attr_name, val): return "" -def _async_attr_mapper(attr_name, val): +def _async_attr_mapper(attr_name: str, val: Union[str, bool]) -> str: """The `async` attribute works slightly different than the other bool attributes. It can be set explicitly to `false` with no surrounding quotes according to the spec.""" @@ -155,7 +164,7 @@ def _async_attr_mapper(attr_name, val): # Allow per-attribute customization of returned string template -SCRIPT_ATTRS = OrderedDict() +SCRIPT_ATTRS: Dict[str, Callable[[str, Any], str]] = OrderedDict() SCRIPT_ATTRS["nonce"] = _default_attr_mapper SCRIPT_ATTRS["id"] = _default_attr_mapper SCRIPT_ATTRS["src"] = _default_attr_mapper @@ -179,7 +188,7 @@ def _async_attr_mapper(attr_name, val): ) -def _unwrap_script(text): +def _unwrap_script(text: str) -> str: """Extract content defined between script tags""" matches = re.search(_script_tag_contents_re, text) if matches and len(matches.groups()): @@ -188,7 +197,7 @@ def _unwrap_script(text): return text -def build_script_tag(content=None, **kwargs): +def build_script_tag(content: Optional[str] = None, **kwargs: Any) -> str: data = {} # Iterate all possible script attrs instead of kwargs to make # interpolation as easy as possible below diff --git a/pyproject.toml b/pyproject.toml index dc21778..0ad9675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ DJANGO_SETTINGS_MODULE = "csp.tests.settings" [tool.mypy] plugins = ["mypy_django_plugin.main"] exclude = ['^build/lib'] +strict=true [tool.django-stubs] django_settings_module = "csp.tests.settings" From 932856e0281d9635b3418c6c5a15df1362ff7322 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Mon, 24 Jun 2024 16:14:13 -0500 Subject: [PATCH 08/15] Refactor ScriptTestBase Althought the code `template.render(context)` looked similar, mypy complained that Django's Template could not take a dict. Rather than switch on types, refactor `make_context` and `make_template` into `render`, which hides the typing details between Django templates and extension templates like Jinja2. --- csp/tests/utils.py | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/csp/tests/utils.py b/csp/tests/utils.py index d5e97c8..36512ca 100644 --- a/csp/tests/utils.py +++ b/csp/tests/utils.py @@ -1,6 +1,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Dict, Optional, TYPE_CHECKING, Callable, Any, Tuple, Union +from typing import Dict, Optional, TYPE_CHECKING, Callable, Any, Tuple from django.http import HttpResponse from django.template import Context, Template, engines @@ -11,7 +11,6 @@ if TYPE_CHECKING: from django.http import HttpRequest - from django.template.backends.base import _EngineTemplate def response(*args: Any, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> Callable[[HttpRequest], HttpResponse]: @@ -41,30 +40,21 @@ def process_templates(self, tpl: str, expected: str) -> Tuple[str, str]: mw.process_request(request) nonce = getattr(request, "csp_nonce") assert isinstance(nonce, SimpleLazyObject) - ctx = self.make_context(request) - return ( - self.make_template(tpl).render(ctx).strip(), # type: ignore - expected.format(nonce), - ) + return (self.render(tpl, request).strip(), expected.format(nonce)) @abstractmethod - def make_context(self, request: HttpRequest) -> Union[dict[str, Any], Context]: ... - - @abstractmethod - def make_template(self, tpl: str) -> Union[_EngineTemplate, Template]: ... + def render(self, template_string: str, request: HttpRequest) -> str: ... class ScriptTagTestBase(ScriptTestBase): - def make_context(self, request: HttpRequest) -> Context: - return Context({"request": request}) - - def make_template(self, tpl: str) -> Template: - return Template(tpl) + def render(self, template_string: str, request: HttpRequest) -> str: + context = Context({"request": request}) + template = Template(template_string) + return template.render(context) class ScriptExtensionTestBase(ScriptTestBase): - def make_context(self, request: HttpRequest) -> dict[str, HttpRequest]: - return {"request": request} - - def make_template(self, tpl: str) -> _EngineTemplate: - return JINJA_ENV.from_string(tpl) + def render(self, template_string: str, request: HttpRequest) -> str: + context = {"request": request} + template = JINJA_ENV.from_string(template_string) + return template.render(context) From 26c03b1187082ec21f024ddbc4caf93e95fec670 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Mon, 24 Jun 2024 17:23:03 -0500 Subject: [PATCH 09/15] Fix Sphinx doc generation without setuptools --- docs/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index df209e5..2c490d4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,7 +10,7 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import pkg_resources +from importlib.metadata import version as get_version from typing import Dict # If extensions (or modules to document with autodoc) are in another directory, @@ -51,7 +51,7 @@ # built documents. # # The short X.Y version. -version = pkg_resources.get_distribution("django_csp").version +version = get_version("django_csp") # The full version, including alpha/beta/rc tags. release = version @@ -123,7 +123,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] +# html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. From 42125a52d60cb461013c8c0c6a0f1b255046a40a Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Mon, 24 Jun 2024 17:24:28 -0500 Subject: [PATCH 10/15] Add `pip install -e ".[dev]"` --- pyproject.toml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0ad9675..c4c3e4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,20 @@ classifiers = [ dependencies = [ "django>=3.2", ] +optional-dependencies.dev = [ + "django-stubs[compatible-mypy]", + "jinja2>=2.9.6", + "mypy", + "pre-commit", + "pytest", + "pytest-cov", + "pytest-django", + "pytest-ruff", + "Sphinx", + "sphinx_rtd_theme", + "tox", + "types-setuptools", +] optional-dependencies.jinja2 = [ "jinja2>=2.9.6", ] @@ -51,12 +65,12 @@ optional-dependencies.tests = [ "pytest-ruff", ] optional-dependencies.typing = [ + "django-stubs[compatible-mypy]", "jinja2>=2.9.6", + "mypy", "pytest", "pytest-django", - "mypy", "types-setuptools", - "django-stubs[compatible-mypy]", ] urls."Bug Tracker" = "https://github.com/mozilla/django-csp/issues" urls.Changelog = "https://github.com/mozilla/django-csp/blob/main/CHANGES" From f247464c823bf8edefc392a7851e0d2dc59a1479 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Mon, 24 Jun 2024 17:34:19 -0500 Subject: [PATCH 11/15] Update docs for typing, etc. --- docs/contributing.rst | 66 +++++++++++++++++++++++++++++++++++++++++-- pyproject.toml | 1 + 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index e48df57..254287e 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -8,6 +8,22 @@ Patches are more than welcome! You can find the issue tracker `on GitHub `_ and we'd love pull requests. +Setup +===== +To install all the requirements (probably into a virtualenv_): + +.. code-block:: bash + + pip install -e . + pip install -e ".[dev]" + +This installs: + +* All the text requirements +* All the typing requirements +* pre-commit_, for checking styles +* tox_, for running tests against multiple environments +* Sphinx_ and document building requirements Style ===== @@ -44,9 +60,55 @@ To run the tests with coverage and get a report, use the following command: pytest --cov=csp --cov-config=.coveragerc +To run the tests like Github Actions does, you'll need pyenv_: + +.. code-block:: bash + + pyenv install 3.8 3.9 3.10 3.11 3.12 pypy3.8 pypy3.9 pypy3.10 + pyenv local 3.8 3.9 3.10. 3.11 3.12 pypy3.8 pypy3.9 pypy3.10 + pip install -e ".[dev]" # installs tox + tox # run sequentially + tox run-parallel # run in parallel, may cause issues on coverage step + tox -e 3.12-4.2.x # run tests on Python 3.12 and Django 4.x + tox --listenvs # list all the environments + +Type Checking +============= + +New code should have type annotations and pass mypy_ in strict mode. Use the +typing syntax available in the earliest supported Python version 3.8. + +To check types: + +.. code-block:: bash + + pip install -e ".[typing]" + mypy . + +If you make a lot of changes, it can help to clear the mypy cache: + +.. code-block:: bash + + mypy --no-incremental . + +Updating Documentation +====================== + +To rebuild documentation locally: + +.. code-block:: bash + + pip install -e ".[dev]" + cd docs + make html + open _build/html/index.html # On macOS .. _PEP8: http://www.python.org/dev/peps/pep-0008/ +.. _Sphinx: https://www.sphinx-doc.org/en/master/index.html +.. _mypy: https://mypy.readthedocs.io/en/stable/ +.. _pre-commit: https://pre-commit.com/#install +.. _pyenv: https://github.com/pyenv/pyenv +.. _pytest: https://pytest.org/latest/usage.html .. _ruff: https://pypi.org/project/ruff/ +.. _tox: https://tox.wiki/en/stable/ .. _virtualenv: http://www.virtualenv.org/ -.. _pytest: https://pytest.org/latest/usage.html -.. _pre-commit: https://pre-commit.com/#install diff --git a/pyproject.toml b/pyproject.toml index c4c3e4f..1dd0b41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ optional-dependencies.dev = [ "Sphinx", "sphinx_rtd_theme", "tox", + "tox-gh-actions", "types-setuptools", ] optional-dependencies.jinja2 = [ From 0b728aa9d64fe636293689ceb0c0a441604276df Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Mon, 24 Jun 2024 17:59:31 -0500 Subject: [PATCH 12/15] Add PEP 561 py.typed file --- csp/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 csp/py.typed diff --git a/csp/py.typed b/csp/py.typed new file mode 100644 index 0000000..e69de29 From c2a431753f17b9b16bd6d993b674a6ca0e0480ba Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Fri, 28 Jun 2024 14:29:26 -0700 Subject: [PATCH 13/15] Bump Django dependency to 4.2+ --- pyproject.toml | 5 ++--- tox.ini | 2 -- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1dd0b41..635a21d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Environment :: Web Environment :: Mozilla", - "Framework :: Django :: 3.2", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Intended Audience :: Developers", @@ -38,7 +37,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "django>=3.2", + "django>=4.2", ] optional-dependencies.dev = [ "django-stubs[compatible-mypy]", @@ -93,7 +92,7 @@ DJANGO_SETTINGS_MODULE = "csp.tests.settings" [tool.mypy] plugins = ["mypy_django_plugin.main"] exclude = ['^build/lib'] -strict=true +strict = true [tool.django-stubs] django_settings_module = "csp.tests.settings" diff --git a/tox.ini b/tox.ini index 02f709b..b993c17 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,6 @@ envlist = {3.10,3.11,3.12,pypy310}-main {3.10,3.11,3.12,pypy310}-5.0.x {3.8,3.9,3.10,3.11,3.12,pypy38,pypy39,pypy310}-4.2.x - {3.8,3.9,3.10}-3.2.x {3.8,3.9,3.10,3.11,3.12,pypy38,pypy39,pypy310}-types @@ -44,7 +43,6 @@ basepython = deps = pytest - 3.2.x: Django>=3.2,<3.3 4.2.x: Django>=4.2,<4.3 5.0.x: Django>=5.0.1,<5.1 main: https://github.com/django/django/archive/main.tar.gz From 0afd172baa0a7892a70427492178fe47ca421f54 Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Fri, 28 Jun 2024 15:05:59 -0700 Subject: [PATCH 14/15] Replace `HttpResponse` type with `HttpResponseBase` --- csp/contrib/rate_limiting.py | 6 ++--- csp/decorators.py | 22 ++++++++++-------- csp/middleware.py | 8 +++---- csp/tests/test_decorators.py | 44 ++++++++++++++++++------------------ 4 files changed, 42 insertions(+), 38 deletions(-) diff --git a/csp/contrib/rate_limiting.py b/csp/contrib/rate_limiting.py index f26e86e..3645a47 100644 --- a/csp/contrib/rate_limiting.py +++ b/csp/contrib/rate_limiting.py @@ -8,14 +8,14 @@ from csp.utils import build_policy if TYPE_CHECKING: - from django.http import HttpRequest, HttpResponse + from django.http import HttpRequest, HttpResponseBase class RateLimitedCSPMiddleware(CSPMiddleware): """A CSP middleware that rate-limits the number of violation reports sent to report-uri by excluding it from some requests.""" - def build_policy(self, request: HttpRequest, response: HttpResponse) -> str: + def build_policy(self, request: HttpRequest, response: HttpResponseBase) -> str: config = getattr(response, "_csp_config", None) update = getattr(response, "_csp_update", None) replace = getattr(response, "_csp_replace", {}) @@ -33,7 +33,7 @@ def build_policy(self, request: HttpRequest, response: HttpResponse) -> str: return build_policy(config=config, update=update, replace=replace, nonce=nonce) - def build_policy_ro(self, request: HttpRequest, response: HttpResponse) -> str: + def build_policy_ro(self, request: HttpRequest, response: HttpResponseBase) -> str: config = getattr(response, "_csp_config_ro", None) update = getattr(response, "_csp_update_ro", None) replace = getattr(response, "_csp_replace_ro", {}) diff --git a/csp/decorators.py b/csp/decorators.py index 890d819..bcac3b6 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -1,10 +1,14 @@ +from __future__ import annotations + from functools import wraps -from typing import Callable, Optional, Any, Dict, List -from django.http import HttpRequest, HttpResponse +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponseBase -# A generic Django view function -_VIEW_T = Callable[[HttpRequest], HttpResponse] -_VIEW_DECORATOR_T = Callable[[_VIEW_T], _VIEW_T] + # A generic Django view function + _VIEW_T = Callable[[HttpRequest], HttpResponseBase] + _VIEW_DECORATOR_T = Callable[[_VIEW_T], _VIEW_T] def csp_exempt(REPORT_ONLY: Optional[bool] = None) -> _VIEW_DECORATOR_T: @@ -18,7 +22,7 @@ def csp_exempt(REPORT_ONLY: Optional[bool] = None) -> _VIEW_DECORATOR_T: def decorator(f: _VIEW_T) -> _VIEW_T: @wraps(f) - def _wrapped(*a: Any, **kw: Any) -> HttpResponse: + def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase: resp = f(*a, **kw) if REPORT_ONLY: setattr(resp, "_csp_exempt_ro", True) @@ -44,7 +48,7 @@ def csp_update(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = Fals def decorator(f: _VIEW_T) -> _VIEW_T: @wraps(f) - def _wrapped(*a: Any, **kw: Any) -> HttpResponse: + def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase: resp = f(*a, **kw) if REPORT_ONLY: setattr(resp, "_csp_update_ro", config) @@ -63,7 +67,7 @@ def csp_replace(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = Fal def decorator(f: _VIEW_T) -> _VIEW_T: @wraps(f) - def _wrapped(*a: Any, **kw: Any) -> HttpResponse: + def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase: resp = f(*a, **kw) if REPORT_ONLY: setattr(resp, "_csp_replace_ro", config) @@ -87,7 +91,7 @@ def csp(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = False, **kw def decorator(f: _VIEW_T) -> _VIEW_T: @wraps(f) - def _wrapped(*a: Any, **kw: Any) -> HttpResponse: + def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase: resp = f(*a, **kw) if REPORT_ONLY: setattr(resp, "_csp_config_ro", processed_config) diff --git a/csp/middleware.py b/csp/middleware.py index 0a33ec7..03cc745 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -13,7 +13,7 @@ from csp.utils import build_policy if TYPE_CHECKING: - from django.http import HttpRequest, HttpResponse + from django.http import HttpRequest, HttpResponseBase class CSPMiddleware(MiddlewareMixin): @@ -39,7 +39,7 @@ def process_request(self, request: HttpRequest) -> None: nonce = partial(self._make_nonce, request) setattr(request, "csp_nonce", SimpleLazyObject(nonce)) - def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: + def process_response(self, request: HttpRequest, response: HttpResponseBase) -> HttpResponseBase: # Check for debug view exempted_debug_codes = ( http_client.INTERNAL_SERVER_ERROR, @@ -72,14 +72,14 @@ def process_response(self, request: HttpRequest, response: HttpResponse) -> Http return response - def build_policy(self, request: HttpRequest, response: HttpResponse) -> str: + def build_policy(self, request: HttpRequest, response: HttpResponseBase) -> str: config = getattr(response, "_csp_config", None) update = getattr(response, "_csp_update", None) replace = getattr(response, "_csp_replace", None) nonce = getattr(request, "_csp_nonce", None) return build_policy(config=config, update=update, replace=replace, nonce=nonce) - def build_policy_ro(self, request: HttpRequest, response: HttpResponse) -> str: + def build_policy_ro(self, request: HttpRequest, response: HttpResponseBase) -> str: config = getattr(response, "_csp_config_ro", None) update = getattr(response, "_csp_update_ro", None) replace = getattr(response, "_csp_replace_ro", None) diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index 6e20b79..1a11dfd 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -12,14 +12,14 @@ from csp.tests.utils import response if TYPE_CHECKING: - from django.http import HttpRequest + from django.http import HttpRequest, HttpResponseBase mw = CSPMiddleware(response()) def test_csp_exempt() -> None: @csp_exempt() - def view(request: HttpRequest) -> HttpResponse: + def view(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view(RequestFactory().get("/")) @@ -29,7 +29,7 @@ def view(request: HttpRequest) -> HttpResponse: def test_csp_exempt_ro() -> None: @csp_exempt(REPORT_ONLY=True) - def view(request: HttpRequest) -> HttpResponse: + def view(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view(RequestFactory().get("/")) @@ -41,7 +41,7 @@ def view(request: HttpRequest) -> HttpResponse: def test_csp_update() -> None: request = RequestFactory().get("/") - def view_without_decorator(request: HttpRequest) -> HttpResponse: + def view_without_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -51,7 +51,7 @@ def view_without_decorator(request: HttpRequest) -> HttpResponse: assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_update({"img-src": ["bar.com", NONCE]}) - def view_with_decorator(request: HttpRequest) -> HttpResponse: + def view_with_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_with_decorator(request) @@ -74,7 +74,7 @@ def view_with_decorator(request: HttpRequest) -> HttpResponse: def test_csp_update_ro() -> None: request = RequestFactory().get("/") - def view_without_decorator(request: HttpRequest) -> HttpResponse: + def view_without_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -84,7 +84,7 @@ def view_without_decorator(request: HttpRequest) -> HttpResponse: assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_update({"img-src": ["bar.com", NONCE]}, REPORT_ONLY=True) - def view_with_decorator(request: HttpRequest) -> HttpResponse: + def view_with_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_with_decorator(request) @@ -107,7 +107,7 @@ def view_with_decorator(request: HttpRequest) -> HttpResponse: def test_csp_replace() -> None: request = RequestFactory().get("/") - def view_without_decorator(request: HttpRequest) -> HttpResponse: + def view_without_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -117,7 +117,7 @@ def view_without_decorator(request: HttpRequest) -> HttpResponse: assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_replace({"img-src": ["bar.com"]}) - def view_with_decorator(request: HttpRequest) -> HttpResponse: + def view_with_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_with_decorator(request) @@ -134,7 +134,7 @@ def view_with_decorator(request: HttpRequest) -> HttpResponse: assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_replace({"img-src": None}) - def view_removing_directive(request: HttpRequest) -> HttpResponse: + def view_removing_directive(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_removing_directive(request) @@ -148,7 +148,7 @@ def view_removing_directive(request: HttpRequest) -> HttpResponse: def test_csp_replace_ro() -> None: request = RequestFactory().get("/") - def view_without_decorator(request: HttpRequest) -> HttpResponse: + def view_without_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -158,7 +158,7 @@ def view_without_decorator(request: HttpRequest) -> HttpResponse: assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_replace({"img-src": ["bar.com"]}, REPORT_ONLY=True) - def view_with_decorator(request: HttpRequest) -> HttpResponse: + def view_with_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_with_decorator(request) @@ -175,7 +175,7 @@ def view_with_decorator(request: HttpRequest) -> HttpResponse: assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_replace({"img-src": None}, REPORT_ONLY=True) - def view_removing_directive(request: HttpRequest) -> HttpResponse: + def view_removing_directive(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_removing_directive(request) @@ -188,7 +188,7 @@ def view_removing_directive(request: HttpRequest) -> HttpResponse: def test_csp() -> None: request = RequestFactory().get("/") - def view_without_decorator(request: HttpRequest) -> HttpResponse: + def view_without_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -198,7 +198,7 @@ def view_without_decorator(request: HttpRequest) -> HttpResponse: assert policy_list == ["default-src 'self'"] @csp({"img-src": ["foo.com"], "font-src": ["bar.com"]}) - def view_with_decorator(request: HttpRequest) -> HttpResponse: + def view_with_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_with_decorator(request) @@ -218,7 +218,7 @@ def view_with_decorator(request: HttpRequest) -> HttpResponse: def test_csp_ro() -> None: request = RequestFactory().get("/") - def view_without_decorator(request: HttpRequest) -> HttpResponse: + def view_without_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -229,7 +229,7 @@ def view_without_decorator(request: HttpRequest) -> HttpResponse: @csp({"img-src": ["foo.com"], "font-src": ["bar.com"]}, REPORT_ONLY=True) @csp({}) # CSP with no directives effectively removes the header. - def view_with_decorator(request: HttpRequest) -> HttpResponse: + def view_with_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_with_decorator(request) @@ -251,7 +251,7 @@ def test_csp_string_values() -> None: request = RequestFactory().get("/") @csp({"img-src": "foo.com", "font-src": "bar.com"}) - def view_with_decorator(request: HttpRequest) -> HttpResponse: + def view_with_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_with_decorator(request) @@ -268,7 +268,7 @@ def test_csp_exempt_error() -> None: with pytest.raises(RuntimeError) as excinfo: # Ignore type error since we're checking for the exception raised for 3.x syntax @csp_exempt # type: ignore - def view(request: HttpRequest) -> HttpResponse: + def view(request: HttpRequest) -> HttpResponseBase: return HttpResponse() assert "Incompatible `csp_exempt` decorator usage" in str(excinfo.value) @@ -278,7 +278,7 @@ def test_csp_update_error() -> None: with pytest.raises(RuntimeError) as excinfo: @csp_update(IMG_SRC="bar.com") - def view(request: HttpRequest) -> HttpResponse: + def view(request: HttpRequest) -> HttpResponseBase: return HttpResponse() assert "Incompatible `csp_update` decorator arguments" in str(excinfo.value) @@ -288,7 +288,7 @@ def test_csp_replace_error() -> None: with pytest.raises(RuntimeError) as excinfo: @csp_replace(IMG_SRC="bar.com") - def view(request: HttpRequest) -> HttpResponse: + def view(request: HttpRequest) -> HttpResponseBase: return HttpResponse() assert "Incompatible `csp_replace` decorator arguments" in str(excinfo.value) @@ -298,7 +298,7 @@ def test_csp_error() -> None: with pytest.raises(RuntimeError) as excinfo: @csp(IMG_SRC=["bar.com"]) - def view(request: HttpRequest) -> HttpResponse: + def view(request: HttpRequest) -> HttpResponseBase: return HttpResponse() assert "Incompatible `csp` decorator arguments" in str(excinfo.value) From a14b51c8ea61850079e947c026c96545ba22ef7f Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Mon, 1 Jul 2024 09:28:31 -0700 Subject: [PATCH 15/15] Update CHANGES file --- CHANGES => CHANGES.md | 8 +++++--- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) rename CHANGES => CHANGES.md (97%) diff --git a/CHANGES b/CHANGES.md similarity index 97% rename from CHANGES rename to CHANGES.md index ec36bfc..bc9f57e 100644 --- a/CHANGES +++ b/CHANGES.md @@ -1,10 +1,12 @@ -======= CHANGES ======= -4.x - Unreleased -================ +Unreleased +=========== +- Add type hints. ([#228](https://github.com/mozilla/django-csp/pull/228)) +4.0b1 +===== BACKWARDS INCOMPATIBLE changes: - Move to dict-based configuration which allows for setting policies for both enforced and report-only. See the migration guide in the docs for migrating your settings. diff --git a/pyproject.toml b/pyproject.toml index 635a21d..78ceec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ optional-dependencies.typing = [ "types-setuptools", ] urls."Bug Tracker" = "https://github.com/mozilla/django-csp/issues" -urls.Changelog = "https://github.com/mozilla/django-csp/blob/main/CHANGES" +urls.Changelog = "https://github.com/mozilla/django-csp/blob/main/CHANGES.md" urls.Documentation = "http://django-csp.readthedocs.org/" urls.Homepage = "http://github.com/mozilla/django-csp" urls."Source Code" = "https://github.com/mozilla/django-csp"