74 lines
2.5 KiB
Python
74 lines
2.5 KiB
Python
"""
|
|
tests/test_security_hardening.py
|
|
--------------------------------
|
|
Covers the security quick-wins:
|
|
|
|
* strong_password() validator — length, letter+digit, common-password blocklist
|
|
* response security headers — hardened CSP directives + HSTS over HTTPS only
|
|
"""
|
|
|
|
import pytest
|
|
from wtforms.validators import ValidationError
|
|
|
|
|
|
# ── Password strength validator ─────────────────────────────────────────────
|
|
|
|
class _Field:
|
|
def __init__(self, data):
|
|
self.data = data
|
|
|
|
|
|
def _accepts(pw, **kw):
|
|
from app.utils.forms import strong_password
|
|
try:
|
|
strong_password(**kw)(None, _Field(pw))
|
|
return True
|
|
except ValidationError:
|
|
return False
|
|
|
|
|
|
def test_strong_password_accepts_reasonable():
|
|
assert _accepts('abcd1234') # 8 chars, letter + digit
|
|
assert _accepts('Tr0ubador!!') # longer, mixed
|
|
assert _accepts('') # empty is skipped (Optional handles required-ness)
|
|
|
|
|
|
def test_strong_password_rejects_weak():
|
|
assert not _accepts('short1') # too short (< 8)
|
|
assert not _accepts('allletters') # no digit
|
|
assert not _accepts('12345678') # no letter
|
|
assert not _accepts('password1') # common-password blocklist
|
|
assert not _accepts('welcome1') # common-password blocklist
|
|
|
|
|
|
def test_strong_password_custom_min_length():
|
|
assert not _accepts('abcd123', min_length=8) # 7 chars
|
|
assert _accepts('abcd1234', min_length=8)
|
|
|
|
|
|
# ── Response security headers ────────────────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
return app.test_client()
|
|
|
|
|
|
def test_hardened_csp_present(client):
|
|
resp = client.get('/auth/login')
|
|
csp = resp.headers.get('Content-Security-Policy', '')
|
|
assert "object-src 'none'" in csp
|
|
assert "base-uri 'self'" in csp
|
|
assert "form-action 'self'" in csp
|
|
assert "frame-ancestors 'none'" in csp
|
|
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
|
|
|
|
|
def test_hsts_only_over_https(client):
|
|
# Plain HTTP request — no HSTS advertised.
|
|
http = client.get('/auth/login')
|
|
assert 'Strict-Transport-Security' not in http.headers
|
|
|
|
# Behind a TLS-terminating proxy (X-Forwarded-Proto=https) — HSTS present.
|
|
https = client.get('/auth/login', headers={'X-Forwarded-Proto': 'https'})
|
|
assert https.headers.get('Strict-Transport-Security', '').startswith('max-age=')
|