From 2bb3e6d6c96b28816539ea47bfb8e27b4e687487 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Mon, 1 Jul 2024 12:16:09 -0500 Subject: [PATCH] Add type hints, fix mypy issues (#198) (#228) * 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. * Add mypy for type checking * Handle case where config is None * 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. * Use tuples where requested - Both startswith() and parser.parse_statements take a tuple rather than a list. * Add type hints * 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. * Fix Sphinx doc generation without setuptools * Add `pip install -e ".[dev]"` * Update docs for typing, etc. * Add PEP 561 py.typed file * Bump Django dependency to 4.2+ * Replace `HttpResponse` type with `HttpResponseBase` * Update CHANGES file --------- Co-authored-by: Rob Hudson --- CHANGES => CHANGES.md | 8 ++- csp/apps.py | 6 +- csp/checks.py | 25 ++++--- csp/constants.py | 6 +- csp/context_processors.py | 9 ++- csp/contrib/rate_limiting.py | 9 ++- csp/decorators.py | 55 ++++++++------ csp/extensions/__init__.py | 12 +++- csp/middleware.py | 36 ++++++---- csp/py.typed | 0 csp/templatetags/csp.py | 22 ++++-- csp/tests/environment.py | 3 +- csp/tests/test_checks.py | 8 +-- csp/tests/test_constants.py | 2 +- csp/tests/test_context_processors.py | 6 +- csp/tests/test_contrib.py | 8 +-- csp/tests/test_decorators.py | 104 ++++++++++++++------------- csp/tests/test_jinja_extension.py | 14 ++-- csp/tests/test_middleware.py | 44 ++++++------ csp/tests/test_templatetags.py | 14 ++-- csp/tests/test_utils.py | 102 +++++++++++++------------- csp/tests/utils.py | 47 ++++++------ csp/utils.py | 25 ++++--- docs/conf.py | 9 +-- docs/contributing.rst | 66 ++++++++++++++++- pyproject.toml | 36 +++++++++- tox.ini | 23 +++--- 27 files changed, 441 insertions(+), 258 deletions(-) rename CHANGES => CHANGES.md (97%) create mode 100644 csp/py.typed 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/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 8a79fa6..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,21 +45,21 @@ ] -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": {}, } - 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", []) @@ -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..3645a47 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, 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, response): + 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", {}) @@ -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: 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 3d71b6c..bcac3b6 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -1,7 +1,17 @@ +from __future__ import annotations + from functools import wraps +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], HttpResponseBase] + _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,14 +20,14 @@ 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) -> HttpResponseBase: 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 @@ -32,18 +42,18 @@ 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) -> HttpResponseBase: 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 @@ -51,18 +61,18 @@ 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) -> HttpResponseBase: 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 @@ -70,20 +80,23 @@ 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")) - config = {k: [v] if isinstance(v, str) else v for k, v in config.items()} + if config is None: + 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) -> HttpResponseBase: resp = f(*a, **kw) if REPORT_ONLY: - resp._csp_config_ro = config + setattr(resp, "_csp_config_ro", processed_config) else: - resp._csp_config = config + setattr(resp, "_csp_config", processed_config) return resp return _wrapped diff --git a/csp/extensions/__init__.py b/csp/extensions/__init__.py index 2e152b8..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 @@ -26,13 +32,13 @@ 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. 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 11bf799..03cc745 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, HttpResponseBase + class CSPMiddleware(MiddlewareMixin): """ @@ -21,17 +26,20 @@ 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 - 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): + def process_request(self, request: HttpRequest) -> None: nonce = partial(self._make_nonce, request) - request.csp_nonce = SimpleLazyObject(nonce) + setattr(request, "csp_nonce", SimpleLazyObject(nonce)) - def process_response(self, request, response): + def process_response(self, request: HttpRequest, response: HttpResponseBase) -> HttpResponseBase: # Check for debug view exempted_debug_codes = ( http_client.INTERNAL_SERVER_ERROR, @@ -45,8 +53,9 @@ 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", ()) - is_not_excluded = not request.path_info.startswith(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(tuple(prefixes)) if all((no_header, is_not_exempt, is_not_excluded)): response[HEADER] = csp @@ -55,21 +64,22 @@ 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", ()) - is_not_excluded = not request.path_info.startswith(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(tuple(prefixes)) if all((no_header, is_not_exempt, is_not_excluded)): response[HEADER_REPORT_ONLY] = csp_ro return response - def build_policy(self, request, response): + 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, response): + 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/py.typed b/csp/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/csp/templatetags/csp.py b/csp/templatetags/csp.py index d31a921..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,19 +30,21 @@ 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): - return _unquote(t.token) if getattr(t, "token", None) else None + 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 = 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/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 666f53f..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) @@ -17,10 +17,10 @@ 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(): +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 d75819d..1a11dfd 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,34 +11,37 @@ from csp.middleware import CSPMiddleware from csp.tests.utils import response +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponseBase + mw = CSPMiddleware(response()) -def test_csp_exempt(): +def test_csp_exempt() -> None: @csp_exempt() - def view(request): + def view(request: HttpRequest) -> HttpResponseBase: 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") -def test_csp_exempt_ro(): +def test_csp_exempt_ro() -> None: @csp_exempt(REPORT_ONLY=True) - def view(request): + def view(request: HttpRequest) -> HttpResponseBase: return HttpResponse() 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"]}}) -def test_csp_update(): +def test_csp_update() -> None: request = RequestFactory().get("/") - def view_without_decorator(request): + def view_without_decorator(request: HttpRequest) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -45,17 +51,17 @@ 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) -> HttpResponseBase: 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) @@ -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) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -78,17 +84,17 @@ 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) -> HttpResponseBase: 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) @@ -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) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -111,11 +117,11 @@ 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) -> HttpResponseBase: 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("; ")) @@ -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) -> HttpResponseBase: 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) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -152,11 +158,11 @@ 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) -> HttpResponseBase: 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("; ")) @@ -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) -> HttpResponseBase: 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) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -192,11 +198,11 @@ 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) -> HttpResponseBase: 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("; ")) @@ -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) -> HttpResponseBase: return HttpResponse() response = view_without_decorator(request) @@ -223,11 +229,11 @@ 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) -> HttpResponseBase: 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("; ")) @@ -240,16 +246,16 @@ 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) -> HttpResponseBase: 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"] @@ -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) -> HttpResponseBase: 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) -> HttpResponseBase: 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) -> HttpResponseBase: 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) -> HttpResponseBase: 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 f8fad35..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,34 +69,34 @@ 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() - 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" -def test_use_update(): +def test_use_update() -> None: 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" @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["foo.com"]}}) -def test_use_replace(): +def test_use_replace() -> None: 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"] @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,23 +104,23 @@ 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(request.csp_nonce) + nonce = str(getattr(request, "csp_nonce")) response = HttpResponse() mw.process_response(request, response) 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,14 +128,14 @@ 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) 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/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 2d96b1c..36512ca 100644 --- a/csp/tests/utils.py +++ b/csp/tests/utils.py @@ -1,12 +1,20 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Dict, Optional, TYPE_CHECKING, Callable, Any, Tuple + 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 + -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 +29,32 @@ 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) - ctx = self.make_context(request) - return ( - self.make_template(tpl).render(ctx).strip(), - expected.format(request.csp_nonce), - ) + nonce = getattr(request, "csp_nonce") + assert isinstance(nonce, SimpleLazyObject) + return (self.render(tpl, request).strip(), expected.format(nonce)) + @abstractmethod + def render(self, template_string: str, request: HttpRequest) -> str: ... -class ScriptTagTestBase(ScriptTestBase): - def make_context(self, request): - return Context({"request": request}) - def make_template(self, tpl): - return Template(tpl) +class ScriptTagTestBase(ScriptTestBase): + 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): - return {"request": request} - - def make_template(self, tpl): - 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) 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/docs/conf.py b/docs/conf.py index dc1ebe9..2c490d4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,7 +10,8 @@ # 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, # add these directories to sys.path here. If the directory is relative to the @@ -50,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 @@ -122,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. @@ -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/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 677c19d..78ceec9 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,22 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "django>=3.2", + "django>=4.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", + "tox-gh-actions", + "types-setuptools", ] optional-dependencies.jinja2 = [ "jinja2>=2.9.6", @@ -50,8 +64,16 @@ optional-dependencies.tests = [ "pytest-django", "pytest-ruff", ] +optional-dependencies.typing = [ + "django-stubs[compatible-mypy]", + "jinja2>=2.9.6", + "mypy", + "pytest", + "pytest-django", + "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" @@ -66,3 +88,11 @@ 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'] +strict = true + +[tool.django-stubs] +django_settings_module = "csp.tests.settings" diff --git a/tox.ini b/tox.ini index a5b476c..b993c17 100644 --- a/tox.ini +++ b/tox.ini @@ -4,17 +4,22 @@ 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.11,3.12,pypy38,pypy39,pypy310}-types # 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] 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 = @@ -32,7 +37,9 @@ 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 @@ -45,11 +52,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