Skip to content

fix: attachment integrity, batch availability, and date overflow on hostile mail - #171

Merged
fedelemantuano merged 3 commits into
developfrom
security-bug
Aug 19, 2026
Merged

fix: attachment integrity, batch availability, and date overflow on hostile mail#171
fedelemantuano merged 3 commits into
developfrom
security-bug

Conversation

@fedelemantuano

@fedelemantuano fedelemantuano commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Three fixes for bugs reachable from fully attacker-controlled mail. All parsed input is hostile by definition here, so each of these is a security issue rather than a robustness nit: two break the integrity of what the tool reports, one breaks availability of the parse.

1. Attachment bytes are no longer altered on the way out

Every non-base64 attachment was read back through ported_string(..., errors="ignore"), which drops each byte the declared charset cannot represent. A 4096-byte binary attachment reached disk as 2048 bytes, so its hash never matched the file the recipient actually received — the single thing a forensics tool must get right.

Attachments are now re-encoded to base64 from their decoded bytes and report base64 as their transfer encoding.

This also closes a parser differential the sender picked by choosing an encoding: a quoted-printable application/* part was reported as a binary payload holding raw QP text, so write_attachments() base64-decoded it and wrote bytes that were neither the attachment nor what was on the wire.

Content-Transfer-Encoding is now stripped before comparison, as email's own get_payload() does. One trailing space made every encoding branch miss and sent the part down the lossy text path; on the body side it re-read the text through raw-unicode-escape, turning a Cyrillic phishing indicator into literal escape sequences that no content scanner matches.

Three charset paths raised outside the MailParser* hierarchy on hostile input, so a caller catching MailParserError still died:

  • ported_string() caught UnicodeDecodeError, but the undefined codec raises a bare UnicodeError;
  • get_payload(decode=False) applies the declared charset, guarded upstream only against an unknown charset name;
  • as_string() re-encodes an 8-bit body with a charset that may refuse it.

raw_payload() and as_string_safe() now fall back to the raw bytes.

2. One hostile attachment no longer costs the whole batch

write_attachments() called _safe_attachment_filename() and write_sample() with no guard, so a single crafted attachment aborted the save and every attachment after it was silently never written:

  • a NUL smuggled in through RFC 2231 (filename*=us-ascii''evil%00.bin) raised ValueError out of the name check;
  • a basename longer than NAME_MAX raised OSError from open(), after creating a zero-byte stub;
  • a payload that is not valid base64 raised binascii.Error (a ValueError subclass) from the decode inside the open() block.

Each is now logged and skipped. The base64 decode moved before open() so a failure leaves no stub, and it repairs padding and alphabet errors the way every mail client does — matching MIME::Base64 on 2008 test cases. Rejecting them let a sender append one character to make an attachment vanish from the extraction directory while it still reached the recipient.

Containment failures must not be swallowed by that broader catch, so they now raise MailParserPathError instead of ValueError.

Long names are truncated to 240 UTF-8 bytes keeping their extension. Deduplication reserves room for its own marker, because write_sample() sanitizes again and the clamp there used to cut the marker back off, collapsing distinct attachments onto one file. It resumes from the last suffix per stem instead of rescanning from 1 — quadratic before (16000 same-named attachments took 14s, now milliseconds) — and folds case, because APFS, exFAT and SMB collapse Invoice.pdf and invoice.pdf.

3. An out-of-range Received date no longer aborts the parse

A Received header whose date carries a 12+ digit year, or a timezone offset large enough to push the timestamp past int64, aborted the parse of the entire message: receiveds_format() guarded convert_mail_date() with except (TypeError, ValueError), but calendar.timegm() raises OverflowError and datetime.fromtimestamp() reports EOVERFLOW as OSError.

Neither belongs to the MailParser* hierarchy, so a caller catching only MailParserError died on the message. The date and timezone properties already wrapped the same conversion in except Exception; only the received path was left open.

Testing

Full suite passes. Each fix ships with regression tests covering the crafted input that triggered it — see the +553 lines in tests/test_mail_parser.py and +44 in tests/test_utils.py.


🤖 Generated with Claude Code

fedelemantuano and others added 3 commits August 18, 2026 10:03
A Received header whose date carries a 12+ digit year, or a timezone
offset large enough to push the timestamp past int64, aborted the parse
of the whole message: receiveds_format() guarded convert_mail_date()
with except (TypeError, ValueError), but calendar.timegm() raises
OverflowError and datetime.fromtimestamp() reports EOVERFLOW as OSError.

Neither belongs to the MailParser* hierarchy, so a caller catching only
MailParserError died on the message. The date and timezone properties
already wrapped the same conversion in except Exception; only the
received path was left open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
write_attachments() called _safe_attachment_filename() and write_sample()
with no guard, so a single hostile attachment aborted the save and every
attachment after it was silently never written:

- a NUL smuggled in through RFC 2231 (filename*=us-ascii''evil%00.bin)
  raised ValueError out of the name check;
- a basename longer than NAME_MAX raised OSError from open(), after
  creating a zero-byte stub;
- a payload that is not valid base64 raised binascii.Error, a ValueError
  subclass, from the decode inside the open() block.

Each is now logged and skipped. The base64 decode moved before open() so
a failure leaves no stub, and it repairs padding and alphabet errors the
way every client does, matching MIME::Base64 on 2008 cases: rejecting
them let a sender append one character to make an attachment vanish from
the extraction directory while it still reached the recipient.

Containment failures must not be swallowed by that broader catch, so
they now raise MailParserPathError instead of ValueError.

Long names are truncated to 240 UTF-8 bytes keeping their extension.
Deduplication reserves room for its own marker, because write_sample()
sanitizes again and the clamp there used to cut the marker back off,
collapsing distinct attachments onto one file. It resumes from the last
suffix per stem rather than rescanning from 1, which was quadratic
(16000 same-named attachments took 14s, now milliseconds), and folds
case, because APFS, exFAT and SMB collapse Invoice.pdf and invoice.pdf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An attachment is a file, not text, but every part that was not base64
was read back through ported_string(..., errors="ignore"), which drops
each byte the declared charset cannot represent. A 4096-byte binary
attachment reached disk as 2048 bytes, so its hash never matched the
file the recipient received. Attachments are now re-encoded to base64
from their decoded bytes and report base64 as their transfer encoding.

That also closes a differential the sender chose by picking an encoding:
a quoted-printable application/* part was reported as a binary payload
holding raw QP text, so write_attachments() base64-decoded it and saved
bytes that were neither the attachment nor what was on the wire.

Content-Transfer-Encoding is now stripped before comparison, as email's
own get_payload() does. One trailing space made every encoding branch
miss, sending the part down the lossy text path; on the body side it
re-read the text through raw-unicode-escape, turning a Cyrillic phishing
indicator into literal escape sequences that no content scanner matches.

Three charset paths raised outside the MailParser* hierarchy on hostile
input: ported_string() caught UnicodeDecodeError but the "undefined"
codec raises a bare UnicodeError; get_payload(decode=False) applies the
declared charset, guarded upstream only against an unknown charset name;
and as_string() re-encodes an 8-bit body with a charset that may refuse
it. raw_payload() and as_string_safe() fall back to the raw bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coveralls

Copy link
Copy Markdown

Coverage Status

Coverage is 100.0%security-bug into develop. No base build found for develop.

@fedelemantuano
fedelemantuano merged commit e1d9fe6 into develop Aug 19, 2026
8 checks passed
@fedelemantuano
fedelemantuano deleted the security-bug branch August 19, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants