Files
team-tryouts/tests/test_csp.py
T
GGThedandClaude Opus 5 7cec18c139 style: formater le depot avec ruff format
QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun
changement de comportement, aucune ligne de logique touchee. 72 fichiers,
4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur
les commits voisins reste lisible.

`quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui
evite le brassage guillemets simples / doubles : le diff porte sur les
retours a la ligne, l indentation des appels longs et les virgules
finales, pas sur le style de chaine.

Verification : 263 tests passent avant et apres, ruff check propre.

L activation en CI arrive dans le commit suivant, separement, pour que ce
diff-ci ne contienne rien d autre.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:53:10 -04:00

146 lines
5.4 KiB
Python

"""Content Security Policy.
SEC-WEB-001 / OPS-010. script-src no longer carries 'unsafe-inline': the
directive that let the stored XSS of SEC-XSS-001 execute instead of being
blocked. Inline scripts are authorised by a per-request nonce, and every
inline event handler has been replaced by a data-action attribute
dispatched from main.js.
Getting here took removing 82 handlers across 17 templates, because a nonce
authorises `<script>` elements and can do nothing for `onclick`. These
tests keep it that way: one new inline handler, and the policy silently
stops applying to that page.
"""
import os
import re
import pytest
from app.app import build_csp
TEMPLATE_ROOT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'app',
'templates',
)
#: Attributes a nonce can never authorise.
INLINE_HANDLER = re.compile(
r'\son(?:click|change|submit|input|load|keyup|keydown|mouseover|focus|blur)\s*=',
re.I,
)
#: Remaining inline handlers, per template. Lower these as you migrate;
#: never raise one. Templates absent from this map must have none.
#: No template may carry an inline event handler. The migration is done;
#: this is now a hard rule, not a countdown.
HANDLER_BUDGET = {}
#: What the ratchet is counting down to.
TOTAL_BUDGET = sum(HANDLER_BUDGET.values())
def _templates():
for root, _dirs, files in os.walk(TEMPLATE_ROOT):
for name in files:
if name.endswith('.html'):
full = os.path.join(root, name)
rel = os.path.relpath(full, TEMPLATE_ROOT).replace(os.sep, '/')
yield rel, full
def _count_handlers(path):
with open(path, encoding='utf-8') as handle:
return len(INLINE_HANDLER.findall(handle.read()))
class TestPolicyHeader:
def test_script_src_no_longer_allows_inline(self, client):
csp = client.get('/auth/login').headers['Content-Security-Policy']
script_src = next(d for d in csp.split(';') if 'script-src' in d)
assert "'unsafe-inline'" not in script_src
assert "'nonce-" in script_src
def test_the_policy_pins_the_dangerous_directives(self, client):
csp = client.get('/auth/login').headers['Content-Security-Policy']
assert "default-src 'self'" in csp
assert "frame-ancestors 'none'" in csp
assert "base-uri 'self'" in csp
assert "form-action 'self'" in csp
assert "object-src" not in csp or "object-src 'none'" in csp
def test_the_legacy_mode_still_builds(self):
"""The escape hatch is kept for a deployment that hits an overlooked
handler. Emitting both would be pointless: a nonce makes browsers
ignore 'unsafe-inline' entirely."""
csp = build_csp(allow_inline_script=True)
assert "'unsafe-inline'" in csp
assert 'nonce-' not in csp
def test_the_hardened_policy_carries_a_nonce_and_no_unsafe_inline(self):
csp = build_csp(allow_inline_script=False, nonce='abc123')
assert "'nonce-abc123'" in csp
assert "'unsafe-inline'" not in csp.split('style-src')[0]
def test_each_request_gets_a_distinct_nonce(self, app):
"""A reused nonce is worth no more than 'unsafe-inline'."""
application = app
application.config['CSP_ALLOW_INLINE_SCRIPT'] = False
client = application.test_client()
seen = set()
for _ in range(5):
csp = client.get('/auth/login').headers['Content-Security-Policy']
seen.add(re.search(r"'nonce-([^']+)'", csp).group(1))
assert len(seen) == 5
class TestInlineHandlerRatchet:
@pytest.mark.parametrize('relative,full', list(_templates()))
def test_a_template_never_gains_an_inline_handler(self, relative, full):
allowed = HANDLER_BUDGET.get(relative, 0)
found = _count_handlers(full)
assert found <= allowed, (
f'{relative} has {found} inline event handler(s), budget is '
f'{allowed}. A nonce cannot authorise these — use '
f'data-action="..." and the delegated listener in main.js.'
)
def test_every_inline_script_block_carries_a_nonce(self):
"""Without a nonce a block is simply not executed now, and nothing
in the server logs says so."""
import re as _re
offenders = []
for relative, full in _templates():
with open(full, encoding='utf-8') as handle:
content = handle.read()
for tag in _re.findall(r'<script[^>]*>', content):
if 'src=' in tag or 'nonce=' in tag:
continue
offenders.append(f'{relative}: {tag}')
assert not offenders, 'inline <script> without nonce="{{ csp_nonce }}": ' + str(offenders)
def test_the_shared_layout_is_free_of_them(self):
"""base.html and macros.html render on every single page."""
for relative in ('layouts/base.html', 'layouts/macros.html'):
full = os.path.join(TEMPLATE_ROOT, *relative.split('/'))
assert _count_handlers(full) == 0, f'{relative} regressed'
def test_no_template_carries_an_inline_handler(self):
total = sum(_count_handlers(full) for _rel, full in _templates())
assert total == 0, (
f'{total} inline event handler(s) reintroduced. They are not '
f'covered by the nonce, so they will not run — use '
f'data-action="..." and registerActions() instead.'
)