Header injection / Input validation in email handling (SMTP address parsing)

HIGH
django/django
Commit: 9ab199168900
Affected: 5.1.x prior to this commit (stable/5.1.x)
2026-04-05 13:33 UTC

Description

Genuine security fix. The commit modernizes Django's email handling to Python's email API and, crucially, adds strict address validation in the SMTP path to prevent header injection / CRLF-based header manipulation. The SMTP backend now calls prep_address to validate and canonicalize addresses, enforcing a single mailbox and performing (optionally) IDNA encoding for domains. It raises ValueError on invalid addresses, preventing injection attempts that rely on crafted addresses to inject extra headers (e.g., CRLF sequences leading to injected headers). This, together with switching to email.policy.SMTP for message construction, closes a class of header-injection vectors in django.core.mail. The changes also deprecate legacy API usage related to headers and address sanitization, further reducing attack surface.

Proof of Concept

PoC (exploit path prior to patch): Header-injection via crafted recipient address using CRLF to inject an extra header in the SMTP DATA stream. Python environment: Django 5.1.x before this commit. 1) Attacker-controlled input for recipient address (e.g., from a form) is crafted to include a CRLF and a header: malicious_to = "victim@example.com\r\nX-Injection: yes" 2) Code path (pre-fix behavior as described by triage) would format the message and include the injected header in the SMTP DATA payload, enabling header manipulation such as adding X-Injection: yes. 3) In a vulnerable setup, sending such a message could yield an email with an extra header injected by the attacker, potentially leaking information or altering recipient handling. Post-fix behavior (this commit): The prep_address validation parses the address, detects CRLF-related defects, enforces a single mailbox, and raises ValueError for invalid addresses, preventing the injection. The PoC would fail with a ValueError instead of injecting headers. Code sketch (conceptual): from django.core.mail import EmailMessage, get_connection malicious_to = "victim@example.com\r\nX-Injection: yes" # attacker-controlled msg = EmailMessage( subject="Test", body="This is a test", from_email="attacker@example.org", to=[malicious_to], ) conn = get_connection() conn.send_messages([msg]) # In vulnerable code: header injection; In patched code: ValueError from prep_address Notes: - In a properly secured environment, the patched code will raise ValueError when encountering the injected CRLF in the recipient address, preventing the injection. - This PoC is for illustration of how an attacker might attempt header injection via email addresses prior to the fix; the actual exploit would fail on Django versions with this fix in place.

Commit Details

Author: Mike Edmunds

Date: 2024-12-23 23:01 UTC

Message:

Fixed #35581 -- Updated django.core.mail to Python's modern email API. - Changed EmailMessage.message() to construct a "modern email API" email.message.EmailMessage and added policy keyword arg. - Added support for modern MIMEPart objects in EmailMessage.attach() (and EmailMessage constructor, EmailMessage.attachments list). - Updated SMTP EmailBackend to use modern email.policy.SMTP. Deprecated: - Attaching MIMEBase objects (replace with MIMEPart) - BadHeaderError (modern email uses ValueError) - SafeMIMEText, SafeMIMEMultipart (unnecessary for modern email) - django.core.mail.forbid_multi_line_headers() (undocumented, but exposed via `__all__` and in wide use) - django.core.mail.message.sanitize_address() (undocumented, but in wide use) Removed without deprecation (all undocumented): - EmailMessage.mixed_subtype - EmailMultiAlternatives.alternative_subtype - Support for setting (undocumented) EmailMessage.encoding property to a legacy email.charset.Charset object Related changes: - Dropped tests for incorrect RFC 2047 encoding of non-ASCII email address localparts. This is specifically prohibited by RFC 2047, and not supported by any known MTA or email client. (Python still mis-applies encoded-word to non-ASCII localparts, but it is a bug that may be fixed in the future.) - Added tests that try to discourage using Python's legacy email APIs in future updates to django.core.mail.

Triage Assessment

Vulnerability Type: Header injection / Input validation in email handling

Confidence: HIGH

Reasoning:

The commit introduces explicit validation for recipient addresses in the SMTP backend (prep_address) and switches to the modern Python email API with policy handling. It enforces single mailbox addresses, applies (or plans to apply) IDNA encoding, and raises ValueError for invalid addresses to prevent CR/NL header injection and malformed headers. This directly addresses input validation and header injection risks in email sending. Minor deprecations are part of modernization, but the core change affecting address handling and use of a safer API constitutes a security fix.

Verification Assessment

Vulnerability Type: Header injection / Input validation in email handling (SMTP address parsing)

