fix(auth): B-2.3 security review fixes — XSS escape + token replay
Targeted fixes for issues raised by code review on commit 37639a7
(B-2.3 DictIA email rebrand). All fixes verified against the Windows
manual driver: 16/16 tests pass (12 pre-existing + 4 new regression).
Critical:
- C1 Stored XSS in transactional emails: user.name (validated only on
Length(max=49), no character class) was rendered raw into the f-string
HTML body of verification + reset emails. Added html.escape on the
HTML branch; text body keeps the raw string (no XSS surface). Also
hardened the fallback chain to ((name or '').strip() or username or
'utilisateur').strip() so a None/whitespace name never produces
'Bonjour ,'.
- C2 Reflected XSS in templates/auth/check_email.html: the email value
from request.form was concatenated with literal '<strong>' tags then
fed through | safe, defeating Jinja's autoescape. Split the string so
template-author HTML stays literal and {{ email }} is autoescaped.
Used   for NBSP instead of '1 heure' | safe (more readable).
Important:
- I1 Dropped {{ message | safe }} on flash blocks in
forgot_password.html and reset_password.html (matches check_email.html).
No XSS today (flashes are static literals) but removes the landmine.
- I2 Password reset token replay: URLSafeTimedSerializer is stateless,
so the same valid link could be clicked twice within the 1h window.
Added a check that user.password_reset_token == token after the user
lookup — runs before BOTH GET (form render) and POST (password update).
The existing 'user.password_reset_token = None' on success now
actually invalidates the token.
- I5 MIMEText defaults to us-ascii, which Q-encodes accented French
characters and produces mojibake in some clients. Added explicit
'utf-8' charset on both text and html parts in _send_email.
New regression tests (tests/test_email_service_dictia.py):
- test_verification_email_falls_back_when_name_is_whitespace (I4)
- test_verification_email_handles_unicode_name (I5)
- test_verification_email_escapes_html_in_user_name (C1)
- test_check_email_template_escapes_email_in_response (C2)
Out of scope (per review): M1 (already addressed via solid-color
fallback), M2 (datetime.utcnow — pre-existing, separate cleanup),
M3 (Windows test driver — documented in tests file docstring),
M4-M6 (deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -266,6 +266,112 @@ def test_check_email_template_extends_marketing_base():
|
||||
_clear_smtp_env()
|
||||
|
||||
|
||||
def test_verification_email_falls_back_when_name_is_whitespace():
|
||||
"""Empty/whitespace name must NOT produce 'Bonjour ,' — falls back to username."""
|
||||
with app.test_request_context('/'):
|
||||
_set_smtp_env()
|
||||
db.create_all()
|
||||
try:
|
||||
user = User(username='claire42', email='claire@example.qc.ca',
|
||||
password='x' * 60, name=' ', email_verified=False)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
||||
from src.services.email import send_verification_email
|
||||
send_verification_email(user)
|
||||
args, _ = mock_send.call_args
|
||||
_, _, html_body, text_body = args
|
||||
assert 'Bonjour ,' not in html_body
|
||||
assert 'Bonjour claire42' in html_body
|
||||
assert 'Bonjour claire42' in text_body
|
||||
finally:
|
||||
db.session.rollback()
|
||||
db.drop_all()
|
||||
_clear_smtp_env()
|
||||
|
||||
|
||||
def test_verification_email_handles_unicode_name():
|
||||
"""Accented French names must round-trip through email without mojibake."""
|
||||
with app.test_request_context('/'):
|
||||
_set_smtp_env()
|
||||
db.create_all()
|
||||
try:
|
||||
user = User(username='francois', email='francois@example.qc.ca',
|
||||
password='x' * 60, name='François Mélanie',
|
||||
email_verified=False)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
||||
from src.services.email import send_verification_email
|
||||
send_verification_email(user)
|
||||
args, _ = mock_send.call_args
|
||||
_, _, html_body, text_body = args
|
||||
assert 'Bonjour François Mélanie' in html_body
|
||||
assert 'Bonjour François Mélanie' in text_body
|
||||
finally:
|
||||
db.session.rollback()
|
||||
db.drop_all()
|
||||
_clear_smtp_env()
|
||||
|
||||
|
||||
def test_verification_email_escapes_html_in_user_name():
|
||||
"""user.name with HTML payload must be escaped in HTML body, raw in text body.
|
||||
|
||||
Regression test for C1 (stored XSS). A signup with name='<img onerror=...>'
|
||||
persists the payload — without escape it executes when the verification
|
||||
email renders.
|
||||
"""
|
||||
with app.test_request_context('/'):
|
||||
_set_smtp_env()
|
||||
db.create_all()
|
||||
try:
|
||||
payload = '<img src=x onerror=alert(1)>'
|
||||
user = User(username='attacker', email='attacker@x.ca',
|
||||
password='x' * 60, name=payload, email_verified=False)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
||||
from src.services.email import send_verification_email
|
||||
send_verification_email(user)
|
||||
args, _ = mock_send.call_args
|
||||
_, _, html_body, text_body = args
|
||||
# HTML body MUST escape the payload
|
||||
assert payload not in html_body, \
|
||||
'Raw HTML payload leaked into HTML email body!'
|
||||
assert '<img src=x onerror=alert(1)>' in html_body
|
||||
# Text body keeps the raw string (it's plaintext, no XSS surface)
|
||||
assert payload in text_body
|
||||
finally:
|
||||
db.session.rollback()
|
||||
db.drop_all()
|
||||
_clear_smtp_env()
|
||||
|
||||
|
||||
def test_check_email_template_escapes_email_in_response():
|
||||
"""email value rendered into check_email.html must be HTML-escaped.
|
||||
|
||||
Regression test for C2 (reflected XSS). Posting a script payload to
|
||||
/forgot-password reflected it unescaped via concat-then-safe pattern.
|
||||
"""
|
||||
with app.app_context():
|
||||
app.config['WTF_CSRF_ENABLED'] = False
|
||||
_set_smtp_env()
|
||||
db.create_all()
|
||||
try:
|
||||
client = app.test_client()
|
||||
payload = '<script>alert(1)</script>'
|
||||
resp = client.post('/forgot-password', data={'email': payload})
|
||||
assert resp.status_code == 200
|
||||
body = resp.data.decode('utf-8')
|
||||
assert payload not in body, \
|
||||
'Raw <script> payload leaked into rendered HTML!'
|
||||
assert '<script>alert(1)</script>' in body
|
||||
finally:
|
||||
db.session.rollback()
|
||||
db.drop_all()
|
||||
_clear_smtp_env()
|
||||
|
||||
|
||||
def test_resend_verification_rate_limited_per_user():
|
||||
"""can_resend_verification returns (False, remaining) within the 60s cooldown."""
|
||||
with app.app_context():
|
||||
|
||||
Reference in New Issue
Block a user