Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 6 min read

CVE-2026-113787: CVE-2026-113787: Stored Cross-Site Scripting via Unescaped Style Attributes in odf-kit

CVE-2026-113787: Stored Cross-Site Scripting via Unescaped Style Attributes in odf-kit Vulnerability ID: CVE-2026-113787 CVSS Score: 6.1 Published: 2026-11-04 CVE-2026-113787 is a stored Cross-Site Scripting (XSS) a

CVE-2026-113787: Stored Cross-Site Scripting via Unescaped Style Attributes in odf-kit

Vulnerability ID: CVE-2026-113787
CVSS Score: 6.1
Published: 2026-11-04

CVE-2026-113787 is a stored Cross-Site Scripting (XSS) and HTML injection vulnerability affecting the odf-kit npm package from version 0.6.0 prior to 0.14.2. The HTML conversion engines (odtToHtml and odsToHtml) interpolate document-controlled styling and image attributes directly into inline style and src attributes without escaping double-quote characters. A remote attacker can supply a crafted .odt or .ods document to break out of HTML attribute boundaries and execute arbitrary JavaScript in the context of any web application rendering the converted HTML.

TL;DR

Versions 0.6.0 through 0.14.1 of odf-kit fail to escape double quotes when rendering inline CSS styles and image data URIs during ODT and ODS to HTML conversion, enabling stored XSS via crafted OpenDocument files.

⚠️ Exploit Status: POC

Technical Details

  • Vulnerability Type: CWE-79: Stored Cross-Site Scripting (XSS) / HTML Injection
  • Affected Component: odf-kit (odtToHtml and odsToHtml renderers)
  • Affected Versions: >= 0.6.0, < 0.14.2
  • Patched Version: 0.14.2
  • Attack Vector: Network (Crafted .odt or .ods file upload/conversion)
  • CVSS v3.1 Score: 6.1 Medium (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N)
  • EPSS Score: 0.246% (15.997th percentile)
  • Exploit Status: Proof-of-Concept Available (Repository test fixtures)

Affected Systems

  • odf-kit (npm package)
  • odf-kit: >= 0.6.0, < 0.14.2 (Fixed in: 0.14.2)

Code Analysis

Commit: fa3abc5

Wrap all style emissions and image data URI attributes with escapeAttr() in src/odt/to-html/html-renderer.ts

diff --git a/src/odt/to-html/html-renderer.ts b/src/odt/to-html/html-renderer.ts
index 4c111d0..aed253c 100644
--- a/src/odt/to-html/html-renderer.ts
+++ b/src/odt/to-html/html-renderer.ts
@@ -41,6 +41,8 @@
  * the inner body content, suitable for embedding in an existing page.
  */

