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:
@@ -677,6 +677,15 @@ def reset_password(token):
|
||||
flash('Utilisateur introuvable.', 'danger')
|
||||
return redirect(url_for('auth.forgot_password'))
|
||||
|
||||
# Single-use token enforcement: URLSafeTimedSerializer is stateless, so
|
||||
# without this check a valid token could be replayed within the 1h window.
|
||||
# We invalidate the token on use by clearing user.password_reset_token, so
|
||||
# a mismatch here means the token has already been consumed (or never set).
|
||||
# Applies to BOTH GET (don't render the form) and POST (don't accept reset).
|
||||
if not user.password_reset_token or user.password_reset_token != token:
|
||||
flash('Le lien de réinitialisation est invalide ou expiré.', 'danger')
|
||||
return redirect(url_for('auth.forgot_password'))
|
||||
|
||||
if request.method == 'POST':
|
||||
password = request.form.get('password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
|
||||
@@ -11,6 +11,7 @@ import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime, timedelta
|
||||
from html import escape as html_escape
|
||||
from typing import Optional
|
||||
|
||||
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
|
||||
@@ -133,13 +134,13 @@ def _send_email(to_email: str, subject: str, html_body: str, text_body: str = No
|
||||
msg['From'] = f"{config['from_name']} <{config['from_address']}>"
|
||||
msg['To'] = to_email
|
||||
|
||||
# Add plain text version
|
||||
# Add plain text version (explicit UTF-8 to prevent Q-encoding mojibake)
|
||||
if text_body:
|
||||
part1 = MIMEText(text_body, 'plain')
|
||||
part1 = MIMEText(text_body, 'plain', 'utf-8')
|
||||
msg.attach(part1)
|
||||
|
||||
# Add HTML version
|
||||
part2 = MIMEText(html_body, 'html')
|
||||
# Add HTML version (explicit UTF-8 to prevent Q-encoding mojibake)
|
||||
part2 = MIMEText(html_body, 'html', 'utf-8')
|
||||
msg.attach(part2)
|
||||
|
||||
# Connect to SMTP server
|
||||
@@ -309,15 +310,20 @@ def send_verification_email(user) -> bool:
|
||||
# Build verification URL
|
||||
verify_url = url_for('auth.verify_email', token=token, _external=True)
|
||||
|
||||
# Display name preferred over username; fallback when name is None/empty.
|
||||
display_name = (getattr(user, 'name', None) or user.username).strip()
|
||||
# Display name preferred over username; fallback chain handles None/empty
|
||||
# name AND the schema-improbable case where username is also missing.
|
||||
# HTML body MUST escape user-controlled name to prevent stored XSS;
|
||||
# text body uses raw string (plaintext has no XSS surface).
|
||||
raw_display_name = ((getattr(user, 'name', None) or '').strip() or user.username or 'utilisateur').strip()
|
||||
display_name_html = html_escape(raw_display_name)
|
||||
display_name_text = raw_display_name
|
||||
|
||||
subject = "Vérifiez votre courriel — DictIA"
|
||||
|
||||
content_html = f"""
|
||||
<h2 style="color: #060d1a; margin: 0 0 24px 0; font-size: 24px; font-weight: 700;">Vérifiez votre adresse courriel</h2>
|
||||
|
||||
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name},</p>
|
||||
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name_html},</p>
|
||||
|
||||
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
|
||||
Bienvenue chez DictIA. Pour terminer votre inscription et commencer à transcrire, vérifiez votre adresse courriel.
|
||||
@@ -338,7 +344,7 @@ def send_verification_email(user) -> bool:
|
||||
</div>
|
||||
"""
|
||||
|
||||
content_text = f"""Bonjour {display_name},
|
||||
content_text = f"""Bonjour {display_name_text},
|
||||
|
||||
Bienvenue chez DictIA. Pour terminer votre inscription et commencer à transcrire, vérifiez votre adresse courriel.
|
||||
|
||||
@@ -376,15 +382,20 @@ def send_password_reset_email(user) -> bool:
|
||||
# Build reset URL
|
||||
reset_url = url_for('auth.reset_password', token=token, _external=True)
|
||||
|
||||
# Display name preferred over username; fallback when name is None/empty.
|
||||
display_name = (getattr(user, 'name', None) or user.username).strip()
|
||||
# Display name preferred over username; fallback chain handles None/empty
|
||||
# name AND the schema-improbable case where username is also missing.
|
||||
# HTML body MUST escape user-controlled name to prevent stored XSS;
|
||||
# text body uses raw string (plaintext has no XSS surface).
|
||||
raw_display_name = ((getattr(user, 'name', None) or '').strip() or user.username or 'utilisateur').strip()
|
||||
display_name_html = html_escape(raw_display_name)
|
||||
display_name_text = raw_display_name
|
||||
|
||||
subject = "Réinitialiser votre mot de passe — DictIA"
|
||||
|
||||
content_html = f"""
|
||||
<h2 style="color: #060d1a; margin: 0 0 24px 0; font-size: 24px; font-weight: 700;">Réinitialiser votre mot de passe</h2>
|
||||
|
||||
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name},</p>
|
||||
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name_html},</p>
|
||||
|
||||
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
|
||||
Nous avons reçu une demande de réinitialisation pour votre compte DictIA. Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe.
|
||||
@@ -405,7 +416,7 @@ def send_password_reset_email(user) -> bool:
|
||||
</div>
|
||||
"""
|
||||
|
||||
content_text = f"""Bonjour {display_name},
|
||||
content_text = f"""Bonjour {display_name_text},
|
||||
|
||||
Nous avons reçu une demande de réinitialisation pour votre compte DictIA. Cliquez sur le lien ci-dessous pour créer un nouveau mot de passe :
|
||||
|
||||
|
||||
Reference in New Issue
Block a user