178 lines
6.8 KiB
Python
178 lines
6.8 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,
|
|
)
|
|
|
|
# An event attribute assembled inside a JavaScript string is absent from the
|
|
# template DOM, so the expression above cannot see it. Once assigned through
|
|
# innerHTML it is still an inline handler and the CSP still refuses to run it.
|
|
DYNAMIC_INLINE_HANDLER = re.compile(
|
|
r'''["']on(?: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:
|
|
content = handle.read()
|
|
return len(INLINE_HANDLER.findall(content)) + len(DYNAMIC_INLINE_HANDLER.findall(content))
|
|
|
|
|
|
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:
|
|
def test_a_handler_built_inside_a_javascript_string_is_counted(self, tmp_path):
|
|
template = tmp_path / 'dynamic-handler.html'
|
|
template.write_text("html += '<button onclick=\"work()\">';", encoding='utf-8')
|
|
|
|
assert _count_handlers(template) == 1
|
|
|
|
def test_dynamic_player_names_are_escaped_before_html_insertion(self):
|
|
template = os.path.join(TEMPLATE_ROOT, 'pages', 'match_form.html')
|
|
with open(template, encoding='utf-8') as handle:
|
|
content = handle.read()
|
|
|
|
assert 'html += playerName;' not in content
|
|
assert "' + playerName + '" not in content
|
|
assert content.count('escapeHtml(playerName)') == 6
|
|
|
|
def test_api_messages_are_written_as_text(self):
|
|
template = os.path.join(TEMPLATE_ROOT, 'pages', 'coach_availability.html')
|
|
with open(template, encoding='utf-8') as handle:
|
|
content = handle.read()
|
|
|
|
assert 'text.textContent = message' in content
|
|
assert "alert.innerHTML = '<span>' + message" not in content
|
|
|
|
@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.'
|
|
)
|