+import { escapeAttr } from "../../core/index.js";
+
 import type {
   BodyNode,
   InlineNode,
@@ -194,7 +196,7 @@ function renderTextSpan(span: TextSpan): string {

   if (span.style !== undefined) {
     const css = spanStyleToCss(span.style);
-    if (css) html = `<span style="${css}">${html}</span>`;
+    if (css) html = `<span style="${escapeAttr(css)}">${html}</span>`;
   }

   if (span.href !== undefined) html = `<a href="${escapeHtml(span.href)}">${html}</a>`;
@@ -224,7 +226,7 @@ function renderImage(node: ImageNode): string {
   const attrs: string[] = [];

   if (node.data && node.mediaType) {
-    attrs.push(`src="data:${node.mediaType};base64,${node.data}"`);
+    attrs.push(`src="data:${escapeAttr(node.mediaType)};base64,${escapeAttr(node.data)}"`);
   }

   attrs.push(`alt="${escapeHtml(node.title ?? "")}"`);
@@ -243,7 +245,7 @@ function renderImage(node: ImageNode): string {
   }
   // "parallel" and "run-through" have no CSS equivalent; no float added.

-  if (styleParts.length > 0) attrs.push(`style="${styleParts.join(";")}"`);
+  if (styleParts.length > 0) attrs.push(`style="${escapeAttr(styleParts.join(";"))}"`);

   if (node.description !== undefined && node.name !== undefined) {
     const descId = `odf-img-${escapeHtml(node.name)}`;
@@ -366,7 +368,7 @@ function renderRow(
   headerCells: boolean,
 ): string {
   const rowCss = row.rowStyle !== undefined ? rowStyleToCss(row.rowStyle) : "";
-  const rowAttrs = rowCss ? ` style="${rowCss}"` : "";
+  const rowAttrs = rowCss ? ` style="${escapeAttr(rowCss)}"` : "";
   const tag = headerCells ? "th" : "td";

   const cells = row.cells
@@ -381,7 +383,7 @@ function renderRow(
       }
       if (cell.cellStyle !== undefined) {
         const css = cellStyleToCss(cell.cellStyle);
-        if (css) attrParts.push(`style="${css}"`);
+        if (css) attrParts.push(`style="${escapeAttr(css)}"`);
       }
       const attrs = attrParts.length > 0 ? " " + attrParts.join(" ") : "";
       return `<${tag}${attrs}>${renderCellContent(cell, options)}</${tag}>`;
@@ -411,7 +413,7 @@ function renderTable(table: TableNode, options?: HtmlOptions): string {
   for (const row of table.rows) {
     const cols = row.cells.map((cell) => {
       const cw = cell.cellStyle?.columnWidth;
-      return cw ? `<col style="width:${cw}">` : "<col>";
+      return cw ? `<col style="width:${escapeAttr(cw)}">` : "<col>";
     });
     // Only emit <colgroup> when at least one cell has an explicit width
     if (row.cells.some((cell) => cell.cellStyle?.columnWidth !== undefined)) {
@@ -512,15 +514,17 @@ function renderBodyNode(node: BodyNode, options?: HtmlOptions, inCell = false):
       const sourceCss =
         node.paragraphStyle !== undefined ? paragraphStyleToCss(node.paragraphStyle) : "";
       const css = [reset, sourceCss].filter((s) => s).join(";");
-      const attrs = (css ? ` style="${css}"` : "") + dirAttr(node.paragraphStyle?.writingMode);
+      const attrs =
+        (css ? ` style="${escapeAttr(css)}"` : "") + dirAttr(node.paragraphStyle?.writingMode);
       return `<p${attrs}>${renderSpans(node.spans, options)}</p>`;
     }
     case "heading": {
       const reset = inCell ? "margin-top:0;margin-bottom:0" : "";
       const sourceCss =
         node.paragraphStyle !== undefined ? paragraphStyleToCss(node.paragraphStyle) : "";
       const css = [reset, sourceCss].filter((s) => s).join(";");
-      const attrs = (css ? ` style="${css}"` : "") + dirAttr(node.paragraphStyle?.writingMode);
+      const attrs =
+        (css ? ` style="${escapeAttr(css)}"` : "") + dirAttr(node.paragraphStyle?.writingMode);
       return `<h${node.level}${attrs}>${renderSpans(node.spans, options)}</h${node.level}>`;
     }
     case "list":

Commit: 1424227

Apply escapeAttr() to buildInlineStyle and row.height in src/ods/to-html/html-renderer.ts

Commit: aa64c73

Export internal escapeAttr() helper from src/core/xml.ts and the core barrel

Exploit Details

  • GitHub: Official patch commit containing regression test cases and smoke fixtures (xss-attr-breakout-ghsa3cgg.odt and xss-markup-injection-ghsa3cgg.odt) demonstrating attribute and tag breakout.

Mitigation Strategies

  • Upgrade odf-kit to version 0.14.2 or later to ensure all inline style and data URI attributes are properly encoded via escapeAttr().
  • Sanitize all HTML output generated by odtToHtml() and odsToHtml() using a robust HTML sanitizer such as DOMPurify or sanitize-html prior to DOM insertion.
  • Isolate rendered document previews inside a sandboxed iframe () lacking allow-same-origin and allow-scripts permissions.</li> <li>Enforce a strict Content Security Policy (CSP) that blocks inline script execution and restricts external image/font fetches to prevent CSS-based exfiltration.</li> </ul> <p><strong>Remediation Steps:</strong></p> <ol> <li>Audit package.json and lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml) to identify any dependency on odf-kit versions &gt;= 0.6.0 and &lt; 0.14.2.</li> <li>Run &#39;npm install odf-kit@^0.14.2&#39; (or the equivalent command for your package manager) to update the package to the patched release.</li> <li>Review all call sites of odtToHtml() and odsToHtml() in your application codebase and wrap their output with DOMPurify.sanitize() before rendering.</li> <li>Verify the fix by running your test suite against sample ODT/ODS files containing double quotes in style properties.</li> </ol> <h2> <a name="references" href="#references" class="anchor"> </a> References </h2> <ul> <li><a href="https://github.com/GitHubNewbie0/odf-kit/security/advisories/GHSA-3cgg-c5pp-57h6">GitHub Security Advisory: GHSA-3cgg-c5pp-57h6</a></li> <li><a href="https://github.com/GitHubNewbie0/odf-kit/commit/fa3abc5a7a757f56c7212b2dbe99d1d2db995420">Primary Fix Commit (ODT HTML Renderer)</a></li> <li><a href="https://github.com/GitHubNewbie0/odf-kit/commit/1424227d1ca2716a249b623d1456575ff0331dcc">Secondary Fix Commit (ODS HTML Renderer)</a></li> <li><a href="https://github.com/GitHubNewbie0/odf-kit/commit/aa64c734ebd7e7be40e35fcda83b1144193c3da2">Core Escaper Export Commit</a></li> <li><a href="https://github.com/GitHubNewbie0/odf-kit/releases/tag/v0.14.2">odf-kit v0.14.2 Release Tag</a></li> <li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-113787">National Vulnerability Database (NVD): CVE-2026-113787</a></li> <li><a href="https://www.cve.org/CVERecord?id=CVE-2026-113787">CVE.org Official Record: CVE-2026-113787</a></li> <li><a href="https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/113xxx/CVE-2026-113787.json">CVEProject cvelistV5 Repository Record</a></li> </ul> <hr> <p><em><a href="https://cvereports.com/reports/CVE-2026-113787">Read the full report for CVE-2026-113787 on our website</a> for more details including interactive diagrams and full exploit analysis.</em></p>
πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.