Confidence: HIGH

Affected Versions: 5.1.x prior to this commit (stable/5.1.x)

Code Diff

diff --git a/django/core/mail/__init__.py b/django/core/mail/__init__.py index 4f49d8575630..4d7f8bf1d929 100644 --- a/django/core/mail/__init__.py +++ b/django/core/mail/__init__.py @@ -13,13 +13,10 @@ # backends and the subsequent reorganization (See #10355) from django.core.mail.message import ( DEFAULT_ATTACHMENT_MIME_TYPE, - BadHeaderError, EmailAlternative, EmailAttachment, EmailMessage, EmailMultiAlternatives, - SafeMIMEMultipart, - SafeMIMEText, forbid_multi_line_headers, make_msgid, ) @@ -33,12 +30,8 @@ "DNS_NAME", "EmailMessage", "EmailMultiAlternatives", - "SafeMIMEText", - "SafeMIMEMultipart", "DEFAULT_ATTACHMENT_MIME_TYPE", "make_msgid", - "BadHeaderError", - "forbid_multi_line_headers", "get_connection", "send_mail", "send_mass_mail", @@ -46,6 +39,12 @@ "mail_managers", "EmailAlternative", "EmailAttachment", + # RemovedInDjango70Warning: When the deprecation ends, remove the last + # entries. + "BadHeaderError", + "SafeMIMEText", + "SafeMIMEMultipart", + "forbid_multi_line_headers", ] @@ -224,3 +223,31 @@ def mail_managers( fail_silently=fail_silently, connection=connection, ) + + +# RemovedInDjango70Warning. +_deprecate_on_import = { + "BadHeaderError": "BadHeaderError is deprecated. Replace with ValueError.", + "SafeMIMEText": ( + "SafeMIMEText is deprecated. The return value" + " of EmailMessage.message() is an email.message.EmailMessage." + ), + "SafeMIMEMultipart": ( + "SafeMIMEMultipart is deprecated. The return value" + " of EmailMessage.message() is an email.message.EmailMessage." + ), +} + + +# RemovedInDjango70Warning. +def __getattr__(name): + try: + msg = _deprecate_on_import[name] + except KeyError: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None + else: + # Issue deprecation warnings at time of import. + from django.core.mail import message + + warnings.warn(msg, category=RemovedInDjango70Warning) + return getattr(message, name) diff --git a/django/core/mail/backends/smtp.py b/django/core/mail/backends/smtp.py index 6820148ac122..4b79d9f3e1e0 100644 --- a/django/core/mail/backends/smtp.py +++ b/django/core/mail/backends/smtp.py @@ -1,13 +1,15 @@ """SMTP email backend class.""" +import email.policy import smtplib import ssl import threading +from email.headerregistry import Address, AddressHeader from django.conf import settings from django.core.mail.backends.base import BaseEmailBackend -from django.core.mail.message import sanitize_address from django.core.mail.utils import DNS_NAME +from django.utils.encoding import force_str, punycode from django.utils.functional import cached_property @@ -145,18 +147,47 @@ def _send(self, email_message): """A helper method that does the actual sending.""" if not email_message.recipients(): return False - encoding = email_message.encoding or settings.DEFAULT_CHARSET - from_email = sanitize_address(email_message.from_email, encoding) - recipients = [ - sanitize_address(addr, encoding) for addr in email_message.recipients() - ] - message = email_message.message() + from_email = self.prep_address(email_message.from_email) + recipients = [self.prep_address(addr) for addr in email_message.recipients()] + message = email_message.message(policy=email.policy.SMTP) try: - self.connection.sendmail( - from_email, recipients, message.as_bytes(linesep="\r\n") - ) + self.connection.sendmail(from_email, recipients, message.as_bytes()) except smtplib.SMTPException: if not self.fail_silently: raise return False return True + + def prep_address(self, address, force_ascii=True): + """ + Return the addr-spec portion of an email address. Raises ValueError for + invalid addresses, including CR/NL injection. + + If force_ascii is True, apply IDNA encoding to non-ASCII domains, and + raise ValueError for non-ASCII local-parts (which can't be encoded). + Otherwise, leave Unicode characters unencoded (e.g., for sending with + SMTPUTF8). + """ + address = force_str(address) + parsed = AddressHeader.value_parser(address) + defects = set(str(defect) for defect in parsed.all_defects) + # Django allows local mailboxes like "From: webmaster" (#15042). + defects.discard("addr-spec local part with no domain") + if not force_ascii: + # Non-ASCII local-part is valid with SMTPUTF8. Remove once + # https://github.com/python/cpython/issues/81074 is fixed. + defects.discard("local-part contains non-ASCII characters)") + if defects: + raise ValueError(f"Invalid address {address!r}: {'; '.join(defects)}") + + mailboxes = parsed.all_mailboxes + if len(mailboxes) != 1: + raise ValueError(f"Invalid address {address!r}: must be a single address") + + mailbox = mailboxes[0] + if force_ascii and mailbox.domain and not mailbox.domain.isascii(): + # Re-compose an addr-spec with the IDNA encoded domain. + domain = punycode(mailbox.domain) + return str(Address(username=mailbox.local_part, domain=domain)) + else: + return mailbox.addr_spec diff --git a/django/core/mail/message.py b/django/core/mail/message.py index 3fd2ef66563c..66d5efeb6338 100644 --- a/django/core/mail/message.py +++ b/django/core/mail/message.py @@ -1,17 +1,19 @@ +import email.message +import email.policy import mimetypes +import warnings from collections import namedtuple +from datetime import datetime, timezone from email import charset as Charset -from email import encoders as Encoders -from email import generator, message_from_bytes +from email import generator from email.errors import HeaderParseError from email.header import Header -from email.headerregistry import Address, parser -from email.message import Message +from email.headerregistry import Address, AddressHeader, parser from email.mime.base import MIMEBase from email.mime.message import MIMEMessage from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from email.utils import formataddr, formatdate, getaddresses, make_msgid +from email.utils import formataddr, getaddresses, make_msgid from io import BytesIO, StringIO from pathlib import Path @@ -19,7 +21,9 @@ from django.core.mail.utils import DNS_NAME from django.utils.deprecation import RemovedInDjango70Warning, deprecate_posargs from django.utils.encoding import force_bytes, force_str, punycode +from django.utils.timezone import get_current_timezone +# RemovedInDjango70Warning. # Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from # some spam filters. utf8_charset = Charset.Charset("utf-8") @@ -31,13 +35,17 @@ # and cannot be guessed). DEFAULT_ATTACHMENT_MIME_TYPE = "application/octet-stream" +# RemovedInDjango70Warning. RFC5322_EMAIL_LINE_LENGTH_LIMIT = 998 -class BadHeaderError(ValueError): - pass - +# RemovedInDjango70Warning. +# BadHeaderError must be ValueError (not subclass it), so that existing code +# with `except BadHeaderError` will catch the ValueError that Python's modern +# email API raises for headers containing CR or NL. +BadHeaderError = ValueError +# RemovedInDjango70Warning. # Header names that contain structured address data (RFC 5322). ADDRESS_HEADERS = { "from", @@ -54,8 +62,16 @@ class BadHeaderError(ValueError): } +# RemovedInDjango70Warning. def forbid_multi_line_headers(name, val, encoding): """Forbid multi-line headers to prevent header injection.""" + warnings.warn( + "The internal API forbid_multi_line_headers() is deprecated." + " Python's modern email API (with email.message.EmailMessage or" + " email.policy.default) will reject multi-line headers.", + RemovedInDjango70Warning, + ) + encoding = encoding or settings.DEFAULT_CHARSET val = str(val) # val may be lazy if "\n" in val or "\r" in val: @@ -77,10 +93,20 @@ def forbid_multi_line_headers(name, val, encoding): return name, val +# RemovedInDjango70Warning. def sanitize_address(addr, encoding): """ Format a pair of (name, address) or an email address string. """ + warnings.warn( + "The internal API sanitize_address() is deprecated." + " Python's modern email API (with email.message.EmailMessage or" + " email.policy.default) will handle most required validation and" + " encoding. Use Python's email.headerregistry.Address to construct" + " formatted addresses from component parts.", + RemovedInDjango70Warning, + ) + address = None if not isinstance(addr, tuple): addr = force_str(addr) @@ -123,6 +149,7 @@ def sanitize_address(addr, encoding): return formataddr((nm, parsed_address.addr_spec)) +# RemovedInDjango70Warning. class MIMEMixin: def as_string(self, unixfrom=False, linesep="\n"): """Return the entire formatted message as a string. @@ -151,6 +178,7 @@ def as_bytes(self, unixfrom=False, linesep="\n"): return fp.getvalue() +# RemovedInDjango70Warning. class SafeMIMEMessage(MIMEMixin, MIMEMessage): def __setitem__(self, name, val): # Per RFC 2046 Section 5.2.1, message/rfc822 attachment headers must be @@ -159,6 +187,7 @@ def __setitem__(self, name, val): MIMEMessage.__setitem__(self, name, val) +# RemovedInDjango70Warning. class SafeMIMEText(MIMEMixin, MIMEText): def __init__(self, _text, _subtype="plain", _charset=None): self.encoding = _charset @@ -181,6 +210,7 @@ def set_payload(self, payload, charset=None): MIMEText.set_payload(self, payload, charset=charset) +# RemovedInDjango70Warning. class SafeMIMEMultipart(MIMEMixin, MIMEMultipart): def __init__( self, _subtype="mixed", boundary=None, _subparts=None, encoding=None, **_params @@ -201,8 +231,10 @@ class EmailMessage: """A container for email information.""" content_subtype = "plain" - mixed_subtype = "mixed" - encoding = None # None => use settings default + + # Undocumented charset to use for text/* message bodies and attachments. + # If None, defaults to settings.DEFAULT_CHARSET. + encoding = None @deprecate_posargs( RemovedInDjango70Warning, @@ -263,7 +295,10 @@ def __init__( self.attachments = [] if attachments: for attachment in attachments: - if isinstance(attachment, MIMEBase): + if isinstance(attachment, email.message.MIMEPart): + self.attach(attachment) + elif isinstance(attachment, MIMEBase): + # RemovedInDjango70Warning. self.attach(attachment) else: self.attach(*attachment) @@ -277,12 +312,13 @@ def get_connection(self, fail_silently=False): self.connection = get_connection(fail_silently=fail_silently) return self.connection - def message(self): - encoding = self.encoding or settings.DEFAULT_CHARSET - msg = SafeMIMEText(self.body, self.content_subtype, encoding) - msg = self._create_message(msg) - msg["Subject"] = self.subject - msg["From"] = self.extra_headers.get("From", self.from_email) + def message(self, *, policy=email.policy.default): + msg = email.message.EmailMessage(policy=policy) + self._add_bodies(msg) + self._add_attachments(msg) + + msg["Subject"] = str(self.subject) + msg["From"] = str(self.extra_headers.get("From", self.from_email)) self._set_list_header_if_not_empty(msg, "To", self.to) self._set_list_header_if_not_empty(msg, "Cc", self.cc) self._set_list_header_if_not_empty(msg, "Reply-To", self.reply_to) @@ -291,18 +327,19 @@ def message(self): # accommodate that when doing comparisons. header_names = [key.lower() for key in self.extra_headers] if "date" not in header_names: - # formatdate() uses stdlib methods to format the date, which use - # the stdlib/OS concept of a timezone, however, Django sets the - # TZ environment variable based on the TIME_ZONE setting which - # will get picked up by formatdate(). - msg["Date"] = formatdate(localtime=settings.EMAIL_USE_LOCALTIME) + if settings.EMAIL_USE_LOCALTIME: + tz = get_current_timezone() + else: + tz = timezone.utc + msg["Date"] = datetime.now(tz) if "message-id" not in header_names: # Use cached DNS_NAME for performance msg["Message-ID"] = make_msgid(domain=DNS_NAME) for name, value in self.extra_headers.items(): # Avoid headers handled above. if name.lower() not in {"from", "to", "cc", "reply-to"}: - msg[name] = value + msg[name] = force_str(value, strings_only=True) + self._idna_encode_address_header_domains(msg) return msg def recipients(self): @@ -332,7 +369,19 @@ def attach(self, filename=None, content=None, mimetype=None): specified as content, decode it as UTF-8. If that fails, set the mimetype to DEFAULT_ATTACHMENT_MIME_TYPE and don't decode the content. """ - if isinstance(filename, MIMEBase): + if isinstance(filename, email.message.MIMEPart): + if content is not None or mimetype is not None: + raise ValueError( + "content and mimetype must not be given when a MIMEPart " + "instance is provided." + ) + self.attachments.append(filename) + elif isinstance(filename, MIMEBase): + warnings.warn( + "MIMEBase attachments are deprecated." + " Use an email.message.MIMEPart instead.", + RemovedInDjango70Warning, + ) if content is not None or mimetype is not None: raise ValueError( "content and mimetype must not be given when a MIMEBase " @@ -376,77 +425,75 @@ def attach_file(self, path, mimetype=None): content = file.read() self.attach(path.name, content, mimetype) - def _create_message(self, msg): - return self._create_attachments(msg) + def _add_bodies(self, msg): + if self.body or not self.attachments: + encoding = self.encoding or settings.DEFAULT_CHARSET + body = force ... [truncated]
← Back to Alerts View on GitHub →