GitHub Security Advisory: GHSA-5478-66c3-rhxr
Package: @chenglou/pretext (NPM)
Severity: HIGH (CVSS 0.0)
CWE: CWE-407: Inefficient Algorithmic Complexity
Vulnerable versions: <= 0.0.4
Patched in: 0.0.5

Summary: Pretext: Algorithmic Complexity (DoS) in the text analysis phase

Details:
`isRepeatedSingleCharRun()` in `src/analysis.ts` (line 285) re-scans the entire accumulated segment on every merge iteration during text analysis, producing O(n²) total work for input consisting of repeated identical punctuation characters. An attacker who controls text passed to `prepare()` can block the main thread for ~20 seconds with 80KB of input (e.g., `"(".repeat(80_000)`).

Tested against commit 9364741d3562fcc65aacc50953e867a5cb9fdb23 (v0.0.4) on Node.js v24.12.0, Windows x64.

A standalone PoC and detailed write-up are attached below.

---

## Root Cause

The `buildMergedSegmentation()` function (line 795) processes text segments produced by `Intl.Segmenter`. When consecutive non-word-like segments consist of the same single character (e.g., `(`, `[`, `!`, `#`), the code merges them into one growing segment (line 859):

```typescript
// analysis.ts:849-859 - the merge branch inside the build loop
} else if (
  isText &&
  !piece.isWordLike &&
  mergedLen > 0 &&
  mergedKinds[mergedLen - 1] === 'text' &&
  piece.text.length === 1 &&
  piece.text !== '-' &&
  piece.text !== '—' &&
  isRepeatedSingleCharRun(mergedTexts[mergedLen - 1]!, piece.text)  // <- O(n) per call
) {
  mergedTexts[mergedLen - 1] += piece.text  // append to accumulator
```

Before each merge, it calls `isRepeatedSingleCharRun()` (line 857) to verify that ALL characters in the accumulated segment match the new character:

```typescript
// analysis.ts:285-291
function isRepeatedSingleCharRun(segment: string, ch: string): boolean {
  if (segment.length === 0) return false
  for (const part of segment) {    // <- Iterates ENTIRE accumulated string
    if (part !== ch) return false
  }
  return true
}
```

`Intl.Segmenter` with `granularity: 'word'` produces individual non-word segments for each punctuation character. For a string of N identical punctuation characters, the merge check is called N times. On the k-th call, the accumulated segment is k characters long, so `isRepeatedSingleCharRun` performs k comparisons.

Total work: `1 + 2 + 3 + ... + N = N(N+1)/2 = O(n^2)`

### Call chain

```
prepare(text, font)                                          // layout.ts:472
  -> prepareInternal(text, font, ...)                        // layout.ts:424
    -> analyzeText(text, profile, whiteSpace='normal')       // layout.ts:430 -> analysis.ts:993
      -> buildMergedSegmentation(normalized, profile, ...)   // analysis.ts:1013 -> analysis.ts:795
        -> for each Intl.Segmenter segment:
          -> isRepeatedSingleCharRun(accumulated, newChar)   // line 857 -> line 285
            -> iterates entire accumulated string            // O(k) per call, k growing
```

## Proof of Concept

The simplest payload is a string of repeated `(` characters:

```typescript
import { prepare } from '@chenglou/pretext'

// 80,000 characters -> ~20 seconds of main-thread blocking
const payload = '('.repeat(80_000)
prepare(payload, '16px Arial')  // Blocks for ~20 seconds
```

Any single character t