Impact
Parsing a single small email consumes seconds to minutes of CPU, or exhausts available
memory. Every input is attacker-controlled and no authentication is required, so any
service that parses untrusted mail — mail gateways, abuse pipelines, IMAP fetchers, upload
and API ingestion endpoints — can be denied service by one message.
Four distinct amplification primitives share one root cause: header names chosen by the
sender were resolved through Python attribute lookup, and the caller-facing conveniences of
that API (_json / _raw suffixes) were applied to them.
1. Quadratic CPU per distinct header name (CWE-407, reported by @iam-niranjan)
_make_mail() resolved one value per distinct header name, and each resolution rescanned
the whole header list via Message.get_all() — O(distinct × total). Cost tracks the number
of distinct names, not message size.
| distinct names |
size |
4.6.1 |
4.6.2 |
| 4,000 |
44 KB |
0.363 s |
0.011 s |
| 8,000 |
88 KB |
1.482 s |
0.015 s |
| 16,000 |
176 KB |
6.832 s |
0.031 s |
| 32,000 repeats of one name |
192 KB |
0.057 s |
0.045 s |
Postfix's default header_size_limit of 102,400 bytes admits roughly 14,600 distinct
three-character names, giving about 50× amplification through a hardened MTA. Ingestion
paths with no header cap are unbounded.
import mailparser
headers = "".join(f"X{i:05d}: v\r\n" for i in range(16000))
mailparser.parse_from_string(f"From: a@b.c\r\n{headers}\r\nbody\r\n")
2. Unbounded recursion via a Headers_json header (CWE-674)
The headers property guarded against self-recursion by excluding the name headers from
its key set, but not the _json alias. A header named Headers_json made the property
re-enter itself, and each cycle rebuilt the entire header dictionary — multiplying against
primitive 1. Parsing then fails outright with MailParserRecursionError.
| filler headers |
size |
4.6.1 |
4.6.2 |
| 800 |
8 KB |
0.917 s |
0.002 s |
| 1,600 |
16 KB |
18.578 s |
0.003 s |
| 3,200 |
32 KB |
48.312 s |
0.006 s |
About 1500× amplification — 30× more efficient than primitive 1 — well inside any MTA
header limit. It fires during parse() itself, so it cannot be avoided by not calling
mail_json. Headers_json_json behaves identically.
import mailparser
filler = "".join(f"Z{i:04d}: v\r\n" for i in range(1600))
mailparser.parse_from_string(f"Headers_json: x\r\n{filler}\r\nbody\r\n")
3. Exponential memory amplification via a _json-suffixed name (CWE-405)
Each _json suffix re-serialised the previous result, and json.dumps escapes every quote
and backslash, so value length follows L → 2L + 2: five input bytes buy one doubling of
the output. A 148-byte header produced a 536 MB string in 1.24 s. Amplification is additive
across headers and every value is retained:
| headers × depth |
input |
retained |
amplification |
| 10 × 18 |
972 B |
5.2 MB |
5,394× |
| 20 × 20 |
2,152 B |
41.9 MB |
19,490× |
| 40 × 22 |
4,712 B |
335.5 MB |
71,211× |
Roughly 7 KB of headers reaches ~80 GB. The blowup happens inside a C-level json.dumps
that does not deliver signals, so a wall-clock timeout does not interrupt it.
import mailparser
name = "X" + "_json" * 28 # 148 bytes
print(len(mailparser.parse_from_string(f"{name}: x\r\n\r\n").mail[name.lower()]))
# 4.6.1 -> 536870910 4.6.2 -> 1
4. Quadratic blowup in headers via case variants (CWE-407)
Header names compare case-insensitively, so every capitalisation resolves to the same full
value list — but headers keyed on the exact spelling sent. n capitalisations of one name
produced n keys, each holding an n-element list. A 20-character name offers 2²⁰
capitalisations at ~26 bytes each.
| case variants |
size |
4.6.1 |
4.6.2 |
| 2,000 |
52 KB |
0.884 s |
0.004 s |
| 4,000 |
104 KB |
2.917 s |
0.008 s |
| 8,000 |
271 KB |
7.565 s |
0.010 s |
| 16,000 |
540 KB |
30.24 s |
0.004 s |
Also an output amplifier: 271 KB of headers produced 540 MB of JSON, and
mail-parser -f bomb.eml -r produced 302 MB of output from a 198 KB file.
import mailparser
base = "x-aaaaaaaaaaaaaaaaaaa"
variants = ("".join(c.upper() if (i >> j) & 1 else c for j, c in enumerate(base))
for i in range(4000))
mailparser.parse_from_string("".join(f"{v}: v\r\n" for v in variants) + "\r\nb\r\n").headers
Patches
Fixed in 4.6.2. Upgrade with pip install --upgrade mail-parser.
Header values now resolve through a literal-lookup-only resolver backed by a per-parse
index, so every lookup is O(1), no header name can re-enter a property, and the _json /
_raw suffixes are no longer interpreted on names taken from a message. headers
deduplicates names case-insensitively.
Workarounds
None. The issues are triggered by ordinary parsing of a well-formed message, so no
configuration change avoids them.
Operators who cannot upgrade immediately can reduce — but not eliminate — exposure by
capping header count and total header size before handing input to mail-parser, and by
running parsing in a subprocess with a memory rlimit. Note that a wall-clock timeout alone
does not stop primitive 3.
References
Impact
Parsing a single small email consumes seconds to minutes of CPU, or exhausts available
memory. Every input is attacker-controlled and no authentication is required, so any
service that parses untrusted mail — mail gateways, abuse pipelines, IMAP fetchers, upload
and API ingestion endpoints — can be denied service by one message.
Four distinct amplification primitives share one root cause: header names chosen by the
sender were resolved through Python attribute lookup, and the caller-facing conveniences of
that API (
_json/_rawsuffixes) were applied to them.1. Quadratic CPU per distinct header name (CWE-407, reported by @iam-niranjan)
_make_mail()resolved one value per distinct header name, and each resolution rescannedthe whole header list via
Message.get_all()— O(distinct × total). Cost tracks the numberof distinct names, not message size.
Postfix's default
header_size_limitof 102,400 bytes admits roughly 14,600 distinctthree-character names, giving about 50× amplification through a hardened MTA. Ingestion
paths with no header cap are unbounded.
2. Unbounded recursion via a
Headers_jsonheader (CWE-674)The
headersproperty guarded against self-recursion by excluding the nameheadersfromits key set, but not the
_jsonalias. A header namedHeaders_jsonmade the propertyre-enter itself, and each cycle rebuilt the entire header dictionary — multiplying against
primitive 1. Parsing then fails outright with
MailParserRecursionError.About 1500× amplification — 30× more efficient than primitive 1 — well inside any MTA
header limit. It fires during
parse()itself, so it cannot be avoided by not callingmail_json.Headers_json_jsonbehaves identically.3. Exponential memory amplification via a
_json-suffixed name (CWE-405)Each
_jsonsuffix re-serialised the previous result, andjson.dumpsescapes every quoteand backslash, so value length follows
L → 2L + 2: five input bytes buy one doubling ofthe output. A 148-byte header produced a 536 MB string in 1.24 s. Amplification is additive
across headers and every value is retained:
Roughly 7 KB of headers reaches ~80 GB. The blowup happens inside a C-level
json.dumpsthat does not deliver signals, so a wall-clock timeout does not interrupt it.
4. Quadratic blowup in
headersvia case variants (CWE-407)Header names compare case-insensitively, so every capitalisation resolves to the same full
value list — but
headerskeyed on the exact spelling sent. n capitalisations of one nameproduced n keys, each holding an n-element list. A 20-character name offers 2²⁰
capitalisations at ~26 bytes each.
Also an output amplifier: 271 KB of headers produced 540 MB of JSON, and
mail-parser -f bomb.eml -rproduced 302 MB of output from a 198 KB file.Patches
Fixed in 4.6.2. Upgrade with
pip install --upgrade mail-parser.Header values now resolve through a literal-lookup-only resolver backed by a per-parse
index, so every lookup is O(1), no header name can re-enter a property, and the
_json/_rawsuffixes are no longer interpreted on names taken from a message.headersdeduplicates names case-insensitively.
Workarounds
None. The issues are triggered by ordinary parsing of a well-formed message, so no
configuration change avoids them.
Operators who cannot upgrade immediately can reduce — but not eliminate — exposure by
capping header count and total header size before handing input to mail-parser, and by
running parsing in a subprocess with a memory rlimit. Note that a wall-clock timeout alone
does not stop primitive 3.
References