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:
- Ability to
POST /documents/upload, or to drop a file into INPUT_DIR and trigger POST /documents/scan.
- 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.
-
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.
-
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.
-
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:
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
-
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.
-
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.
-
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.
-
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.
-
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.
Summary
The native DOCX parser copies an OOXML image relationship
Targetverbatim whenTargetMode="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 tosidecar_dirwithoutresolve()oris_relative_to(), so neither an absolute path nor../../../is contained. The value originates entirely inside the uploaded.docx, inword/_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_optionsat: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:
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.candidate.read_bytes()at:3762, base64 at:3782anduse_vlm_func(..., image_inputs=[img_payload])at:3820. Tier 2 additionally requiresVLM_PROCESS_ENABLE=trueplus a vision-capable binding. That is not the shipped default (lightrag/lightrag.py:613-615defaultsvlm_process_enabletoFalse), 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_documentsplusapipeline_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 outsideworking_dirand outsideINPUT_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 atdocument_routes.py:1014-1017.Severity / CVSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:NScore: 7.5 (High), scored for Tier 2 in the shipped default authentication posture.
Justification of the metrics that are not self-evident:
PR:Nbecause LightRAG's default configuration has no authentication, soPOST /documents/uploadis reachable unauthenticated. WithAUTH_ACCOUNTSorLIGHTRAG_API_KEYconfigured, the same attack isPR:L(any ordinary document-uploading user, no admin rights), which scores 6.5 (Medium). The multi-tenant cross-workspace read below is thePR:Lcase and is the more interesting one.AC:Lbecause the payload is a static crafted.docxwith no race, no guessing and no per-target tuning. The engine and image option are encoded in the filename.UI:Nbecause the attacker uploads the document, so no second party acts.S:Ubecause the read stays within the privileges of the server process. It crosses the application's own workspace boundary, not an OS privilege boundary.C:Hbecause the disclosed content is a file of the attacker's choosing from the server filesystem, including other tenants' extracted document images.I:NandA:Nbecause nothing is written or degraded.Preconditions stated plainly, because they bound who is affected:
POST /documents/upload, or to drop a file intoINPUT_DIRand triggerPOST /documents/scan.VLM_PROCESS_ENABLE=trueand a vision-capable binding (VLM_LLM_BINDINGorLLM_BINDINGin 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 tosidecar_dirwith noresolve()and nois_relative_to()lightrag/pipeline.py:3730-3733_analyze_drawingreadsitem["path"]straight from the sidecar and hands it to_resolve_image_pathlightrag/pipeline.py:3739-3744the only restriction on the target, an extension gate against_VLM_RASTER_EXTS(:3544,{.png, .jpg, .jpeg, .gif, .webp})lightrag/pipeline.py:3745-3754read_image_dimensions(candidate)and themin_image_pixelcomparison, both executed on the attacker's pathlightrag/pipeline.py:3755-3760the VLM availability gate, which runs after the two probes abovelightrag/pipeline.py:3762candidate.read_bytes(),:3782base64 encoding,:3785source_file,:3820-3826the VLM call carryingimage_inputslightrag/parser/docx/drawing_image_extractor.py:222-255load_relationships, which recordsTargetandTargetModeverbatim fromword/_rels/document.xml.relslightrag/parser/docx/drawing_image_extractor.py:336-338the 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 exerciseslightrag/parser/docx/drawing_image_extractor.py:381-383the VML sibling branch:if rel.target_mode.lower() == "external": ... attrs["path"] = rel.target, reached fromv:imagedatalightrag/parser/docx/drawing_image_extractor.py:86-87and:234the two places that already refuse to treat anExternalrelationship as package contentlightrag/parser/docx/ir_builder.py:229-233,244the value becomesIRDrawing.path_override, documented at:203-206as passed through verbatimlightrag/sidecar/writer.py:525-527and:629-630path_overridewritten verbatim as thepathfield of<doc>.drawings.jsonlightrag/parser/routing.py:1320resolve_file_parser_directivesand:216-236parse_process_options, which decode the engine and theiflag from the filenamelightrag/pipeline.py:3409and:4128where theiflag becomesprocess_opts.imagesand selects the drawings sidecarlightrag/api/routers/document_routes.py:2585-2589POST /documents/upload, the entrypointlightrag/api/routers/document_routes.py:2678sanitize_filename(file.filename, doc_manager.input_dir), which inspects the filename onlylightrag/api/routers/document_routes.py:1014-1017DocumentManager.__init__, the per-workspace input directory that the relative-target variant reads acrosslightrag/lightrag.py:613-615vlm_process_enabledefaults toFalseVersion 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.
The path is copied verbatim out of attacker-controlled XML and carried untouched to the sink.
load_relationshipsstoresTargetandTargetModeexactly as they appear inword/_rels/document.xml.rels(drawing_image_extractor.py:224-225,247-255). For a linked image the target becomes thepathattribute of the<drawing>placeholder (:336-338for DrawingML,:381-383for VML).ir_builder.py:229-233promotes it toIRDrawing.path_override, with the comment at:203-206stating the intent, "pass through verbatim".writer.py:525-527and:629-630then write it as thepathfield of<doc>.drawings.jsonunder 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.The resolver applies no containment.
_resolve_image_pathreturns an absolute path as given, and for a relative path computessidecar_dir / path_strand accepts it if it exists, without callingresolve()and without comparing the result againstsidecar_dir.../../../therefore escapes the sidecar directory, the workspace input directory, andworking_dir.The trust decision that would prevent this already exists in the same extractor and is not honoured at the sink.
export_embedded_imagereturnsNonefor anExternalrelationship (drawing_image_extractor.py:86-87), andload_relationshipsrefuses 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 inpipeline.pyreverses 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_filenamecomputes(input_dir / clean_name).resolve()and rejects the result unlessfinal_path.is_relative_to(input_dir.resolve())(document_routes.py:154-156), andvalidate_file_path_securitydoes 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_filenameruns onfile.filenameand 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
apipeline_enqueue_documentsplusapipeline_process_enqueue_documentswith a recording stub in the VLM role: 2 VLM invocations carried images, both withsource_file = /tmp/lr-audit/fsaudit/victim_home/id_rsa_screenshot.png, andEXFIL MATCHES VICTIM FILE: Truefor both. That flag is byte-for-byte equality between the base64 blob handed to the model and a 226 byte file outsideworking_dirand outsideINPUT_DIR.sidecar_dirat/tmp/lr-audit/fsaudit/ws/tenantA/__parsed__/attack.docx.parsedand a relationshipTargetof../../../tenantB/__parsed__/victim.docx.parsed/victim.blocks.assets/image1.png, the resolver returned a path outside tenant A and the bytes read wereb'\x89PNG\r\n\x1a\nTENANT-B-CONFIDENTIAL'. The workspace scoping atdocument_routes.py:1014-1017is 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.:3739-3744andread_image_dimensionsat:3745both run before thevlm_process_enablecheck at:3755. A path that does not exist yieldsimage file not found: <path>from:3736; a path that exists but is not raster-suffixed yieldsunsupported 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_dimensionsruns and a small image yieldsimage width or height is smaller than 64px(:3751,DEFAULT_MM_IMAGE_MIN_PIXEL = 64atconstants.py:385), which is a dimension oracle. The messages are persisted into the sidecar: in the run below,quarterly.drawings.jsoncarriesllm_analyze_result.messagefor the item. [INFERENCE] The same strings also reachdoc_status; only the sidecar surfacing was directly observed.resolve_file_parser_directives('quarterly.[native-i].docx')returns('native', 'i'), confirmed by direct invocation at this commit.nativeroutes to the DOCX drawing extractor;isetsprocess_opts.images(pipeline.py:3409), which selects the drawings sidecar for analysis (:4128). Both come from the filename.sanitize_filenameatdocument_routes.py:2678never opens the archive. Nothing between the upload handler andPath.read_bytesinspects the relationship target./query. Not directly observed: in the run below the stub returned a payload that failed the response schema check, sollm_analyze_result.statuswasfailureand no description was indexed. The exfiltration to the model itself is not inferred, it is the measuredEXFIL MATCHES VICTIM FILE: True.Limitations
.png,.jpg,.jpeg,.gifor.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 themin_image_pixelcomparison is skipped whenread_image_dimensionsreturnsNone(:3746), so a non-image file that happens to end in.pngis 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.Proof of Concept
The crafted package
quarterly.[native-i].docx, an otherwise ordinary DOCX. The filename hint selects enginenativeand process optioni.word/_rels/document.xml.rels:word/document.xmlreferences it as a linked image inside awp:inlinedrawing:Delivery is a single unauthenticated upload in the default configuration:
curl -F 'file=@quarterly.[native-i].docx' http://target:9621/documents/uploadThe cross-tenant variant differs only in the target:
(a) End to end through the real pipeline
poc_e2e_exfil.pybuilds the package, calls the realLightRAG.apipeline_enqueue_documentsandapipeline_process_enqueue_documents, and installs a stub in the VLM role that records exactly what it is handed.EXFIL MATCHES VICTIM FILE: Trueis byte-for-byte equality between the base64 blob handed to the VLM and the contents of a file that lives outsideworking_dirand outsideINPUT_DIR. Thepathfield 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.pyreadslightrag/pipeline.py, slices lines 3699 to 3711, and compiles that text. Nothing is retyped, so the function under test is the shipped one.Three results in one block: an absolute path outside the tree is returned unchanged, a relative
../../../target escapes the sidecar directory, and/etc/hostnameis accepted as a resolved image path./etc/hostnameis 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 asDocumentManagerlays them out.The marker bytes come from a file only tenant B uploaded, in a directory
document_routes.py:1014-1017scopes to tenant B.All three results were reproduced at commit
79b54271while preparing this report.Recommended fix
Do not treat an
Externalrelationship target as a filesystem path. Atlightrag/parser/docx/drawing_image_extractor.py:381-383(VML) and:336-338(DrawingML, the branch keyed onrel_kind == "link"), stop assigningrel.targettoattrs["path"]. A linked image has no bytes inside the package by definition, which is the position the same file already takes twice:export_embedded_imagereturnsNoneforExternalat:86-87, andload_relationshipsdeclines to resolve a part name forExternalat: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 distinctsrcfield that no consumer opens, which the IR already has (IRDrawing.src,ir_builder.py:243), and leavepathempty.Enforce containment at the sink. In
_resolve_image_path(lightrag/pipeline.py:3699-3711), reject absolute paths outright and resolve before use:This is the
resolve()plusis_relative_to()pattern already implemented in this codebase for the same upload, insanitize_filename(document_routes.py:154-156) and invalidate_file_path_security(:1105-1146). Reusing it keeps one idiom rather than adding a second.Defend the sidecar consumer independently of the producer. The
pathfield of<doc>.drawings.jsonis 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 reachread_bytesoutside its own directory. Fix 2 is what makes this true.Move the availability gate ahead of the probes. The
vlm_process_enablecheck at:3755runs afterread_image_dimensionsat: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.Do not echo the resolved path back to callers. The skipped and failure messages at
:3736,:3742,:3751and:3765interpolate 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
79b542714732a0396ae2652bc93f93902fd208b2lightrag/pipeline.py:3699-3711, consumed at:3730-3733,:3739-3744,:3745,:3755,:3762,:3782,:3820lightrag/parser/docx/drawing_image_extractor.py:222-255,:336-338,:381-383; the existingExternalrefusals at:86-87and:234lightrag/parser/docx/ir_builder.py:203-206,:229-233,:244;lightrag/sidecar/writer.py:525-527,:629-630lightrag/parser/routing.py:1320,:216-236;lightrag/pipeline.py:3409,:4128lightrag/api/routers/document_routes.py:2585-2589,:2678,:118lightrag/api/routers/document_routes.py:1014-1017lightrag/api/routers/document_routes.py:154-156,:1105-1146lightrag/lightrag.py:613-615lightrag/parser/docx/,lightrag/sidecar/and theanalyze_multimodalpath oflightrag/pipeline.py.GHSA-vv3m-f8x4-7377: that advisory is inlightrag/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 inlightrag/parser/docx/, the sink is a localPath.read_bytesatpipeline.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.GHSA-32jh-39m7-8x84(ReDoS in the semantic chunker),GHSA-mmg5-8x8q-v934(the/api/*authentication exemption) andGHSA-xpjq-3w4w-w5wr(the WebUI mermaid sink): different components, and none of them involves DOCX relationship parsing or image path resolution.