Summary
eml_parser.routing.parserouting() dynamically builds a regex from up to four greedy .* groups (from (?P<from>.*)by (?P<by>.*)with (?P<with>.*)for (?P<for>.*)) terminated by an escaped literal of the date extracted from a normalized copy of the Received header. Because the date literal comes from the normalized text but is searched in the original line, a crafted header can make the trailing literal unmatchable; the search then fails at every start position only after exhaustively backtracking the four nested greedy groups — O(n^4) in header length. A single ~5.9 KB email pins one CPU core for ~12 seconds; doubling the input multiplies the cost ~25x, so a ~13 KB header takes minutes and a ~25 KB header effectively hangs the parse worker.
Details
Root cause: eml_parser/routing.py:140-148 (v3.0.3):
reg = ''
for item in tout:
reg += item[1] + '(?P<' + item[1].strip() + '>.*)' # e.g. 'from (?P<from>.*)'
if npdate:
reg += eml_parser.regexes.escape_special_regex_chars.sub(r"""\\\1""", npdate)
reparse = re.compile(reg)
reparseg = reparse.search(line) # searched in the ORIGINAL line
npdate is extracted from npline, a normalized copy of the header (spacing added around (, ), ; at routing.py:79-81; parenthesized text removed at line 82; whitespace collapsed at line 83), so it is not guaranteed to be a substring of line. An unbalanced ( in the date tail (e.g. +0000(comment) becomes ( comment in npline but stays (comment in line, making the mandatory trailing literal unmatchable.
Data flow: attacker-controlled Received: header (arbitrary length; the custom policy sets max_line_length=0) -> parser.py:352-355 (lowercased, whitespace-flattened, no size cap - unlike body text, which is sliced) -> parser.py:365 routing.parserouting(...) -> O(n^4) search. Every Received header is processed, multiplying the cost, and the behavior cannot be disabled via any EmlParser option.
Measured on Python 3.14 (identical with stdlib re and the optional regex module):
| Segments |
Header size |
Wall time |
| 20 |
1.4 KB |
0.022 s |
| 40 |
2.9 KB |
0.43 s |
| 80 |
5.8 KB |
12 s |
| 160 (extrapolated) |
13 KB |
~5 min |
cProfile attributes 83% of wall time to re.Pattern.search called from routing.parserouting. The parse completes normally (no exception) - it is pure CPU exhaustion.
This is distinct from GHSA-g47v-rwmh-r9f8 (recursion DoS in get_raw_body_text(), fixed via MULTIPART_RECURSION_LIMIT - different function and mechanism) and from issue #48 (old url_regex_simple body-path hang; body regexes now run on ~600-char slices, capping impact - the Received-header path is unsliced).
PoC
import eml_parser
seg = 'from h{0}.example.com by mx{0}.example.com with esmtp for u{0}@example.com '
received = ''.join(seg.format(i) for i in range(80)) + ';Mon, 01 Jan 2024 00:00:00 +0000(comment'
# NB: unbalanced '(' in the date tail -> the normalized date literal never occurs in the
# searched line, so the dynamic 4-group greedy regex can never match and backtracks O(n^4).
eml = (
'Received: ' + received + '\r\n'
'From: attacker@evil.example\r\n'
'To: victim@target.example\r\n'
'Subject: poc\r\n'
'Content-Type: text/plain\r\n'
'\r\n'
'hello\r\n'
).encode()
import time
t0 = time.perf_counter()
eml_parser.EmlParser().decode_email_bytes(eml)
print(f'{time.perf_counter() - t0:.2f}s') # ~12 s for a 5.9 KB input
Observed on v3.0.3: eml size: 5887 bytes, decode_email_bytes() wall time: 12.33s, message parses fine. Affected APIs: decode_email, decode_email_bytes, parse_email.
Impact
Algorithmic-complexity denial of service. Any application that parses untrusted email with eml_parser (mail gateways, phishing-analysis and forensics pipelines, ticketing systems) can have a parser worker pinned by a single small email; an attacker sending a steady trickle of such messages keeps workers permanently busy (CPU exhaustion, queue backlog) even behind per-message timeouts. No crash, corruption, or information leak - availability impact only.
Reporter credit: Niranjan Ganesan (https://securityinsights.io/, @iam-niranjan)
Resolution
An algorithmic complexity vulnerability ($O(n^4)$ ReDoS) in eml_parser.routing.parserouting() was resolved.
Previously, parserouting() dynamically constructed a regex using multiple greedy .* capture groups appended with a normalized date string (npdate). Because npdate was extracted from a normalized copy of the Received header line, formatting mismatches between npdate and the un-normalized input line could render the trailing literal unmatchable. This forced the engine into exhaustive backtracking across nested greedy groups on unmatchable inputs, causing high CPU consumption and potential worker hangs on malicious headers.
Fix:
-
Dynamic Regex Construction: Removed the static
npdate suffix from the compiled pattern, replacing it with non-greedy field capture groups (.*?) terminated lazily by a semicolon or end-of-line anchor ((?:;|$)).
-
Keyword Ordering Logic: Streamlined the routing keyword position detection to a single-pass
str.find() search, removing $O(k^2)$ matrix evaluations.
These changes eliminate catastrophic backtracking and reduce processing time from $O(n^4)$ to linear $O(n)$ complexity.
Summary
eml_parser.routing.parserouting()dynamically builds a regex from up to four greedy.*groups (from (?P<from>.*)by (?P<by>.*)with (?P<with>.*)for (?P<for>.*)) terminated by an escaped literal of the date extracted from a normalized copy of theReceivedheader. Because the date literal comes from the normalized text but is searched in the original line, a crafted header can make the trailing literal unmatchable; the search then fails at every start position only after exhaustively backtracking the four nested greedy groups — O(n^4) in header length. A single ~5.9 KB email pins one CPU core for ~12 seconds; doubling the input multiplies the cost ~25x, so a ~13 KB header takes minutes and a ~25 KB header effectively hangs the parse worker.Details
Root cause:
eml_parser/routing.py:140-148(v3.0.3):npdateis extracted fromnpline, a normalized copy of the header (spacing added around(,),;at routing.py:79-81; parenthesized text removed at line 82; whitespace collapsed at line 83), so it is not guaranteed to be a substring ofline. An unbalanced(in the date tail (e.g.+0000(comment) becomes( commentinnplinebut stays(commentinline, making the mandatory trailing literal unmatchable.Data flow: attacker-controlled
Received:header (arbitrary length; the custom policy setsmax_line_length=0) ->parser.py:352-355(lowercased, whitespace-flattened, no size cap - unlike body text, which is sliced) ->parser.py:365routing.parserouting(...)-> O(n^4) search. Every Received header is processed, multiplying the cost, and the behavior cannot be disabled via anyEmlParseroption.Measured on Python 3.14 (identical with stdlib
reand the optionalregexmodule):cProfile attributes 83% of wall time to
re.Pattern.searchcalled fromrouting.parserouting. The parse completes normally (no exception) - it is pure CPU exhaustion.This is distinct from GHSA-g47v-rwmh-r9f8 (recursion DoS in
get_raw_body_text(), fixed viaMULTIPART_RECURSION_LIMIT- different function and mechanism) and from issue #48 (oldurl_regex_simplebody-path hang; body regexes now run on ~600-char slices, capping impact - the Received-header path is unsliced).PoC
Observed on v3.0.3:
eml size: 5887 bytes,decode_email_bytes() wall time: 12.33s, message parses fine. Affected APIs:decode_email,decode_email_bytes,parse_email.Impact
Algorithmic-complexity denial of service. Any application that parses untrusted email with eml_parser (mail gateways, phishing-analysis and forensics pipelines, ticketing systems) can have a parser worker pinned by a single small email; an attacker sending a steady trickle of such messages keeps workers permanently busy (CPU exhaustion, queue backlog) even behind per-message timeouts. No crash, corruption, or information leak - availability impact only.
Reporter credit: Niranjan Ganesan (https://securityinsights.io/, @iam-niranjan)
Resolution
An algorithmic complexity vulnerability ($O(n^4)$ ReDoS) in
eml_parser.routing.parserouting()was resolved.Previously,
parserouting()dynamically constructed a regex using multiple greedy.*capture groups appended with a normalized date string (npdate). Becausenpdatewas extracted from a normalized copy of theReceivedheader line, formatting mismatches betweennpdateand the un-normalized input line could render the trailing literal unmatchable. This forced the engine into exhaustive backtracking across nested greedy groups on unmatchable inputs, causing high CPU consumption and potential worker hangs on malicious headers.Fix:
npdatesuffix from the compiled pattern, replacing it with non-greedy field capture groups (.*?) terminated lazily by a semicolon or end-of-line anchor ((?:;|$)).str.find()search, removingThese changes eliminate catastrophic backtracking and reduce processing time from$O(n^4)$ to linear $O(n)$ complexity.