Skip to content

Commit 81ee2b6

Browse files
test(csp): tighten CSP assertions with parsed directives and nonce rotation
1 parent 9735d95 commit 81ee2b6

1 file changed

Lines changed: 65 additions & 12 deletions

File tree

tests/test_csp_header.py

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,30 @@
44

55
import re
66

7-
from app import build_content_security_policy
7+
_CDNJS_ORIGIN = "https://cdnjs.cloudflare.com"
8+
_EXPECTED_CSP_DIRECTIVES = {
9+
"default-src": ["'self'"],
10+
"script-src": ["'self'", _CDNJS_ORIGIN],
11+
"style-src": ["'self'", "'unsafe-inline'", _CDNJS_ORIGIN],
12+
"img-src": ["'self'", "data:"],
13+
"connect-src": ["'self'"],
14+
"font-src": ["'self'"],
15+
"object-src": ["'none'"],
16+
"form-action": ["'self'"],
17+
"base-uri": ["'self'"],
18+
"frame-ancestors": ["'none'"],
19+
}
20+
21+
22+
def _parse_csp_directives(csp: str) -> dict[str, list[str]]:
23+
directives: dict[str, list[str]] = {}
24+
for part in csp.split(";"):
25+
part = part.strip()
26+
if not part:
27+
continue
28+
name, _, value = part.partition(" ")
29+
directives[name] = value.split()
30+
return directives
831

932

1033
def _extract_csp_nonce(csp: str) -> str:
@@ -13,25 +36,55 @@ def _extract_csp_nonce(csp: str) -> str:
1336
return match.group(1)
1437

1538

39+
def _assert_expected_csp_directives(csp: str, nonce: str) -> None:
40+
directives = _parse_csp_directives(csp)
41+
assert set(directives) == set(_EXPECTED_CSP_DIRECTIVES)
42+
43+
for name, expected_tokens in _EXPECTED_CSP_DIRECTIVES.items():
44+
actual_tokens = directives[name]
45+
if name == "script-src":
46+
nonce_tokens = [
47+
token for token in actual_tokens if token.startswith("'nonce-")
48+
]
49+
non_nonce_tokens = [
50+
token for token in actual_tokens if not token.startswith("'nonce-")
51+
]
52+
assert non_nonce_tokens == expected_tokens
53+
assert len(nonce_tokens) == 1
54+
assert nonce_tokens[0] == f"'nonce-{nonce}'"
55+
else:
56+
assert actual_tokens == expected_tokens
57+
58+
59+
def _assert_inline_script_nonce(html: str, nonce: str) -> None:
60+
pattern = rf'<script\s+nonce="{re.escape(nonce)}"'
61+
assert re.search(pattern, html) is not None
62+
63+
1664
def test_html_page_has_content_security_policy_header(client) -> None:
1765
response = client.get("/")
1866
assert response.status_code == 200
1967
csp = response.headers.get("Content-Security-Policy")
2068
assert csp
21-
assert "default-src 'self'" in csp
22-
assert "script-src 'self' https://cdnjs.cloudflare.com" in csp
23-
assert "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com" in csp
24-
assert "'nonce-" in csp
69+
nonce = _extract_csp_nonce(csp)
70+
_assert_expected_csp_directives(csp, nonce)
2571

2672

2773
def test_html_page_nonce_matches_inline_script(client) -> None:
28-
response = client.get("/search")
29-
assert response.status_code == 200
30-
csp = response.headers.get("Content-Security-Policy", "")
31-
nonce = _extract_csp_nonce(csp)
32-
html = response.get_data(as_text=True)
33-
assert f'nonce="{nonce}"' in html
34-
assert build_content_security_policy(nonce) == csp
74+
first = client.get("/search")
75+
second = client.get("/search")
76+
assert first.status_code == 200
77+
assert second.status_code == 200
78+
79+
first_csp = first.headers.get("Content-Security-Policy", "")
80+
second_csp = second.headers.get("Content-Security-Policy", "")
81+
first_nonce = _extract_csp_nonce(first_csp)
82+
second_nonce = _extract_csp_nonce(second_csp)
83+
assert first_nonce != second_nonce
84+
85+
html = first.get_data(as_text=True)
86+
_assert_expected_csp_directives(first_csp, first_nonce)
87+
_assert_inline_script_nonce(html, first_nonce)
3588

3689

3790
def test_json_api_response_has_no_content_security_policy_header(client) -> None:

0 commit comments

Comments
 (0)