Fixed many bugs - #167
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes 21 security findings in the header-parsing and sender-IP-attribution paths, all reachable from
parse_from_string()/parse_from_bytes()on attacker-controlled input.Three were reported by an external audit against 4.6.1; a fourth of the same family surfaced while reproducing them. The remaining 17 were found by four rounds of
security-revieweron the fix itself — including one High regression introduced by an earlier iteration of this branch (F11) and one pre-existing bug that was masking a broken test (F14).Every finding has a reproducing PoC and a regression test. No public API signature changed, but several behaviours did — see Behaviour changes.
Root causes
Two mechanisms account for all 21 findings.
1. Header values were resolved through Python attribute lookup.
MailParserexposes headers dynamically via__getattr__, and_make_mail()/headersiterated the sender's header names callinggetattr(self, name). Python resolves real class attributes before__getattr__, so the sender chose which attribute was read — and the caller-facing conveniences (_→-folding,_json/_rawsuffixes) were applied to names off the wire.Fixed by
MailParser._header_value(), a literal-lookup-only resolver backed by a per-parse header index. It performs no attribute access and no name rewriting.__getattr__keeps the conveniences for names the caller types.2. Sender-IP attribution tried to classify text by what it looks like.
get_server_ipaddress()located thebyclause withreceived_header.find("by"), then successive iterations of this branch tried to tell MTA-written text from sender-written text with substring searches, regex deletion, and unbalanced-delimiter heuristics. Each guard reopened a hole the previous one closed (F8→F11, F12→F16/F18, F9→F19), because a closed[...]pair the sender wrote is byte-identical to one the MTA wrote andEHLO [8.8.8.8]is a form RFC 5321 §4.1.3 requires of a client with no FQDN.Replaced with one positional rule in
_sender_ip_candidates():fromclause is the HELO name, whatever its shape — never a candidate;(helo=x),(account a@b HELO x)) is sender text — excluded;(/[group — which is what makes a clause truncated by a multi-word HELO fail closed, since what it leaves behind is bare;Findings
Reported by the audit
getattrrescanned the whole header list viaMessage.get_all→ O(distinct × total)get_server_ipaddress()sender-IP spoofing viafind("by")matching inside a hostnamederby.attacker.com→ returned forged6.6.6.6instead of1.2.3.4; benignnearby.example.org→Nonemail_jsoncrash: a header named after a method stored a bound method in the mail dictParse: x\r\n\r\n(12 bytes) →TypeError: Object of type method is not JSON serializable; 9 names confirmedFinding 2 is rated higher than the 5.3 the audit proposed. The failure mode is not "returns nothing" — truncation kills extraction on the genuine hop, the loop falls through, and it returns the attacker's forged IP. That is an integrity failure in a value used for attribution and blocklisting, while the docstring promises a trust boundary. The HELO name needs no DNS control.
Found while reproducing
Headers_json:header. The- {"headers"}guard did not cover the_jsonalias, and each cycle rebuilt the full header dict, compounding with finding 1parse(), not lazily.Headers_json_jsonrecurses tooFound by review of the fix
_jsonsuffix applied to a wire name re-serialized the previous result — one output doubling per five input bytesX_rawon an 8-bit header dumped anemail.header.Header, an uncaughtTypeErroroutside theMailParser*hierarchyb"Subject: caf\xe9\r\nSubject_raw: x\r\n\r\n"(39 bytes) kills the workercheck[-1]takes the last IP, and Exim/CommuniGate record the HELO after the genuine IPhelo=[8.8.8.8]andHELO 8.8.8.8both returned the attacker's addressfromsurfaced an IP fromby/for/with/id— andforholds the envelope recipient, chosen at RCPT TOfor <bounce+8.8.8.8@x>→8.8.8.8Subject_json:reportedSubject's value;X_Spam_Flag: YES→""; indicators absent from every output surface withhas_defects == FalseMessage.__len__is the header count, so a header-less message is falsy:parse()returned before_reset()and every property answered""parse_from_string("Click http://evil.example/pay.exe now")→body == "",mail == "", no errorheadersdeduped on exact spelling while values resolve case-insensitively — n casings of one name gave n keys each holding an n-element listbylet a multi-word HELO inject a clause keyword and truncate before the MTA-written IPEHLO evil 8.8.8.8 by z→8.8.8.8_HELO_RE's\S+ate the MTA-written IP when the HELO name was literallyhelofrom helo ([45.33.32.156])→Nonebydragged in theforandenvelope-fromvalues, both sender-chosenMAIL FROM:<"x 8.8.8.8 by q"@evil.example>→8.8.8.8; the private-IP variant reached a fully forged header_HELO_REdeleted text rather than marking it, so an attacker token equal tohelobefore the MTA-written IP removed it8.8.8.8REGXIP.findall(...) or REGXIP6.findall(...)is family-first: one IPv4 literal at EHLO suppressed the IPv6 scan entirelyEHLO 10.0.0.1on an IPv6 hop → private → walk resumed into the attacker's headerREGXIP6matched inside the RFC 5321IPv6:tag[IPv6:2a00:1450:4864:20::32]→6:2a00:1450:4864:20::, a different valid routable addressmail/mail_jsonheadersEHLO [8.8.8.8]passed the unbalanced-delimiter test — a closed pair the sender wrote is indistinguishable from one the MTA wrote[8.8.8.8]→ reported verbatim;[10.0.0.1]→ private → fall-through to forged headerlone_tokenwhitelisted a clause truncated to a single tokenEHLO 8.8.8.8 by z→8.8.8.8(?![\[(])made_HELO_REmiss a genuine bracketed HELO argumentHELO [8.8.8.8]→ sender's literal became the last candidateIPv6:blank used spaces, manufacturing a_HELO_RElookbehind position inside the sender's own tokenEHLO [8.8.8.8]IPv6:helo=→8.8.8.8REGXIP6alternation ordered ascending by trailing-group count;retakes the first match, not the longest2001:470:1f0b:16c0::2:1→…::2, still public, silently attributedmta_writtensliced the whole prefix per match → O(n·m)Behaviour changes
Consumers should read these before upgrading.
get_server_ipaddress()— semantics changed materially, and deliberately.Nonerather than falling through to older, sender-written headers.fromclause is searched, and only addresses inside a(/[group written after the first token.from 139.88.66.159, no HELO marker anywhere) now returnsNone. It is byte-identical toEHLO 139.88.66.159, so it fails closed. Exim/CommuniGatefrom [ip] (helo=x)still resolves, because the marker in the comment identifies the first token as MTA-written.truststring that matches multiple hops (e.g. a recipient domain rather than the border MTA) may now getNonewhere they previously got an address from a lower hop.headersMessage.get_all().MailParsermethod or property (Parse,Message,Headers_json) and names containing underscores (X_Spam_Flag). They are resolved as headers, never as attributes, and never rewritten.mail/mail_json_→-folding, no_json/_rawsuffix interpretation.defects,defects_categoriesorhas_defectsare skipped so they cannot shadow the defect metadata with a value of a different type. Still reachable viaheadersand<name>_raw.X_raw— always a JSON array ([]for an absent header, previouslynull), and 8-bit values are coerced tostrinstead of raising.Attribute access — names beginning with
_now raiseAttributeErrorinstead of being answered as an absent header. This stops internal bugs being masked as"", and incidentally makescopy.deepcopy/picklework.Header-less messages —
parse_from_string("body only")now parses the body and records the defect, instead of returning an object whose every property is"".Tests
258 passing, 100% statement and branch coverage on all
src/mailparsermodules.Every finding has a regression test asserting the failure mode, not just the fix — scaling assertions for the algorithmic ones, control matrices for the attribution ones, and a
dir(MailParser)collision sweep for the reflective ones.Three pre-existing tests were changed, each because it encoded a bug:
test_issue_139— the original fix for Processing breaks if a "headers:" email header exists #139 hid the literalheaders:header from output rather than resolving it. Recursion is now impossible structurally, so the header is reported like any other. Expectation gained"headers": "hello-world".test_extract_ip_ipv6_fallback— passed only because of F14:REGXIP6matched6:2001:db8::(public) instead of the real2001:db8::1, which Python classifies as private (documentation range). The test's comment asserting "it is not private" was factually wrong. Now uses a genuinely public address and asserts the exact value.test_extract_ip_invalid_ip_returns_none— mockedREGXIP.findall, which the refactor replaced withfinditer, so it silently stopped exercising theValueErrorbranch. Replaced with a direct_public_iptest needing no mocks.Also updated
.claude/agents/security-reviewer.md— the reviewer missed all four original findings. Its complexity guidance was regex-only and it had no notion of reflective dispatch or trust-boundary parsing. Added: probes must vary distinct-key count and total size independently (a size-only probe keeps the ratio flat and hides exactly this quadratic); a mandatorydir(MailParser)collision sweep covering collision / recursion cycle / side-effecting property; a 4-cell control matrix for attribution bugs that tests the fall-through, not just the single-header case; and an explicit instruction to reject denylist patches — which is howheaders_jsonsurvived theheadersfix.CLAUDE.md— the two invariants above, written as rules for future changes, including "do not add lookbehind guards to_HELO_RE", since three rounds of that each reopened a hole.README.md— the behaviour changes above.Notes for release
Given the DoS findings are remotely triggerable on unauthenticated input, this warrants a patch release and GHSA advisories rather than riding to the next feature release.