This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/sbp by this push:
new 7c8efdd4 Ensure that emails cannot contain null bytes
7c8efdd4 is described below
commit 7c8efdd4b39c069dde08f690fd147a20db06c05a
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Mar 11 20:03:00 2026 +0000
Ensure that emails cannot contain null bytes
---
atr/mail.py | 17 +++++++
tests/unit/test_mail.py | 116 +++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 132 insertions(+), 1 deletion(-)
diff --git a/atr/mail.py b/atr/mail.py
index 492cbc6e..7811f74c 100644
--- a/atr/mail.py
+++ b/atr/mail.py
@@ -53,6 +53,13 @@ class Message:
async def send(msg_data: Message) -> tuple[str, list[str]]:
"""Send an email notification about an artifact or a vote."""
log.info(f"Sending email for event: {msg_data}")
+ _reject_null_bytes(
+ msg_data.email_sender,
+ msg_data.email_recipient,
+ msg_data.subject,
+ msg_data.body,
+ msg_data.in_reply_to,
+ )
from_addr = msg_data.email_sender
if not from_addr.endswith(f"@{global_domain}"):
raise ValueError(f"from_addr must end with @{global_domain}, got
{from_addr}")
@@ -105,6 +112,12 @@ async def send(msg_data: Message) -> tuple[str, list[str]]:
return mid, errors
+def _reject_null_bytes(*values: str | None) -> None:
+ for value in values:
+ if (value is not None) and ("\x00" in value):
+ raise ValueError("Email content cannot contain null bytes")
+
+
async def _send_many(from_addr: str, to_addrs: list[str], msg_text: str) ->
list[str]:
"""Send an email to multiple recipients."""
message_bytes = bytes(msg_text, "utf-8")
@@ -147,6 +160,10 @@ async def _send_via_relay(from_addr: str, to_addr: str,
msg_bytes: bytes) -> Non
def _split_address(addr: str) -> tuple[str, str]:
"""Split an email address into local and domain parts."""
+ if "\x00" in addr:
+ raise ValueError("Email address cannot contain null bytes")
+ if ("\r" in addr) or ("\n" in addr):
+ raise ValueError("Email address cannot contain CR/LF characters")
parts = addr.split("@", 1)
if len(parts) != 2:
raise ValueError("Invalid mail address")
diff --git a/tests/unit/test_mail.py b/tests/unit/test_mail.py
index e8586559..14b2fa76 100644
--- a/tests/unit/test_mail.py
+++ b/tests/unit/test_mail.py
@@ -321,7 +321,7 @@ async def test_send_rejects_crlf_in_to_address(monkeypatch:
"MonkeyPatch") -> No
)
# Call send and expect it to raise ValueError due to invalid recipient
format
- with pytest.raises(ValueError, match=r"Email recipient must be
@apache.org"):
+ with pytest.raises(ValueError, match=r"CR/LF"):
await mail.send(malicious_message)
# Assert that _send_many was never called
@@ -353,6 +353,102 @@ async def
test_send_rejects_lf_only_injection(monkeypatch: "MonkeyPatch") -> Non
mock_send_many.assert_not_called()
[email protected]
+async def test_send_rejects_null_byte_in_body(monkeypatch: "MonkeyPatch") ->
None:
+ """Test that null bytes in body field are rejected."""
+ mock_send_many = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr("atr.mail._send_many", mock_send_many)
+
+ malicious_message = mail.Message(
+ email_sender="[email protected]",
+ email_recipient="[email protected]",
+ subject="Test Subject",
+ body="Normal start\x00injected content",
+ )
+
+ with pytest.raises(ValueError, match=r"null bytes"):
+ await mail.send(malicious_message)
+
+ mock_send_many.assert_not_called()
+
+
[email protected]
+async def test_send_rejects_null_byte_in_from_address(monkeypatch:
"MonkeyPatch") -> None:
+ """Test that null bytes in from address field are rejected."""
+ mock_send_many = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr("atr.mail._send_many", mock_send_many)
+
+ malicious_message = mail.Message(
+ email_sender="sender\[email protected]",
+ email_recipient="[email protected]",
+ subject="Test Subject",
+ body="This is a test message",
+ )
+
+ with pytest.raises(ValueError, match=r"null bytes"):
+ await mail.send(malicious_message)
+
+ mock_send_many.assert_not_called()
+
+
[email protected]
+async def test_send_rejects_null_byte_in_reply_to(monkeypatch: "MonkeyPatch")
-> None:
+ """Test that null bytes in in_reply_to field are rejected."""
+ mock_send_many = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr("atr.mail._send_many", mock_send_many)
+
+ malicious_message = mail.Message(
+ email_sender="[email protected]",
+ email_recipient="[email protected]",
+ subject="Test Subject",
+ body="This is a test message",
+ in_reply_to="valid-id\[email protected]",
+ )
+
+ with pytest.raises(ValueError, match=r"null bytes"):
+ await mail.send(malicious_message)
+
+ mock_send_many.assert_not_called()
+
+
[email protected]
+async def test_send_rejects_null_byte_in_subject(monkeypatch: "MonkeyPatch")
-> None:
+ """Test that null bytes in subject field are rejected."""
+ mock_send_many = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr("atr.mail._send_many", mock_send_many)
+
+ malicious_message = mail.Message(
+ email_sender="[email protected]",
+ email_recipient="[email protected]",
+ subject="Legitimate Subject\x00Bcc: [email protected]",
+ body="This is a test message",
+ )
+
+ with pytest.raises(ValueError, match=r"null bytes"):
+ await mail.send(malicious_message)
+
+ mock_send_many.assert_not_called()
+
+
[email protected]
+async def test_send_rejects_null_byte_in_to_address(monkeypatch:
"MonkeyPatch") -> None:
+ """Test that null bytes in to address field are rejected."""
+ mock_send_many = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr("atr.mail._send_many", mock_send_many)
+
+ malicious_message = mail.Message(
+ email_sender="[email protected]",
+ email_recipient="recipient\[email protected]",
+ subject="Test Subject",
+ body="This is a test message",
+ )
+
+ with pytest.raises(ValueError, match=r"null bytes"):
+ await mail.send(malicious_message)
+
+ mock_send_many.assert_not_called()
+
+
def test_smtp_policy_vs_smtputf8() -> None:
"""Test that SMTPUTF8 policy is required for proper Unicode handling.
@@ -383,3 +479,21 @@ def test_smtp_policy_vs_smtputf8() -> None:
# SMTPUTF8 preserves the character directly
assert "Test avec é" in smtputf8_str
assert "=?utf-8?" not in smtputf8_str
+
+
+def test_split_address_rejects_cr() -> None:
+ """Test that _split_address rejects addresses containing CR."""
+ with pytest.raises(ValueError, match=r"CR/LF"):
+ mail._split_address("user\[email protected]")
+
+
+def test_split_address_rejects_lf() -> None:
+ """Test that _split_address rejects addresses containing LF."""
+ with pytest.raises(ValueError, match=r"CR/LF"):
+ mail._split_address("user\[email protected]")
+
+
+def test_split_address_rejects_null_byte() -> None:
+ """Test that _split_address rejects addresses containing null bytes."""
+ with pytest.raises(ValueError, match=r"null bytes"):
+ mail._split_address("user\[email protected]")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]