diff --git a/src/api/auth.py b/src/api/auth.py index d570bcb..14ca778 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -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') diff --git a/src/services/email.py b/src/services/email.py index ef521cb..621e04f 100644 --- a/src/services/email.py +++ b/src/services/email.py @@ -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"""

Vérifiez votre adresse courriel

-

Bonjour {display_name},

+

Bonjour {display_name_html},

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: """ - 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"""

Réinitialiser votre mot de passe

-

Bonjour {display_name},

+

Bonjour {display_name_html},

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: """ - 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 : diff --git a/templates/auth/check_email.html b/templates/auth/check_email.html index 841fcc4..d1e3ae0 100644 --- a/templates/auth/check_email.html +++ b/templates/auth/check_email.html @@ -16,11 +16,11 @@

{% if action == 'password_reset' %} - {{ "Si un compte DictIA existe pour " ~ email ~ ", vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1 heure." | safe }} + Si un compte DictIA existe pour {{ email }}, vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1 heure. {% elif action == 'verification_required' %} - {{ "Vérifiez votre boîte de réception à " ~ email ~ ". Si vous ne recevez rien, demandez un nouveau courriel ci-dessous." | safe }} + Vérifiez votre boîte de réception à {{ email }}. Si vous ne recevez rien, demandez un nouveau courriel ci-dessous. {% else %} - {{ "Nous avons envoyé un lien de vérification à " ~ email ~ ". Cliquez dessus pour activer votre compte. Le lien expire dans 24 heures." | safe }} + Nous avons envoyé un lien de vérification à {{ email }}. Cliquez dessus pour activer votre compte. Le lien expire dans 24 heures. {% endif %}

diff --git a/templates/auth/forgot_password.html b/templates/auth/forgot_password.html index 520ebb0..b9fa875 100644 --- a/templates/auth/forgot_password.html +++ b/templates/auth/forgot_password.html @@ -17,7 +17,7 @@ {% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200 {% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200 {% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}"> - {{ message | safe }} + {{ message }} {% endfor %} {% endif %} diff --git a/templates/auth/reset_password.html b/templates/auth/reset_password.html index 2f1c3d5..6666752 100644 --- a/templates/auth/reset_password.html +++ b/templates/auth/reset_password.html @@ -17,7 +17,7 @@ {% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200 {% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200 {% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}"> - {{ message | safe }} + {{ message }} {% endfor %} {% endif %} diff --git a/tests/test_email_service_dictia.py b/tests/test_email_service_dictia.py index 1764f53..f4e6135 100644 --- a/tests/test_email_service_dictia.py +++ b/tests/test_email_service_dictia.py @@ -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='' + 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 = '' + 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 = '' + 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