VYPR
Medium severity5.3GHSA Advisory· Published Jun 15, 2026· Updated Jun 15, 2026

markdown-it: Quadratic complexity DoS in smartquotes rule via replaceAt string operations

CVE-2026-48988

Description

A quadratic time complexity DoS in markdown-it's smartquotes rule (typographer enabled) allows attackers to cause denial of service with many consecutive quotation marks.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

A quadratic time complexity DoS in markdown-it's smartquotes rule (typographer enabled) allows attackers to cause denial of service with many consecutive quotation marks.

Vulnerability

A quadratic time complexity vulnerability exists in the replaceAt() helper function used by the smartquotes rule in lib/rules_core/smartquotes.mjs of the markdown-it library [1][2]. The vulnerability is triggered when the typographer: true option is enabled. The replaceAt() function creates new string slices and concatenations for each quote character replaced, leading to O(n^2) time complexity when processing a token containing many consecutive quotation marks. All versions of markdown-it prior to the fix are affected.

Exploitation

An attacker can send a deliberately crafted Markdown input consisting of a long sequence of double-quote (") or single-quote (') characters (e.g., 160,000 quotes) to an application using markdown-it with typographer: true [1][2]. No authentication or special privileges are required. The input is parsed as a single text token, causing the smartquotes rule to call replaceAt() for each quote character, each operation being O(n) on the growing string. The total processing time scales quadratically with the number of quotes, leading to excessive CPU consumption.

Impact

Successful exploitation results in a denial of service condition. The application becomes unresponsive or extremely slow while rendering the malicious input. For example, 160,000 quotes cause a render time of approximately 21 seconds, compared to 8 ms when typographer is disabled [1][2]. This can be used to exhaust server resources or disrupt availability.

Mitigation

A fix was made available by the markdown-it maintainers. Users should update to the latest patched version of the library. As a temporary workaround, users can disable the typographer option (set typographer: false) if smart quotes are not required. No CVE or KEV listing is known at this time [1][2].

AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
9ce2087562c4

Fix smartquotes perfomance

https://github.com/markdown-it/markdown-itVitaly PuzrinMay 23, 2026Fixed in 14.2.0via ghsa-release-walk
3 files changed · +41 18
  • CHANGELOG.md+3 0 modified
    @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     - More strict entities decode to avoid false positives `;`, #1096.
     - Restore block parser state on fail in `lheading` rule, #1131.
     
    +### Security
    +- Fixed poor smartquotes perfomance on > 70k quotes in single block
    +
     
     ## [14.1.1] - 2026-01-11
     ### Security
    
  • lib/rules_core/smartquotes.mjs+34 18 modified
    @@ -7,14 +7,36 @@ const QUOTE_TEST_RE = /['"]/
     const QUOTE_RE = /['"]/g
     const APOSTROPHE = '\u2019' /* ’ */
     
    -function replaceAt (str, index, ch) {
    -  return str.slice(0, index) + ch + str.slice(index + 1)
    +function addReplacement (replacements, tokenIdx, pos, ch) {
    +  if (!replacements[tokenIdx]) {
    +    replacements[tokenIdx] = []
    +  }
    +
    +  replacements[tokenIdx].push({ pos, ch })
    +}
    +
    +function applyReplacements (str, replacements) {
    +  let result = ''
    +  let lastPos = 0
    +
    +  replacements.sort((a, b) => a.pos - b.pos)
    +
    +  for (let i = 0; i < replacements.length; i++) {
    +    const replacement = replacements[i]
    +
    +    result += str.slice(lastPos, replacement.pos) + replacement.ch
    +    lastPos = replacement.pos + 1
    +  }
    +
    +  return result + str.slice(lastPos)
     }
     
     function process_inlines (tokens, state) {
       let j
     
       const stack = []
    +  // token index -> list of replacements in the original token content
    +  const replacements = {}
     
       for (let i = 0; i < tokens.length; i++) {
         const token = tokens[i]
    @@ -28,9 +50,9 @@ function process_inlines (tokens, state) {
     
         if (token.type !== 'text') { continue }
     
    -    let text = token.content
    +    const text = token.content
         let pos = 0
    -    let max = text.length
    +    const max = text.length
     
         /* eslint no-labels:0,block-scoped-var:0 */
         OUTER:
    @@ -122,7 +144,7 @@ function process_inlines (tokens, state) {
           if (!canOpen && !canClose) {
             // middle of word
             if (isSingle) {
    -          token.content = replaceAt(token.content, t.index, APOSTROPHE)
    +          addReplacement(replacements, i, t.index, APOSTROPHE)
             }
             continue
           }
    @@ -145,18 +167,8 @@ function process_inlines (tokens, state) {
                   closeQuote = state.md.options.quotes[1]
                 }
     
    -            // replace token.content *before* tokens[item.token].content,
    -            // because, if they are pointing at the same token, replaceAt
    -            // could mess up indices when quote length != 1
    -            token.content = replaceAt(token.content, t.index, closeQuote)
    -            tokens[item.token].content = replaceAt(
    -              tokens[item.token].content, item.pos, openQuote)
    -
    -            pos += closeQuote.length - 1
    -            if (item.token === i) { pos += openQuote.length - 1 }
    -
    -            text = token.content
    -            max = text.length
    +            addReplacement(replacements, i, t.index, closeQuote)
    +            addReplacement(replacements, item.token, item.pos, openQuote)
     
                 stack.length = j
                 continue OUTER
    @@ -172,10 +184,14 @@ function process_inlines (tokens, state) {
               level: thisLevel
             })
           } else if (canClose && isSingle) {
    -        token.content = replaceAt(token.content, t.index, APOSTROPHE)
    +        addReplacement(replacements, i, t.index, APOSTROPHE)
           }
         }
       }
    +
    +  Object.keys(replacements).forEach(function (tokenIdx) {
    +    tokens[tokenIdx].content = applyReplacements(tokens[tokenIdx].content, replacements[tokenIdx])
    +  })
     }
     
     export default function smartquotes (state) {
    
  • test/pathological.mjs+4 0 modified
    @@ -170,5 +170,9 @@ describe('Pathological sequences speed', () => {
             { linkify: true }
           )
         })
    +
    +    it('a lot of smartquotes', async () => {
    +      await test_pattern('"'.repeat(160000), { typographer: true })
    +    })
       })
     })
    

Vulnerability mechanics

Root cause

"The `replaceAt()` helper in the smartquotes rule performs O(n) string slicing per quote character, resulting in O(n²) total time when processing many consecutive quotation marks."

Attack vector

An attacker submits a markdown payload consisting of many consecutive quotation marks (e.g., 160,000 double-quote characters) to an application that renders user-supplied markdown with `typographer: true` enabled [ref_id=1]. Each quote triggers the `replaceAt()` helper, which creates three string slices and concatenates them — an O(n) operation per quote — leading to O(n²) total time [CWE-407]. The server spends over 21 seconds processing a single 160KB payload, and repeated submissions can exhaust CPU resources, causing denial of service [CWE-400].

Affected code

The vulnerability is in the `replaceAt()` helper function within `lib/rules_core/smartquotes.mjs`. The `process_inlines()` function (line 14) iterates over each quote character in a text token and calls `replaceAt()` (lines 151-152) to substitute it with a typographic quote, performing O(n) string slicing per call.

What the fix does

The advisory recommends replacing the `replaceAt()` approach with an array-based or StringBuilder-style method that collects all replacements and applies them in a single pass, reducing time complexity to O(n) [ref_id=1]. No patch is shown in the bundle, so the exact diff is unavailable; the suggested fix eliminates repeated O(n) string slicing by building the result incrementally.

Preconditions

  • configThe markdown-it instance must have `typographer: true` explicitly enabled (default is false).
  • inputThe attacker must be able to supply arbitrary markdown input to the parser.

Reproduction

```javascript const md = require('markdown-it'); const instance = md({ typographer: true });

// 160,000 consecutive double-quote characters const payload = '"'.repeat(160000);

console.time('render'); instance.render(payload); console.timeEnd('render'); // Output: render: ~21000ms (21 seconds)

// Compare with typographer disabled: const safe = md({ typographer: false }); console.time('render-safe'); safe.render(payload); console.timeEnd('render-safe'); // Output: render-safe: ~8ms ```

Generated on Jun 15, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.