SEC-WEB-004. Limiter() ne nommait aucun stockage, donc Flask-Limiter retombait sur memory://. Le choix n'avait jamais ete fait : c'etait simplement ce qui arrivait. Pour un seul processus Waitress, memory:// est la bonne reponse -- ce qui est precisement pourquoi il fallait l'ecrire. Un deuxieme worker laisserait passer deux fois chaque limite, en silence, avec une configuration qui a l'air inchangee. RATELIMIT_STORAGE_URI rend la valeur lisible dans .env, modifiable en une ligne le jour ou le deploiement gagne un processus, et le demarrage journalise laquelle est active. La part qui reste bloquee est nommee dans le code : un stockage partage ne rend pas les limites solides tant qu'elles sont indexees sur une adresse IP falsifiable, c'est-a-dire tant qu'OPS-002 / SEC-WEB-002 n'est pas tranche avec le developpeur. C'est pour cela que celui-la est le prerequis et pas celui-ci. Co-Authored-By: Claude Opus 5 <[email protected]>
116 lines
4.6 KiB
Python
116 lines
4.6 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) == ''
|
|
|
|
|
|
class TestRateLimitStorage:
|
|
"""SEC-WEB-004 — the counters were in process memory by accident.
|
|
|
|
Flask-Limiter falls back to `memory://` when nothing names a storage, so
|
|
the choice was never made: it was simply what happened. For one Waitress
|
|
process that answer is correct, which is exactly why it needed writing
|
|
down — a second worker would double every limit, silently, and the
|
|
configuration would look untouched.
|
|
"""
|
|
|
|
def test_the_storage_is_named_rather_than_defaulted(self, app):
|
|
assert app.config['RATELIMIT_STORAGE_URI'] == 'memory://'
|
|
|
|
def test_the_environment_can_move_it(self, monkeypatch, tmp_path):
|
|
"""The point of naming it: the day the deployment gains a second
|
|
process, this is a one-line change and not a code change."""
|
|
from app.app import create_app
|
|
|
|
monkeypatch.setenv('RATELIMIT_STORAGE_URI', 'redis://cache.example.test:6379')
|
|
|
|
application = create_app(
|
|
{
|
|
'SECRET_KEY': 'test-secret-not-used-anywhere-real',
|
|
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{tmp_path / "t.sqlite"}',
|
|
'TESTING': True,
|
|
'WTF_CSRF_ENABLED': False,
|
|
'FORCE_HTTPS': False,
|
|
'SESSION_COOKIE_SECURE': False,
|
|
'ENABLE_DISCORD_BOT': False,
|
|
'AUTO_CREATE_TABLES': False,
|
|
'CORS_ALLOWED_ORIGINS': '',
|
|
'RATELIMIT_ENABLED': False,
|
|
}
|
|
)
|
|
|
|
assert application.config['RATELIMIT_STORAGE_URI'] == 'redis://cache.example.test:6379'
|