Reading a message: the HTML stripper and the References parser take quadratic time on unbounded input #15
Labels
No labels
bug
documentation
enhancement
performance
security
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo-imap#15
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo-imap at
cc09c6381, timing_strip_htmlandparseon inputs of growing size.The regular expressions that turn an HTML part into text, and the one that reads the threading headers, rescan to the end of their input from every candidate start when the terminator they look for is missing. Their cost grows with the square of the input, nothing bounds the input, and the work runs inline in the scheduler pass or in a web worker. This is uncontrolled resource consumption through an inefficient regular expression on untrusted input.
Where:
src/postulo_imap/mailbox.py:128-131,_strip_html:(?is)<(script|style).*?</\1>,<[^>]+>and\s+\n, each run over the whole decoded part.src/postulo_imap/mailbox.py:145-147,parse:re.findall(r"<[^>]+>", ...)overIn-Reply-ToandReferences, for every message whatever its body.src/postulo_imap/mailbox.py:102-111,plain_text: the cut toEXCERPT_CHARSis at line 111, after the stripping at lines 104 and 109.src/postulo_imap/mailbox.py:199,Mailbox.fetch:BODY.PEEK[]fetches each message whole, so no size limit applies earlier either.What happens: measured on the snapshot (Python 3.14), on input made only of the shape named:
scriptopenings with no closing tag<with no>Referencesheader of<with no>Doubling the input quadruples the time, so a part of 1 MB takes between ten minutes and half an hour, and a message may be far larger than that. A
text/plainbody is not affected (1 MB of spaces: 0.03 s); only the HTML path and the two headers are.Who can trigger it: any account, by pointing a connection at a mailbox it controls; there the size is whatever that server hands over. Also whoever can get a message into the folder a person's connection reads, within the size the receiving mail server accepts.
Why it matters: syncs run inline, one after another, in the scheduler pass (
plugins/syncing.py:110-137, called fromsend_due_remindersunderonly_one_pass), and the sync budget only decides whether to start another connection: one already started is never cut off. While it runs, no later pass starts, so every person's reminders, quiet-application and closing notices, document copies and webhooks wait. The message is flagged only at the end of the pass, on a connection that has by then sat idle for as long as the regular expression ran; if the server has dropped it, nothing is flagged and the next pass repeats the cost. With Leave the mailbox untouched it repeats on every pass by design. Sync now (plugins/views.py:445-459) spends the same time in a web worker.Fix:
_strip_html, and the two threading headers to a few kilobytes beforefindall.html.parser.HTMLParsersubclass that collects text and skipsscript,styleandhead. That also decodes entities, which the classification needs for its own reasons.BODY.PEEK[]<0.N>with a ceiling, so an oversized message is never held in memory whole.Done when
tests/test_imap.pyhas a test that strips 1 MB of each of the three shapes above, and parses a message whoseReferencesheader is 100 KB of<, each within one second. All four take minutes today.From the 2026-10-16 security review: the same two headers have a second problem besides the regex's time, the number of identifiers kept.
parsekeeps every<...>match ofIn-Reply-ToandReferences(mailbox.py:145-147,156), andmatchbinds all of them into oneINclause (matching.py:90-98:filter(external_id__in=mail.references, ...)). Django splits anINlist only on Oracle (max_in_list_size()isNonefor SQLite and PostgreSQL), so a header with more identifiers than the database accepts as bound parameters (SQLite's default 32 766, roughly a 100 KB header; PostgreSQL's 65 535, roughly 200 KB) raisesOperationalErrorinside the loop. The pass ends beforebox.mark(uids), and the next pass fetches the same message and fails again. Such a header is far outside normal mail and often above the receiving MTA's limit (Postfix defaults to 102 400 bytes), so this is Low, but nothing in the plugin bounds it.Cutting the headers to a few kilobytes before
findall, as proposed here, bounds the count as a side effect. To make it explicit, please also:In-Reply-Toidentifier plus the last few ofReferences(RFC 5322 §3.6.4 puts the parent last), e.g.references[-8:], each cut to the 250 charactersSuggestion.external_idholds;Referencesheader of several thousand well-formed identifiers: the pass files the other messages, flags them, and the query binds at most nine values. The current "Done when" (100 KB of<with no>) yields zero identifiers and would not exercise theIN.Fixed by pull request #25.