Skip to content

Commit 2bb3e6d

Browse files
jwhitlockRob Hudson
andauthored
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 <[email protected]>
1 parent b0a5e45 commit 2bb3e6d

27 files changed

+441
-258
lines changed

CHANGES renamed to CHANGES.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
=======
21
CHANGES
32
=======
43

5-
4.x - Unreleased
6-
================
4+
Unreleased
5+
===========
6+
- Add type hints. ([#228](https://github.com/mozilla/django-csp/pull/228))
77

8+
4.0b1
9+
=====
810
BACKWARDS INCOMPATIBLE changes:
911
- Move to dict-based configuration which allows for setting policies for both enforced and
1012
report-only. See the migration guide in the docs for migrating your settings.

csp/apps.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,7 @@
77
class CspConfig(AppConfig):
88
name = "csp"
99

10-
def ready(self):
11-
checks.register(check_django_csp_lt_4_0, checks.Tags.security)
10+
def ready(self) -> None:
11+
# Ignore known issue typeddjango/django-stubs #2232
12+
# The overload of CheckRegistry.register as a function is incomplete
13+
checks.register(check_django_csp_lt_4_0, checks.Tags.security) # type: ignore

csp/checks.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
from __future__ import annotations
12
import pprint
3+
from typing import Dict, Tuple, Any, Optional, Sequence, TYPE_CHECKING, List
24

35
from django.conf import settings
46
from django.core.checks import Error
57

68
from csp.constants import NONCE
79

10+
if TYPE_CHECKING:
11+
from django.apps.config import AppConfig
12+
813

914
OUTDATED_SETTINGS = [
1015
"CSP_CHILD_SRC",
@@ -40,21 +45,21 @@
4045
]
4146

4247

43-
def migrate_settings():
48+
def migrate_settings() -> Tuple[Dict[str, Any], bool]:
4449
# This function is used to migrate settings from the old format to the new format.
45-
config = {
50+
config: Dict[str, Any] = {
4651
"DIRECTIVES": {},
4752
}
48-
REPORT_ONLY = False
4953

50-
if hasattr(settings, "CSP_REPORT_ONLY"):
51-
REPORT_ONLY = settings.CSP_REPORT_ONLY
54+
REPORT_ONLY = getattr(settings, "CSP_REPORT_ONLY", False)
5255

53-
if hasattr(settings, "CSP_EXCLUDE_URL_PREFIXES"):
54-
config["EXCLUDE_URL_PREFIXES"] = settings.CSP_EXCLUDE_URL_PREFIXES
56+
_EXCLUDE_URL_PREFIXES = getattr(settings, "CSP_EXCLUDE_URL_PREFIXES", None)
57+
if _EXCLUDE_URL_PREFIXES is not None:
58+
config["EXCLUDE_URL_PREFIXES"] = _EXCLUDE_URL_PREFIXES
5559

56-
if hasattr(settings, "CSP_REPORT_PERCENTAGE"):
57-
config["REPORT_PERCENTAGE"] = round(settings.CSP_REPORT_PERCENTAGE * 100)
60+
_REPORT_PERCENTAGE = getattr(settings, "CSP_REPORT_PERCENTAGE", None)
61+
if _REPORT_PERCENTAGE is not None:
62+
config["REPORT_PERCENTAGE"] = round(_REPORT_PERCENTAGE * 100)
5863

5964
include_nonce_in = getattr(settings, "CSP_INCLUDE_NONCE_IN", [])
6065

@@ -70,7 +75,7 @@ def migrate_settings():
7075
return config, REPORT_ONLY
7176

7277

73-
def check_django_csp_lt_4_0(app_configs, **kwargs):
78+
def check_django_csp_lt_4_0(app_configs: Optional[Sequence[AppConfig]], **kwargs: Any) -> List[Error]:
7479
check_settings = OUTDATED_SETTINGS + ["CSP_REPORT_ONLY", "CSP_EXCLUDE_URL_PREFIXES", "CSP_REPORT_PERCENTAGE"]
7580
if any(hasattr(settings, setting) for setting in check_settings):
7681
# Try to build the new config.

csp/constants.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any, Type
2+
13
HEADER = "Content-Security-Policy"
24
HEADER_REPORT_ONLY = "Content-Security-Policy-Report-Only"
35

@@ -15,12 +17,12 @@
1517
class Nonce:
1618
_instance = None
1719

18-
def __new__(cls, *args, **kwargs):
20+
def __new__(cls: Type["Nonce"], *args: Any, **kwargs: Any) -> "Nonce":
1921
if cls._instance is None:
2022
cls._instance = super().__new__(cls)
2123
return cls._instance
2224

23-
def __repr__(self):
25+
def __repr__(self) -> str:
2426
return "csp.constants.NONCE"
2527

2628

csp/context_processors.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
def nonce(request):
1+
from __future__ import annotations
2+
from typing import Dict, Literal, TYPE_CHECKING
3+
4+
if TYPE_CHECKING:
5+
from django.http import HttpRequest
6+
7+
8+
def nonce(request: HttpRequest) -> Dict[Literal["CSP_NONCE"], str]:
29
nonce = request.csp_nonce if hasattr(request, "csp_nonce") else ""
310

411
return {"CSP_NONCE": nonce}

csp/contrib/rate_limiting.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1+
from __future__ import annotations
2+
from typing import TYPE_CHECKING
13
import random
24

35
from django.conf import settings
46

57
from csp.middleware import CSPMiddleware
68
from csp.utils import build_policy
79

10+
if TYPE_CHECKING:
11+
from django.http import HttpRequest, HttpResponseBase
12+
813

914
class RateLimitedCSPMiddleware(CSPMiddleware):
1015
"""A CSP middleware that rate-limits the number of violation reports sent
1116
to report-uri by excluding it from some requests."""
1217

13-
def build_policy(self, request, response):
18+
def build_policy(self, request: HttpRequest, response: HttpResponseBase) -> str:
1419
config = getattr(response, "_csp_config", None)
1520
update = getattr(response, "_csp_update", None)
1621
replace = getattr(response, "_csp_replace", {})
@@ -28,7 +33,7 @@ def build_policy(self, request, response):
2833

2934
return build_policy(config=config, update=update, replace=replace, nonce=nonce)
3035

31-
def build_policy_ro(self, request, response):
36+
def build_policy_ro(self, request: HttpRequest, response: HttpResponseBase) -> str:
3237
config = getattr(response, "_csp_config_ro", None)
3338
update = getattr(response, "_csp_update_ro", None)
3439
replace = getattr(response, "_csp_replace_ro", {})

csp/decorators.py

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1+
from __future__ import annotations
2+
13
from functools import wraps
4+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
5+
6+
if TYPE_CHECKING:
7+
from django.http import HttpRequest, HttpResponseBase
8+
9+
# A generic Django view function
10+
_VIEW_T = Callable[[HttpRequest], HttpResponseBase]
11+
_VIEW_DECORATOR_T = Callable[[_VIEW_T], _VIEW_T]
212

313

4-
def csp_exempt(REPORT_ONLY=None):
14+
def csp_exempt(REPORT_ONLY: Optional[bool] = None) -> _VIEW_DECORATOR_T:
515
if callable(REPORT_ONLY):
616
raise RuntimeError(
717
"Incompatible `csp_exempt` decorator usage. This decorator now requires arguments, "
@@ -10,14 +20,14 @@ def csp_exempt(REPORT_ONLY=None):
1020
"information."
1121
)
1222

13-
def decorator(f):
23+
def decorator(f: _VIEW_T) -> _VIEW_T:
1424
@wraps(f)
15-
def _wrapped(*a, **kw):
25+
def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase:
1626
resp = f(*a, **kw)
1727
if REPORT_ONLY:
18-
resp._csp_exempt_ro = True
28+
setattr(resp, "_csp_exempt_ro", True)
1929
else:
20-
resp._csp_exempt = True
30+
setattr(resp, "_csp_exempt", True)
2131
return resp
2232

2333
return _wrapped
@@ -32,58 +42,61 @@ def _wrapped(*a, **kw):
3242
)
3343

3444

35-
def csp_update(config=None, REPORT_ONLY=False, **kwargs):
45+
def csp_update(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T:
3646
if config is None and kwargs:
3747
raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp_update"))
3848

39-
def decorator(f):
49+
def decorator(f: _VIEW_T) -> _VIEW_T:
4050
@wraps(f)
41-
def _wrapped(*a, **kw):
51+
def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase:
4252
resp = f(*a, **kw)
4353
if REPORT_ONLY:
44-
resp._csp_update_ro = config
54+
setattr(resp, "_csp_update_ro", config)
4555
else:
46-
resp._csp_update = config
56+
setattr(resp, "_csp_update", config)
4757
return resp
4858

4959
return _wrapped
5060

5161
return decorator
5262

5363

54-
def csp_replace(config=None, REPORT_ONLY=False, **kwargs):
64+
def csp_replace(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T:
5565
if config is None and kwargs:
5666
raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp_replace"))
5767

58-
def decorator(f):
68+
def decorator(f: _VIEW_T) -> _VIEW_T:
5969
@wraps(f)
60-
def _wrapped(*a, **kw):
70+
def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase:
6171
resp = f(*a, **kw)
6272
if REPORT_ONLY:
63-
resp._csp_replace_ro = config
73+
setattr(resp, "_csp_replace_ro", config)
6474
else:
65-
resp._csp_replace = config
75+
setattr(resp, "_csp_replace", config)
6676
return resp
6777

6878
return _wrapped
6979

7080
return decorator
7181

7282

73-
def csp(config=None, REPORT_ONLY=False, **kwargs):
83+
def csp(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T:
7484
if config is None and kwargs:
7585
raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp"))
7686

77-
config = {k: [v] if isinstance(v, str) else v for k, v in config.items()}
87+
if config is None:
88+
processed_config: Dict[str, List[Any]] = {}
89+
else:
90+
processed_config = {k: [v] if isinstance(v, str) else v for k, v in config.items()}
7891

79-
def decorator(f):
92+
def decorator(f: _VIEW_T) -> _VIEW_T:
8093
@wraps(f)
81-
def _wrapped(*a, **kw):
94+
def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase:
8295
resp = f(*a, **kw)
8396
if REPORT_ONLY:
84-
resp._csp_config_ro = config
97+
setattr(resp, "_csp_config_ro", processed_config)
8598
else:
86-
resp._csp_config = config
99+
setattr(resp, "_csp_config", processed_config)
87100
return resp
88101

89102
return _wrapped

csp/extensions/__init__.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1+
from __future__ import annotations
2+
from typing import Callable, TYPE_CHECKING, Any
3+
14
from jinja2 import nodes
25
from jinja2.ext import Extension
36

47
from csp.utils import SCRIPT_ATTRS, build_script_tag
58

9+
if TYPE_CHECKING:
10+
from jinja2.parser import Parser
11+
612

713
class NoncedScript(Extension):
814
# a set of names that trigger the extension.
915
tags = {"script"}
1016

11-
def parse(self, parser):
17+
def parse(self, parser: Parser) -> nodes.Node:
1218
# the first token is the token that started the tag. In our case
1319
# we only listen to ``'script'`` so this will be a name token with
1420
# `script` as value. We get the line number so that we can give
@@ -26,13 +32,13 @@ def parse(self, parser):
2632

2733
# now we parse the body of the script block up to `endscript` and
2834
# drop the needle (which would always be `endscript` in that case)
29-
body = parser.parse_statements(["name:endscript"], drop_needle=True)
35+
body = parser.parse_statements(("name:endscript",), drop_needle=True)
3036

3137
# now return a `CallBlock` node that calls our _render_script
3238
# helper method on this extension.
3339
return nodes.CallBlock(self.call_method("_render_script", kwargs=kwargs), [], [], body).set_lineno(lineno)
3440

35-
def _render_script(self, caller, **kwargs):
41+
def _render_script(self, caller: Callable[[], str], **kwargs: Any) -> str:
3642
ctx = kwargs.pop("ctx")
3743
request = ctx.get("request")
3844
kwargs["nonce"] = request.csp_nonce

csp/middleware.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
from __future__ import annotations
12
import base64
23
import http.client as http_client
34
import os
45
from functools import partial
6+
from typing import TYPE_CHECKING
57

68
from django.conf import settings
79
from django.utils.deprecation import MiddlewareMixin
@@ -10,6 +12,9 @@
1012
from csp.constants import HEADER, HEADER_REPORT_ONLY
1113
from csp.utils import build_policy
1214

15+
if TYPE_CHECKING:
16+
from django.http import HttpRequest, HttpResponseBase
17+
1318

1419
class CSPMiddleware(MiddlewareMixin):
1520
"""
@@ -21,17 +26,20 @@ class CSPMiddleware(MiddlewareMixin):
2126
2227
"""
2328

24-
def _make_nonce(self, request):
29+
def _make_nonce(self, request: HttpRequest) -> str:
2530
# Ensure that any subsequent calls to request.csp_nonce return the same value
26-
if not getattr(request, "_csp_nonce", None):
27-
request._csp_nonce = base64.b64encode(os.urandom(16)).decode("ascii")
28-
return request._csp_nonce
31+
stored_nonce = getattr(request, "_csp_nonce", None)
32+
if isinstance(stored_nonce, str):
33+
return stored_nonce
34+
nonce = base64.b64encode(os.urandom(16)).decode("ascii")
35+
setattr(request, "_csp_nonce", nonce)
36+
return nonce
2937

30-
def process_request(self, request):
38+
def process_request(self, request: HttpRequest) -> None:
3139
nonce = partial(self._make_nonce, request)
32-
request.csp_nonce = SimpleLazyObject(nonce)
40+
setattr(request, "csp_nonce", SimpleLazyObject(nonce))
3341

34-
def process_response(self, request, response):
42+
def process_response(self, request: HttpRequest, response: HttpResponseBase) -> HttpResponseBase:
3543
# Check for debug view
3644
exempted_debug_codes = (
3745
http_client.INTERNAL_SERVER_ERROR,
@@ -45,8 +53,9 @@ def process_response(self, request, response):
4553
# Only set header if not already set and not an excluded prefix and not exempted.
4654
is_not_exempt = getattr(response, "_csp_exempt", False) is False
4755
no_header = HEADER not in response
48-
prefixes = getattr(settings, "CONTENT_SECURITY_POLICY", {}).get("EXCLUDE_URL_PREFIXES", ())
49-
is_not_excluded = not request.path_info.startswith(prefixes)
56+
policy = getattr(settings, "CONTENT_SECURITY_POLICY", None) or {}
57+
prefixes = policy.get("EXCLUDE_URL_PREFIXES", None) or ()
58+
is_not_excluded = not request.path_info.startswith(tuple(prefixes))
5059
if all((no_header, is_not_exempt, is_not_excluded)):
5160
response[HEADER] = csp
5261

@@ -55,21 +64,22 @@ def process_response(self, request, response):
5564
# Only set header if not already set and not an excluded prefix and not exempted.
5665
is_not_exempt = getattr(response, "_csp_exempt_ro", False) is False
5766
no_header = HEADER_REPORT_ONLY not in response
58-
prefixes = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", {}).get("EXCLUDE_URL_PREFIXES", ())
59-
is_not_excluded = not request.path_info.startswith(prefixes)
67+
policy = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", None) or {}
68+
prefixes = policy.get("EXCLUDE_URL_PREFIXES", None) or ()
69+
is_not_excluded = not request.path_info.startswith(tuple(prefixes))
6070
if all((no_header, is_not_exempt, is_not_excluded)):
6171
response[HEADER_REPORT_ONLY] = csp_ro
6272

6373
return response
6474

65-
def build_policy(self, request, response):
75+
def build_policy(self, request: HttpRequest, response: HttpResponseBase) -> str:
6676
config = getattr(response, "_csp_config", None)
6777
update = getattr(response, "_csp_update", None)
6878
replace = getattr(response, "_csp_replace", None)
6979
nonce = getattr(request, "_csp_nonce", None)
7080
return build_policy(config=config, update=update, replace=replace, nonce=nonce)
7181

72-
def build_policy_ro(self, request, response):
82+
def build_policy_ro(self, request: HttpRequest, response: HttpResponseBase) -> str:
7383
config = getattr(response, "_csp_config_ro", None)
7484
update = getattr(response, "_csp_update_ro", None)
7585
replace = getattr(response, "_csp_replace_ro", None)

csp/py.typed

Whitespace-only changes.

0 commit comments

Comments
 (0)