Skip to content

Track: quill@2.0.3 HTML-export XSS (GHSA-v3m3-f69x-jf25) — no upstream patch, mitigated via double sanitize #105

@important-new

Description

@important-new

Summary

quill@2.0.3 is flagged by a Dependabot / npm audit advisory for an XSS issue in its
HTML-export path (GHSA-v3m3-f69x-jf25). There is no patched upstream release of Quill
yet — npm audit fix --force only "resolves" it by downgrading to quill@2.0.2 (an older
release, not a real fix), which we deliberately do not take.

This issue tracks the advisory and documents the mitigation we ship in the meantime.

Why the practical risk is low

Quill's rich text only reaches a viewer's browser through the agreement template body, which
is authored by an authenticated inspector/admin (in standalone, the operator themselves) — not
by anonymous/untrusted users. The advisory is exploitable only when malicious HTML is rendered to a
victim, so the realistic attack surface is small.

Mitigation shipped (defense-in-depth)

The render side is hardened so that whether or not Quill produces unsafe markup, nothing dangerous
can reach the DOM:

1. Server-side sanitize at write timeserver/services/agreement.service.ts
(sanitizeAgreementHtml), applied in createAgreement / updateAgreement before persisting:

  • Strips dangerous element pairs and their content (script, style, iframe, object, embed,
    form, input, button, textarea, svg, math, link, meta).
  • Removes HTML comments in a loop-until-stable pass (guards against reconstructed <!-- payloads —
    CodeQL js/incomplete-multi-character-sanitization).
  • Allow-lists only p, strong, em, u, b, i, h2, h3, ol, ul, li, br, span. Allowed tags are
    rebuilt clean — every attribute is dropped
    except a strictly-matched class="ql-…" on
    p/ol/ul/li (for Quill indent levels).
  • Loops a final on* / javascript: / data: strip until stable.

2. Client-side DOMPurify on mountapp/components/SanitizedHtml.tsx:

  • Lazy import('dompurify') inside useEffect (≈26 KB client-only chunk; worker entry size
    unchanged), re-sanitizing with a real HTML parser using an allow-list mirroring the server.
  • Cloudflare Workers SSR has no DOM, so DOMPurify runs only in the browser (and in the headless
    renderer used for signed PDFs). SSR emits the server-sanitized HTML as a safe first paint /
    no-JS fallback.

Both user-content render paths — app/routes/public/agreement-sign.tsx and
app/routes/public/agreement-printable.tsx — go through <SanitizedHtml>. (The only other
dangerouslySetInnerHTML usages are static CSS / FOUC constants in root.tsx, not user input.)

Why this is structurally safe

The output allow-list contains no URL-bearing tags — no <a>, no <img>, no <svg>. Combined
with the rebuild-without-attributes approach, event-handler attributes and javascript:/data:
URIs are structurally unreachable, and <script> injected via innerHTML is inert. The security
therefore rests on the restrictive allow-list, not on regex precision.

⚠️ Regression guard: do not add <a href> or <img src> to the allow-list (in either
SanitizedHtml.tsx ALLOWED_TAGS or agreement.service.ts sanitizeAgreementHtml). Doing so
would make the weaker regex javascript:/data: strip the only remaining guard on the SSR/no-JS
path.

Resolution criteria

  • Upgrade to a real patched Quill release (> 2.0.3) once available, then re-evaluate whether the
    double-sanitize layer can be relaxed.
  • Until then: keep the mitigation; this advisory is mitigated, risk tolerable.

References

  • Advisory: GHSA-v3m3-f69x-jf25
  • Mitigation: server/services/agreement.service.ts, app/components/SanitizedHtml.tsx

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions