Skip to content

OpenID discovery: make the HTML pre-strip in parseLinkAttrs linear - #78

Merged
necaris merged 1 commit into
necaris:mainfrom
BrianWillows:fix-html-prestrip-quadratic
Oct 9, 2026
Merged

necaris merged 1 commit into
necaris:mainfrom
BrianWillows:fix-html-prestrip-quadratic

Conversation

@BrianWillows

Copy link
Copy Markdown

As agreed by email with @necaris, this is the fix for the report I sent privately, opened as a public PR.

parseLinkAttrs() spends time quadratic in the length of the page it is given, and OpenID discovery hands
it the body of whatever URL the end user typed. Measured on 3.2.0 through parseLinkAttrs, the public
entry point openid/consumer/discover.py:163 calls:

page before after
128 KB of <!-- with no --> 11.7475 s 0.0079 s
512 KB of <![CDATA[ with no ]]> 64.6237 s 0.0273 s
512 KB of <script > with no </script> 69.1554 s 0.0191 s
512 KB of <script with no > anywhere 12.1776 s 0.0233 s
2 MB of any of the above - 0.07 to 0.12 s
288 KB realistic discovery page 0.0046 s 0.0055 s

Roughly fifteen times the cost per quadrupling before; linear after; no measurable cost on an ordinary page.

Who controls the input. Discovery runs before the user has proved anything: the relying party fetches
the identifier URL the user supplied and parses the response. The page author therefore chooses the body,
and a few hundred kilobytes is an ordinary page size.

Cause. openid/consumer/html_parse.py:81, applied at :209:

removed_re = re.compile(r'''
  <!--.*?-->
| <!\[CDATA\[.*?\]\]>
| <script\b (?!:) [^>]*>.*?</script>
''', flags)   # DOTALL | IGNORECASE | VERBOSE | UNICODE
...
stripped = removed_re.sub('', html)

Each alternative is a lazy pair. When an opener has no closer, the engine scans to the end of the document
and then tries again from the next opener, so every unclosed opener costs the rest of the page. The fourth
row is the same shape one level in: [^>]*> scans to the end from every <script when there is no >.

Fix - one forward scan with fixed-string searches. A lazy pair means "the first opener, then the
first closer after it", which search() on a literal gives directly. The step that makes it linear rather
than merely faster: once a closer is absent from the rest of the string, no later opener of that kind can
complete either, so that kind is retired. The script branch keeps the exact <script\b(?!:) test by
matching that fragment at the fixed position of each <script literal, then takes the first > and the
first </script> after it.

-    stripped = removed_re.sub('', html)
+    stripped = removeMarkup(html)

with removeMarkup and its helpers added above tag_expr (see the diff in this PR). removed_re stays
defined so that anything importing it keeps working.

Verification.

  • Differential at the output of parseLinkAttrs() and of the strip itself: 351,092 inputs - every
    sequence of 0 to 3 tokens drawn from comment, CDATA and script openers and closers, <script:ns>,
    <scriptx>, upper-case variants, stray <, >, -, ], newlines, a non-ASCII letter whose
    lower-casing changes string length, 150,000 random longer token strings, every string of length 0 to 4
    over <>-![]/scripta:\n, 120,000 random longer ones, and 34 hand-written pages. The SHA-256 of all
    input/output pairs is identical on both trees.
  • Your own openid/test/linkparse.txt: 73 cases pass on both trees, and the wider consumer suite
    (linkparse, test_htmldiscover, test_discover, test_consumer, test_examples, test_fetchers,
    test_parsehtml): 294 tests, 0 failures, 0 errors on both.
  • A control test: seven parsing assertions read off the shipped code (a comment hiding a link, a script
    hiding a link, upper-case SCRIPT, a CDATA block, an unclosed <script - which the module docstring says
    is ignored - a script: namespace, and server plus delegate) plus a timing assertion on 128 KB of
    comment openers. On 3.2.0 the seven pass and the timing fails at 11.66 s; patched, all pass in 0.006 s.
  • The module docstring's contract is preserved: unclosed <script>, unclosed comments and unclosed
    <![CDATA[ are ignored.

Two things the fix does not change. html_find and head_find run on the stripped text and are
linear on the same shapes (0.0853 s at 2 MB of <html openers), so nothing after the strip takes over
the cost. And the rewrite deliberately avoids str.lower() for the case-insensitive searches, because
lower-casing can change a string's length for some characters and would desynchronise the offsets;
re.IGNORECASE on a literal is linear and keeps the indices honest.

Prior art. The maintained fork python-openid2 ships openid/consumer/ with no html_parse.py at all,
and the PHP port's Auth_OpenID_Parse documentation states this regex "is quite ineffective and may fail
with the default pcre.backtrack_limit" on large HTML and should use strpos instead - which is the fix
above, reached independently in another language.

Found with AI assistance (Claude) and verified by hand.

removed_re.sub() is quadratic in the length of the fetched page when a
comment, CDATA or script opener has no closer: every unclosed opener
rescans to the end of the document. OpenID discovery passes it the body
of the identifier URL the end user supplies.

Replace the sub() with one forward scan using fixed-string searches.
Once a closer is absent from the rest of the string, that kind of
opener is retired, which keeps the scan linear. Output is identical to
removed_re.sub('', html); removed_re stays defined for importers.

128 KB of unclosed comment openers: 12.70 s before, 0.0008 s after.
Consumer test suites: 294 tests, 0 failures on both trees.
@necaris
necaris merged commit 9e77fbb into necaris:main Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants