From 4b013afa10025a63f58e0b3fa35a8b5a85e1b949 Mon Sep 17 00:00:00 2001 From: nandgator <38415384+nandgator@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:27:39 +0530 Subject: [PATCH] Match the email local part without Unicode case-insensitivity The local-part pattern lists the characters it allows explicitly: ASCII letters and digits, the RFC 5322 specials and U+00A0 to U+024F. Compiled with re.IGNORECASE but not re.ASCII, a character also matches when re treats one of its other-case forms as equal to a listed character. That admitted 31 characters outside those ranges, among them the Kelvin sign (U+212A, lowercase "k"), the Angstrom sign (U+212B, lowercase U+00E5), Greek mu, U+1E9E and IPA letters. The Kelvin and Angstrom signs render as "K" and "A with ring above", so a lookalike of "Kelvin@example.com" validated. With re.ASCII, case-insensitive matching applies to ASCII letters only. Every character inside the listed ranges still matches itself, so nothing inside them is lost; comparing both patterns over all code points drops exactly those 31 characters and adds none. --- src/validators/email.py | 4 +++- tests/test_email.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/validators/email.py b/src/validators/email.py index 46cc5946..3e80dbbb 100644 --- a/src/validators/email.py +++ b/src/validators/email.py @@ -95,7 +95,9 @@ def email( + r"!#-\[\]-\177]|\\[\011.]" + r')*")$', username_part, - re.IGNORECASE, + # re.ASCII: Unicode case-insensitive matching would also admit characters + # outside these ranges, such as the Kelvin sign as a "k" + re.IGNORECASE | re.ASCII, ) else False ) diff --git a/tests/test_email.py b/tests/test_email.py index 9b82eb46..6fa6d6f1 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -72,3 +72,29 @@ def test_rejects_newline_after_local_part(local_part: str): def test_returns_failed_validation_on_idna_mapped_lookalike_domain(domain_part: str): """Test returns failed validation on email domains that only IDNA 2003 mapping makes valid.""" assert isinstance(email(f"user@{domain_part}"), ValidationError) + + +@pytest.mark.parametrize( + "local_part", + [ + "\u212aelvin", # Kelvin sign, lowercase is k + "\u212belvin", # Angstrom sign, lowercase is U+00E5 + "\u1e9eelvin", # capital sharp s, lowercase is U+00DF + "\u039celvin", # Greek capital mu, re equates it with U+00B5 + "\u2c65elvin", # a with stroke, uppercase is U+023A + ], +) +@pytest.mark.parametrize("quoted", [False, True]) +def test_returns_failed_validation_on_case_folded_local_part(local_part: str, quoted: bool): + """Test returns failed validation on characters allowed only through Unicode case folding.""" + if quoted: + local_part = f'"{local_part}"' + assert isinstance(email(f"{local_part}@example.com"), ValidationError) + + +@pytest.mark.parametrize( + "local_part", ["KELVIN", "Kelvin", "\u0141\u00d3KA\u015a\u0106", "\u00ff\u0178", '"Kelvin"'] +) +def test_returns_true_on_mixed_case_local_part(local_part: str): + """Test returns true on upper and lower case letters inside the allowed ranges.""" + assert email(f"{local_part}@example.com")