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 >= 0.6.0 and < 0.14.2.</li> <li>Run 'npm install odf-kit@^0.14.2' (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>
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.