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]>
78 lines
3.0 KiB
Python
78 lines
3.0 KiB
Python
"""HTTP hardening, error disclosure, and template escaping."""
|
|
|
|
from app.app import nl2br
|
|
|
|
|
|
class TestSecurityHeaders:
|
|
def test_core_headers_are_present(self, client):
|
|
headers = client.get('/auth/login').headers
|
|
|
|
assert headers['X-Content-Type-Options'] == 'nosniff'
|
|
assert headers['X-Frame-Options'] == 'DENY'
|
|
assert headers['Referrer-Policy'] == 'strict-origin-when-cross-origin'
|
|
assert 'frame-ancestors' in headers['Content-Security-Policy']
|
|
|
|
def test_deprecated_xss_auditor_header_is_not_sent(self, client):
|
|
"""X-XSS-Protection was removed: deprecated, and harmful in its
|
|
last implementations."""
|
|
assert 'X-XSS-Protection' not in client.get('/auth/login').headers
|
|
|
|
def test_csp_does_not_allow_inline_script(self, client):
|
|
"""SEC-WEB-001, closed: inline scripts are authorised by a
|
|
per-request nonce instead."""
|
|
csp = client.get('/auth/login').headers['Content-Security-Policy']
|
|
script_src = [d for d in csp.split(';') if d.strip().startswith('script-src')][0]
|
|
assert "'unsafe-inline'" not in script_src
|
|
assert "'nonce-" in script_src
|
|
|
|
|
|
class TestErrorDisclosure:
|
|
def test_health_reports_status_without_the_driver_message(self, app, client):
|
|
"""/health is unauthenticated. Driver exceptions routinely carry the
|
|
host, database name and user of the connection string."""
|
|
from app.extensions import db
|
|
|
|
def boom(*args, **kwargs):
|
|
raise RuntimeError(
|
|
'FATAL: password authentication failed for user "app" host=db.internal port=5432'
|
|
)
|
|
|
|
original = db.session.execute
|
|
db.session.execute = boom
|
|
try:
|
|
response = client.get('/health')
|
|
finally:
|
|
db.session.execute = original
|
|
|
|
assert response.status_code == 503
|
|
body = response.get_data(as_text=True)
|
|
assert response.get_json()['database'] == 'error'
|
|
for leaked in ('password', 'db.internal', '5432', 'FATAL'):
|
|
assert leaked not in body, f'{leaked!r} leaked through /health'
|
|
|
|
def test_health_reports_healthy_when_the_database_answers(self, client):
|
|
response = client.get('/health')
|
|
assert response.status_code == 200
|
|
assert response.get_json()['database'] == 'connected'
|
|
|
|
|
|
class TestTemplateEscaping:
|
|
def test_nl2br_escapes_markup(self, app):
|
|
"""Markup('<br>'.join(...)) marked attacker text as safe.
|
|
Markup('<br>').join(...) escapes each segment first."""
|
|
with app.app_context():
|
|
rendered = str(nl2br('<script>alert(1)</script>\nsecond'))
|
|
|
|
assert '<script>' not in rendered
|
|
assert '<script>' in rendered
|
|
assert '<br>' in rendered, 'the line break itself must stay markup'
|
|
|
|
def test_nl2br_still_joins_lines(self, app):
|
|
with app.app_context():
|
|
assert str(nl2br('a\nb')) == 'a<br>b'
|
|
|
|
def test_nl2br_handles_empty_input(self, app):
|
|
with app.app_context():
|
|
assert nl2br('') == ''
|
|
assert nl2br(None) == ''
|