Skip to content

Path traversal: a DOCX external image relationship `Target` reaches `_resolve_image_path` with no containment check, giving arbitrary local image read

Moderate
danielaskdd published GHSA-8rgj-chc2-6chv Sep 5, 2026

Package

pip lightrag-hku (pip)

Affected versions

<= 1.5.6

Patched versions

1.5.7

Description

Summary

The native DOCX parser copies an OOXML image relationship Target verbatim when TargetMode="External", and the multimodal analyze stage then treats that string as a filesystem path. _resolve_image_path (lightrag/pipeline.py:3699-3711) returns an absolute path unchanged, and joins a relative path to sidecar_dir without resolve() or is_relative_to(), so neither an absolute path nor ../../../ is contained. The value originates entirely inside the uploaded .docx, in word/_rels/document.xml.rels.

The attacker also selects the code path. resolve_file_parser_directives("quarterly.[native-i].docx") returns ('native', 'i') (lightrag/parser/routing.py:1320, parse_process_options at :216-236), so the parser engine and the image-analysis option are both chosen from the filename. No server-side configuration choice is needed for that hop.

Impact splits into two tiers, and the difference matters:

  • Tier 1 needs only the ability to upload a document. read_image_dimensions (lightrag/pipeline.py:3745) runs on the attacker's path before the VLM availability gate at :3755, and the three outcomes are reported with distinct messages, so an uploader gets an arbitrary-path existence oracle and, for raster targets, an image-dimension oracle. This works in the shipped default configuration with no environment change.
  • Tier 2 is full byte exfiltration of the target file to the configured model, through candidate.read_bytes() at :3762, base64 at :3782 and use_vlm_func(..., image_inputs=[img_payload]) at :3820. Tier 2 additionally requires VLM_PROCESS_ENABLE=true plus a vision-capable binding. That is not the shipped default (lightrag/lightrag.py:613-615 defaults vlm_process_enable to False), but it is the documented and intended configuration for multimodal DOCX ingestion, which is the reason the native DOCX parser emits drawings at all.

Existing input validation does not help. sanitize_filename (lightrag/api/routers/document_routes.py:2678, defined at :118) strips separators, NUL bytes and .. from the uploaded filename, and never inspects the interior of the package. The payload is inside the .docx.

Demonstrated impact: through the real LightRAG.apipeline_enqueue_documents plus apipeline_process_enqueue_documents, with a recording stub in the VLM role, the base64 blob handed to the model is byte-for-byte equal to a 226 byte file that lives outside working_dir and outside INPUT_DIR. In a second run, a tenant A sidecar read tenant B's extracted asset bytes, b'\x89PNG\r\n\x1a\nTENANT-B-CONFIDENTIAL', defeating the workspace input-directory isolation built at document_routes.py:1014-1017.

Severity / CVSS

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Score: 7.5 (High), scored for Tier 2 in the shipped default authentication posture.

Justification of the metrics that are not self-evident:

  • PR:N because LightRAG's default configuration has no authentication, so POST /documents/upload is reachable unauthenticated. With AUTH_ACCOUNTS or LIGHTRAG_API_KEY configured, the same attack is PR:L (any ordinary document-uploading user, no admin rights), which scores 6.5 (Medium). The multi-tenant cross-workspace read below is the PR:L case and is the more interesting one.
  • AC:L because the payload is a static crafted .docx with no race, no guessing and no per-target tuning. The engine and image option are encoded in the filename.
  • UI:N because the attacker uploads the document, so no second party acts.
  • S:U because the read stays within the privileges of the server process. It crosses the application's own workspace boundary, not an OS privilege boundary.
  • C:H because the disclosed content is a file of the attacker's choosing from the server filesystem, including other tenants' extracted document images. I:N and A:N because nothing is written or degraded.

Preconditions stated plainly, because they bound who is affected:

  1. Ability to POST /documents/upload, or to drop a file into INPUT_DIR and trigger POST /documents/scan.
  2. For Tier 2 only: VLM_PROCESS_ENABLE=true and a vision-capable binding (VLM_LLM_BINDING or LLM_BINDING in openai, azure_openai, gemini, bedrock, ollama). Not the shipped default.

Tier 1 alone, with no environment change from the shipped default, is CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N, 5.3 (Medium).

CWE-22 (improper limitation of a pathname to a restricted directory), CWE-610 (externally controlled reference to a resource in another sphere), CWE-200 (exposure of sensitive information to an unauthorized actor).

Affected component

  • lightrag/pipeline.py:3699-3711 _resolve_image_path, the sink: absolute paths returned unchanged, relative paths joined to sidecar_dir with no resolve() and no is_relative_to()
  • lightrag/pipeline.py:3730-3733 _analyze_drawing reads item["path"] straight from the sidecar and hands it to _resolve_image_path
  • lightrag/pipeline.py:3739-3744 the only restriction on the target, an extension gate against _VLM_RASTER_EXTS (:3544, {.png, .jpg, .jpeg, .gif, .webp})
  • lightrag/pipeline.py:3745-3754 read_image_dimensions(candidate) and the min_image_pixel comparison, both executed on the attacker's path
  • lightrag/pipeline.py:3755-3760 the VLM availability gate, which runs after the two probes above
  • lightrag/pipeline.py:3762 candidate.read_bytes(), :3782 base64 encoding, :3785 source_file, :3820-3826 the VLM call carrying image_inputs
  • lightrag/parser/docx/drawing_image_extractor.py:222-255 load_relationships, which records Target and TargetMode verbatim from word/_rels/document.xml.rels
  • lightrag/parser/docx/drawing_image_extractor.py:336-338 the DrawingML branch: elif rel_kind == "link": ... attrs["path"] = rel.target, reached from <a:blip r:link="..."/>. This is the branch the end-to-end proof of concept below exercises
  • lightrag/parser/docx/drawing_image_extractor.py:381-383 the VML sibling branch: if rel.target_mode.lower() == "external": ... attrs["path"] = rel.target, reached from v:imagedata
  • lightrag/parser/docx/drawing_image_extractor.py:86-87 and :234 the two places that already refuse to treat an External relationship as package content
  • lightrag/parser/docx/ir_builder.py:229-233,244 the value becomes IRDrawing.path_override, documented at :203-206 as passed through verbatim
  • lightrag/sidecar/writer.py:525-527 and :629-630 path_override written verbatim as the path field of <doc>.drawings.json
  • lightrag/parser/routing.py:1320 resolve_file_parser_directives and :216-236 parse_process_options, which decode the engine and the i flag from the filename
  • lightrag/pipeline.py:3409 and :4128 where the i flag becomes process_opts.images and selects the drawings sidecar
  • lightrag/api/routers/document_routes.py:2585-2589 POST /documents/upload, the entrypoint
  • lightrag/api/routers/document_routes.py:2678 sanitize_filename(file.filename, doc_manager.input_dir), which inspects the filename only
  • lightrag/api/routers/document_routes.py:1014-1017 DocumentManager.__init__, the per-workspace input directory that the relative-target variant reads across
  • lightrag/lightrag.py:613-615 vlm_process_enable defaults to False

Version tested: 1.5.5, api version 0316, commit 79b542714732a0396ae2652bc93f93902fd208b2, working tree clean. Every line reference above was re-read at that commit.

Root cause

Three facts combine.

  1. The path is copied verbatim out of attacker-controlled XML and carried untouched to the sink. load_relationships stores Target and TargetMode exactly as they appear in word/_rels/document.xml.rels (drawing_image_extractor.py:224-225,247-255). For a linked image the target becomes the path attribute of the <drawing> placeholder (:336-338 for DrawingML, :381-383 for VML). ir_builder.py:229-233 promotes it to IRDrawing.path_override, with the comment at :203-206 stating the intent, "pass through verbatim". writer.py:525-527 and :629-630 then write it as the path field of <doc>.drawings.json under the comment "Verbatim external/linked reference, pass through unchanged". Each of those steps is individually deliberate. Nothing in the chain validates, and nothing downstream expects to have to.

  2. The resolver applies no containment. _resolve_image_path returns an absolute path as given, and for a relative path computes sidecar_dir / path_str and accepts it if it exists, without calling resolve() and without comparing the result against sidecar_dir. ../../../ therefore escapes the sidecar directory, the workspace input directory, and working_dir.

  3. The trust decision that would prevent this already exists in the same extractor and is not honoured at the sink. export_embedded_image returns None for an External relationship (drawing_image_extractor.py:86-87), and load_relationships refuses to resolve a part name for one (:234). The extractor's position is that an external relationship has no bytes inside the package, which is correct by definition. The path resolver in pipeline.py reverses that position and opens the value as a local file.

The containment check that is missing is already implemented in this codebase, twice, in the file that guards the same upload. sanitize_filename computes (input_dir / clean_name).resolve() and rejects the result unless final_path.is_relative_to(input_dir.resolve()) (document_routes.py:154-156), and validate_file_path_security does the same at :1105-1146. The parser and analyze stages use neither.

The result also removes the value of the one control that was applied to this upload. sanitize_filename runs on file.filename and is effective there (a battery of hostile filenames including backslashes, NUL, overlong and unicode encodings, ....// and absolute paths was run against it and all were neutralized). It is simply the wrong layer for this payload, which travels inside the archive.

Impact

  • Full byte exfiltration of a chosen local file to the configured model (Tier 2). Verified end to end against real apipeline_enqueue_documents plus apipeline_process_enqueue_documents with a recording stub in the VLM role: 2 VLM invocations carried images, both with source_file = /tmp/lr-audit/fsaudit/victim_home/id_rsa_screenshot.png, and EXFIL MATCHES VICTIM FILE: True for both. That flag is byte-for-byte equality between the base64 blob handed to the model and a 226 byte file outside working_dir and outside INPUT_DIR.
  • Cross-tenant read, defeating the workspace input-directory isolation. With sidecar_dir at /tmp/lr-audit/fsaudit/ws/tenantA/__parsed__/attack.docx.parsed and a relationship Target of ../../../tenantB/__parsed__/victim.docx.parsed/victim.blocks.assets/image1.png, the resolver returned a path outside tenant A and the bytes read were b'\x89PNG\r\n\x1a\nTENANT-B-CONFIDENTIAL'. The workspace scoping at document_routes.py:1014-1017 is correct as a parameter check and is bypassed from inside a document rather than through that parameter. Every other tenant's extracted document images are readable, and those are exactly the files this feature exists to produce.
  • Arbitrary-path existence oracle with no configuration change at all (Tier 1). The order of operations is the whole point: the extension gate at :3739-3744 and read_image_dimensions at :3745 both run before the vlm_process_enable check at :3755. A path that does not exist yields image file not found: <path> from :3736; a path that exists but is not raster-suffixed yields unsupported image format: <ext> from :3742. Those two messages differ, so the existence of any path, image or not, is distinguishable. For a raster-suffixed target that exists, read_image_dimensions runs and a small image yields image width or height is smaller than 64px (:3751, DEFAULT_MM_IMAGE_MIN_PIXEL = 64 at constants.py:385), which is a dimension oracle. The messages are persisted into the sidecar: in the run below, quarterly.drawings.json carries llm_analyze_result.message for the item. [INFERENCE] The same strings also reach doc_status; only the sidecar surfacing was directly observed.
  • The attacker selects the parser engine and the image option, so no operator opt-in is needed to reach the resolver. resolve_file_parser_directives('quarterly.[native-i].docx') returns ('native', 'i'), confirmed by direct invocation at this commit. native routes to the DOCX drawing extractor; i sets process_opts.images (pipeline.py:3409), which selects the drawings sidecar for analysis (:4128). Both come from the filename.
  • Existing filename validation is not a mitigation. sanitize_filename at document_routes.py:2678 never opens the archive. Nothing between the upload handler and Path.read_bytes inspects the relationship target.
  • The model's description of the stolen image is persisted and retrievable. [INFERENCE] Analysis results are written back into the sidecar and indexed, so a successful analysis makes the content of the stolen file retrievable through /query. Not directly observed: in the run below the stub returned a payload that failed the response schema check, so llm_analyze_result.status was failure and no description was indexed. The exfiltration to the model itself is not inferred, it is the measured EXFIL MATCHES VICTIM FILE: True.

Limitations

  • The extension gate restricts the byte read to targets whose path ends in .png, .jpg, .jpeg, .gif or .webp. It is therefore arbitrary image-suffixed file read, not arbitrary file read. The gate tests the suffix of the attacker-supplied path, not the file's content, and the min_image_pixel comparison is skipped when read_image_dimensions returns None (:3746), so a non-image file that happens to end in .png is still read in full.
  • max_image_bytes (:3516-3519, default 5 MiB) caps the exfiltrated size; a larger target is reported as skipped rather than sent.
  • The existence oracle in Tier 1 is not limited by extension, because the not-found and unsupported-format messages are distinct.

Proof of Concept

The crafted package

quarterly.[native-i].docx, an otherwise ordinary DOCX. The filename hint selects engine native and process option i. word/_rels/document.xml.rels:

<Relationship Id="rId7"
  Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
  Target="/home/victim/private/id_rsa_screenshot.png" TargetMode="External"/>

word/document.xml references it as a linked image inside a wp:inline drawing:

<a:blip r:link="rId7"/>

Delivery is a single unauthenticated upload in the default configuration:

curl -F 'file=@quarterly.[native-i].docx' http://target:9621/documents/upload

The cross-tenant variant differs only in the target:

Target="../../../tenantB/__parsed__/victim.docx.parsed/victim.blocks.assets/image1.png"
TargetMode="External"

(a) End to end through the real pipeline

poc_e2e_exfil.py builds the package, calls the real LightRAG.apipeline_enqueue_documents and apipeline_process_enqueue_documents, and installs a stub in the VLM role that records exactly what it is handed.

[+] victim secret at /tmp/lr-audit/fsaudit/victim_home/id_rsa_screenshot.png (226 bytes)
[+] uploaded file: quarterly.[native-i].docx
[+] routing from filename -> engine='native' process_options='i'
INFO: Parsing (native): doc-554516a1c97159cf7952570f36de3c81
INFO: [sidecar] wrote 1 blocks ... (0 tables, 1 drawings, 0 equations, assets=False, engine=native)
INFO: Analyzing multimodal: doc-554516a1c97159cf7952570f36de3c81

[+] input/__parsed__/quarterly.docx.parsed/quarterly.drawings.json:
{
  "drawings": {
    "im-554516a1c97159cf7952570f36de3c81-0001": {
      "format": "png",
      "path": "/tmp/lr-audit/fsaudit/victim_home/id_rsa_screenshot.png",
      ...

[+] VLM invocations carrying images: 2
    source_file = /tmp/lr-audit/fsaudit/victim_home/id_rsa_screenshot.png
    EXFIL MATCHES VICTIM FILE: True

EXFIL MATCHES VICTIM FILE: True is byte-for-byte equality between the base64 blob handed to the VLM and the contents of a file that lives outside working_dir and outside INPUT_DIR. The path field of the generated sidecar holds the attacker's absolute path verbatim, which is the parser half of the chain confirmed on disk.

(b) The sink, compiled out of the repository source

verify_sink_source.py reads lightrag/pipeline.py, slices lines 3699 to 3711, and compiles that text. Nothing is retyped, so the function under test is the shipped one.

---- verbatim source lines 3699-3711 of lightrag/pipeline.py ----
def _resolve_image_path(
    path_str: str | None, sidecar_dir: Path
) -> Path | None:
    if not path_str:
        return None
    candidate = Path(path_str)
    if not candidate.is_absolute():
        sidecar_candidate = sidecar_dir / path_str
        if sidecar_candidate.exists() and sidecar_candidate.is_file():
            candidate = sidecar_candidate
    if candidate.exists() and candidate.is_file():
        return candidate
    return None
-----------------------------------------------------------------
'/tmp/lr-audit/fsaudit/secret_outside_root.png'  -> /tmp/lr-audit/fsaudit/secret_outside_root.png
'../../../OUTSIDE/private_screenshot.png'        -> .../report.parsed/../../../OUTSIDE/private_screenshot.png
'/etc/hostname'                                  -> /etc/hostname

Three results in one block: an absolute path outside the tree is returned unchanged, a relative ../../../ target escapes the sidecar directory, and /etc/hostname is accepted as a resolved image path. /etc/hostname is returned by the resolver and is then rejected 29 lines later by the extension gate at :3740, which is precisely why the existence oracle in Tier 1 is not limited to images while the byte read is.

(c) Cross-workspace read

poc_cross_workspace.py, same verbatim sink, two workspace directories laid out as DocumentManager lays them out.

sidecar_dir (tenant A): /tmp/lr-audit/fsaudit/ws/tenantA/__parsed__/attack.docx.parsed
relationship Target   : ../../../tenantB/__parsed__/victim.docx.parsed/victim.blocks.assets/image1.png
resolved outside A    : True
bytes read            : b'\x89PNG\r\n\x1a\nTENANT-B-CONFIDENTIAL'

The marker bytes come from a file only tenant B uploaded, in a directory document_routes.py:1014-1017 scopes to tenant B.

All three results were reproduced at commit 79b54271 while preparing this report.

Recommended fix

  1. Do not treat an External relationship target as a filesystem path. At lightrag/parser/docx/drawing_image_extractor.py:381-383 (VML) and :336-338 (DrawingML, the branch keyed on rel_kind == "link"), stop assigning rel.target to attrs["path"]. A linked image has no bytes inside the package by definition, which is the position the same file already takes twice: export_embedded_image returns None for External at :86-87, and load_relationships declines to resolve a part name for External at :234. Honouring that same decision in the path resolver is consistent with the extractor's existing intent rather than a new policy. If the link is worth keeping for display, carry it in a distinct src field that no consumer opens, which the IR already has (IRDrawing.src, ir_builder.py:243), and leave path empty.

  2. Enforce containment at the sink. In _resolve_image_path (lightrag/pipeline.py:3699-3711), reject absolute paths outright and resolve before use:

    candidate = (sidecar_dir / path_str).resolve()
    if not candidate.is_relative_to(sidecar_dir.resolve()):
        return None

    This is the resolve() plus is_relative_to() pattern already implemented in this codebase for the same upload, in sanitize_filename (document_routes.py:154-156) and in validate_file_path_security (:1105-1146). Reusing it keeps one idiom rather than adding a second.

  3. Defend the sidecar consumer independently of the producer. The path field of <doc>.drawings.json is attacker-influenced data at rest. _analyze_drawing (pipeline.py:3730-3733) should treat it as such even after fix 1 lands, so that a sidecar written by an older version, by an external engine, or by any future producer cannot reach read_bytes outside its own directory. Fix 2 is what makes this true.

  4. Move the availability gate ahead of the probes. The vlm_process_enable check at :3755 runs after read_image_dimensions at :3745. Hoisting it above the resolve-and-probe block removes the Tier 1 oracle from deployments that never enabled VLM analysis, and costs nothing, since those deployments cannot produce an analysis anyway.

  5. Do not echo the resolved path back to callers. The skipped and failure messages at :3736, :3742, :3751 and :3765 interpolate the attacker's path or the resolved candidate. Once fixes 1 and 2 land these are harmless; while the oracle exists they are the readout channel.

Fixes 1 and 2 are the defect, and either alone closes the byte read. Fixes 3 to 5 are defence in depth and remove the oracle.

References

  • Version tested: 1.5.5, api version 0316, commit 79b542714732a0396ae2652bc93f93902fd208b2
  • Sink: lightrag/pipeline.py:3699-3711, consumed at :3730-3733, :3739-3744, :3745, :3755, :3762, :3782, :3820
  • Source of the untrusted value: lightrag/parser/docx/drawing_image_extractor.py:222-255, :336-338, :381-383; the existing External refusals at :86-87 and :234
  • Pass-through: lightrag/parser/docx/ir_builder.py:203-206, :229-233, :244; lightrag/sidecar/writer.py:525-527, :629-630
  • Attacker-selected routing: lightrag/parser/routing.py:1320, :216-236; lightrag/pipeline.py:3409, :4128
  • Entrypoint and the validation that does not cover this: lightrag/api/routers/document_routes.py:2585-2589, :2678, :118
  • Isolation crossed: lightrag/api/routers/document_routes.py:1014-1017
  • Containment precedent to reuse: lightrag/api/routers/document_routes.py:154-156, :1105-1146
  • Tier 2 precondition: lightrag/lightrag.py:613-615
  • No existing advisory for this project touches the parser, sidecar or multimodal-analysis code. This report is the first in lightrag/parser/docx/, lightrag/sidecar/ and the analyze_multimodal path of lightrag/pipeline.py.
  • Distinct from GHSA-vv3m-f8x4-7377: that advisory is in lightrag/parser/markdown/, its sink is an outbound HTTP fetch, and its root cause is an incomplete IP-address blocklist (IPv6-transition addresses). This report is in lightrag/parser/docx/, the sink is a local Path.read_bytes at pipeline.py:3762, and the root cause is a missing path-containment check on a value copied verbatim out of an OOXML relationship. Different file, different sink class (local filesystem read against network egress), different fix. Correcting that blocklist has no effect here.
  • Also distinct from GHSA-32jh-39m7-8x84 (ReDoS in the semantic chunker), GHSA-mmg5-8x8q-v934 (the /api/* authentication exemption) and GHSA-xpjq-3w4w-w5wr (the WebUI mermaid sink): different components, and none of them involves DOCX relationship parsing or image path resolution.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

CVE ID

No known CVE

Weaknesses

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. Learn more on MITRE.

Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information. Learn more on MITRE.

Externally Controlled Reference to a Resource in Another Sphere

The product uses an externally controlled name or reference that resolves to a resource that is outside of the intended control sphere. Learn more on MITRE.

Credits