A 7.5-year-old CVE, a 42-line patch, and the afternoon nobody had
Two lines reproduce it: import markdown, time t = time.perf_counter() markdown.markdown('[' * 20_000) print(round(time.perf_counter() - t, 3)) 77.6 seconds before, 0.081 after. 958×, same machine, same interpreter, no extensions enabled. The input is twenty thousand open brackets and nothing else. The growth is clean quadratic. 2 000 brackets: 0.79 s. 4 000: 3.15 s. 8 000: 12.7 s. 13 000: 33.5 s. 20 000: 77.6 s. Every doubling multiplies by four, which is what O(n²) looks like when you plot it instead of arguing about it. At 52 000 brackets the patched build finishes in 0.36 s; the unpatched one, extrapolating from five measured points, does not finish before you get bored. This is CVE-2019-25781, CVSS v4 8.7, published 2026-10-29, covering 3.0 through 3.11.2. Availability impact high, everything else none. No memory corruption, nothing exotic — a parser you can make burn a core for minutes from a comment box. The timeline is the actual article When What happened 2019-03-06 Report filed in the aftermath of a real denial of service, not a fuzzing session. Root cause named the same day: each [ re-runs the bracket walk to the end of the buffer. A maintainer answers: "I'm willing to entertain any PR as long as it maintains backward compatibility." 2024-02-17 Someone points at a second quadratic path in a different processor. Nothing ships. 2026-09-16 An unrelated patch merges and closes this issue by accident — it solved a different quadratic case. Reopened seven hours later. A maintainer: "This can be reduced now." 2026-10-29 CVE assigned. Priority changes. 2026-11-06 Timings posted to the thread. 2026-11-07 Patch opened as a pull request. Still open, still unreviewed. Note what is not in that table: a disagreement about whether the bug is real, or a rejected patch. The diagnosis was right in March 2019 and the door was open the same afternoon. The gap between the first row and the last is seven years and eight months, and it is not made of either of those things. Why a fix that cheap sat there The function sits on the hot path. Links, images and reference-style links all go through it, so any behavioural difference is a rendering regression in somebody's documentation pipeline. Forty-two lines of change, and one hard question: does the output still match, byte for byte, on input nobody has collected yet? Writing the fix is cheap. Proving it is safe is expensive, and that bill lands on the reviewer, not the author. A maintainer reading a new algorithm has to rebuild the old behaviour in their head to check the claim, which costs more than writing the stack themselves. Absent a bundle of evidence they can skim, leaving it alone is the rational move. The old code is slow but correct, and slow without a test attached is not a bug report. A CVE changes ordering. It moves the issue toward the top of a list. It does not add an hour to anyone's week. What the evidence bundle looked like The root cause is not "the regex is slow". The link pattern matches every [; getText then walks forward from each one, counting brackets, until the count returns to zero. For a run of unclosed openers the count never returns to zero, so every [ walks to the end of the buffer. Quadratic. The patch computes the matching ] for each [ once per buffer with a stack, caches it keyed on the buffer object's identity, and reduces getText to a dictionary lookup. A ] with no [ before it is still ignored exactly as before, so unpaired closers behave identically. It covers the [ direction only; the (/) walk in getLink is untouched and still deserves its own look. Then the part that ate the afternoon: 1 945 rendered documents compared against unpatched master. 400 short random inputs over brackets, parens, backticks, quotes and escapes; 1 500 longer random documents with link and image fragments mixed in; 45 hand-written nesting shapes at depths 1, 2, 3 and 7, closers before openers, 200-deep runs. Two corpora, two concatenated-output hashes, identical before and after: 51cb0be444d0524fd90ae0c983141369 and ff96a25b5fee5033bbef14f0dc071d3b. The suite: 1 188 tests, 111 skipped, green. flake8 clean. One new test that proves the regression is real: 78 seconds on the old code, 0.08 on the new. The test's runtime is the evidence — you cannot accidentally pass it. "Tests pass" is a claim about the tests. "Output bytes are identical on 1 945 documents, here are the hashes" is a claim about behaviour. The part people will misread This is not a story about an AI writing a patch. The reviewer's question in that thread was narrower and better: can the person filing this speak for the code, line by line, and rework it in review? A disclosed, reproducible evidence pack answers that question whether a human or a tool typed the diff. Nobody is owed a review. You earn one when checking your work costs the maintainer less than doing it themselves. 958× is not the interesting number. 1 945 identical hashes is. Five checks before you open that PR Measure on one machine, one interpreter, four or five values of n, and plot the shape. Two points make a line; five make a curve. Compare rendered-output hashes across random and hand-written corpora, not just the test suite. Turn the old slowness into a test: pick an n where the old code visibly hangs. Keep the diff on the reported problem. No refactors, no dependency bumps, no reformatting. Fill in the disclosure section, and say explicitly what you did not test. Why this shape is familiar The number that stays with me is not 958×. It is seven years and eight months of nobody measuring anything. A slow path is not a failing test, an open issue is not a blocked release, and no dashboard was ever going to draw a red line around "this parser takes 77 seconds on input nobody sends". Every signal said the software was fine, and every signal was describing a different question. I ship a Linux audit tool now, and that silence is the thing people actually pay to remove. The case I keep coming back to: a backup target the checker cannot read at all, a target that is genuinely empty, and a target written five minutes ago. Three different answers. A checker that prints one green tick for all three has told you nothing, and it has said it clearly enough that you stopped walking over to look. That is the check that catches a job which exits 0 and writes nothing. The tool is called vaultcheck, and keeping those three answers apart is the entire product. I'll write that code path up on its own some other day — the walk that swallows its own errors and the mtime fallback that made an unreadable directory look fresh. It is a better lesson than the CVE, because it is the one that gets shipped everywhere.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to