## Summary
LightRAG's native markdown parser downloads external images referenced by an uploaded markdown or textpack document. The only SSRF guard, `_validated_addresses()` in `lightrag/parser/markdown/parser.py`, resolves the image host and rejects it when the resolved IP is not `is_global`. That check is evaluated on the raw resolved address and never decodes IPv6 transition wrappers that embed an internal IPv4. Python's `ipaddress` classifies a NAT64 (`64:ff9b::/96` and the RFC 8215 `64:ff9b:1::/48` prefix), IPv4-compatible (`::a.b.c.d`), or 6to4 (`2002::/16`) literal that wraps an internal IPv4 as globally routable, so the guard passes it. On a host with NAT64/DNS64 routing the request is then delivered to the embedded internal target (loopback, RFC1918, or a cloud metadata endpoint), and the fetched body is ingested. The plain and IPv4-mapped (`::ffff:`) forms of the same internal address are correctly blocked, so this is an encoding that defeats the existing filter.
## Affected component and versions
- Package: `lightrag-hku` (LightRAG), the native markdown image-download path.
- Component: `lightrag/parser/markdown/parser.py`, guard `_validated_addresses()` (the `if not (ip.is_global or ...)` check), reached from `_download()` -> `_build_guarded_opener().open(req)`.
- Enabled by default: `download_enabled = _env_bool("NATIVE_MD_IMAGE_DOWNLOAD_ENABLED", True)`.
- Affected: LightRAG `<= 1.5.4` (latest release at time of report, commit `9a45b64`).
- Precondition: the caller can upload a document (API key via `Depends(combined_auth)`); the host has NAT64/DNS64 routing for the encoded address to reach the internal endpoint.
## Vulnerable code vs the guarded sibling
Evaluated on the exact resolved addresses (CPython `ipaddress`):
| Form | address | is_global | guard verdict |
|---|---|---|---|
| plain internal 127.0.0.1 | `127.0.0.1` | False | BLOCK (correct) |
| IPv4-mapped | `::ffff:7f00:1` | False | BLOCK (correct) |
| NAT64 64:ff9b::/96 | `64:ff9b::7f00:1` | True | PASS (bypass) |
| NAT64 RFC8215 64:ff9b:1::/48 | `64:ff9b:1::7f00:1` | True | PASS (bypass) |
| IPv4-compatible ::a.b.c.d | `::7f00:1` | True | PASS (bypass) |
| 6to4 2002::/16 | `2002:7f00:1::` | True | PASS (bypass) |
The guard already blocks the plain and IPv4-mapped internal forms (the forms the author tested), proving the intent is to reject internal destinations; it never canonicalizes the other transition wrappers, so they pass.
> **Note — CPython version dependency of the table.** The `is_global` verdicts above depend on the interpreter. CPython gh-113171 (backported to 3.10.14 / 3.11.9 / 3.12.3+, ~2024-04) added `64:ff9b:1::/48` and `2002::/16` to `ipaddress._private_networks`, so on any current patch release those two rows return `is_global = False` and are already blocked by the stdlib before the guard is reached. The **`64:ff9b::/96` well-known NAT64 prefix and the IPv4-compatible `::/96` form bypass on every current CPython** — and `64:ff9b::/96` is exactly the prefix real DNS64/NAT64 deployments use, so the vulnerability stands. The four-row table reflects a pre-gh-113171 interpreter. The fix classifies by the embedded IPv4 and therefore closes all forms regardless of interpreter version.
## Severity
High. CWE-918 (Server-Side Request Forgery). CVSS 3.1 vector `AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:L/A:N`, base score 7.1. AC:H reflects the NAT64/DNS64 network precondition for end-to-end reach; PR:L because document upload requires the API key; S:C because the request pivots into an internal network segment; C:H for internal/metadata/secret disclosure.
## Proof of concept (deployed, both directions, verbatim)
Environment: three Docker containers on an IPv6-enabled network - an internal victim (`10.66.0.2:80`, RFC1918) serving a unique secret, a DNS64/NAT64 gateway owning `64:ff9b::/96` that forwards `[64:ff9b::0a42:0002]:80` to `10.66.0.2:80`, and an attacker host with a client-side route `64:ff9b::/96 via <gateway>`. This models a client on an IPv6-only / NAT64+DNS64 network (AWS/GCP IPv6-only subnets, many mobile and corporate networks). The attacker runs the EXACT released guard code (`_validated_addresses`, `_host_is_public`, `_build_guarded_opener`, the `_Guarded*Connection/Handler` classes, sliced verbatim from v1.5.4 `parser.py`, only the `logger` import shimmed) plus a verbatim reproduction of the released `_download()` scheme and host precheck. Trigger in production: an uploaded document containing ``.
RUN 1, released v1.5.4 guard (VULNERABLE):
```
attack URL = http://[64:ff9b::0a42:0002]:80/logo.png
===== DIRECTION A: ATTACK (NAT64-wrapped internal address) =====
guard verdict : PASSED (fetched)
SECRET LEAKED : True
body : SSRF-LIGHTRAG-INTERNAL-METADATA-9f3a2b7c
===== control: plain internal address (must be BLOCKED) =====
verdict: BLOCKED at guard: non-public host blocked
===== DIRECTION B: PUBLIC address (must still be FETCHED) =====
guard verdict : PASSED (fetched)
public body : PUBLIC-OK-BODY
===== SUMMARY =====
attack_leaked_internal_secret = True
plain_internal_blocked = True
public_still_fetched = True
```
RUN 2, one-line-class decode fix applied (same inputs, same network):
```
===== DIRECTION A: ATTACK (NAT64-wrapped internal address) =====
guard verdict : BLOCKED at guard: non-public host blocked
SECRET LEAKED : False
===== control: plain internal address (must be BLOCKED) =====
verdict: BLOCKED at guard: non-public host blocked
===== DIRECTION B: PUBLIC address (must still be FETCHED) =====
guard verdict : PASSED (fetched)
public body : PUBLIC-OK-BODY
===== SUMMARY =====
attack_leaked_internal_secret = False
plain_internal_blocked = True
public_still_fetched = True
```
Fix-correctness matrix (isolated): genuine global IPv6 `2606:4700:4700::1111` and `2001:4860:4860::8888` -> ALLOW (no false positive); NAT64/RFC8215/IPv4-compat/6to4 wrappers of an internal IPv4 -> BLOCK; NAT64 of a public IPv4 (`64:ff9b::808:808` = 8.8.8.8) -> ALLOW.
## Impact
A caller who can upload a markdown or textpack document (textpack uploads route to the native engine with zero config; markdown reaches it when the native engine is selected) can make the LightRAG server issue HTTP requests to internal-only addresses that the SSRF guard is meant to forbid, and the fetched body is ingested. On a deployment with NAT64/DNS64 routing this reaches cloud instance metadata (`169.254.169.254`, `100.100.100.200`), loopback services, and RFC1918 internal hosts, exposing IAM credentials and internal service data.
## Suggested fix
Decode the embedded IPv4 of any IPv6 transition wrapper and classify by the embedded IPv4 before the `is_global` check:
```python
def _unwrap_embedded_ipv4(ip):
"""Return the IPv4 embedded in an IPv6 transition wrapper (IPv4-mapped,
IPv4-compatible, NAT64 64:ff9b::/96 and RFC8215 64:ff9b:1::/48, 6to4
2002::/16) so it is classified by IPv4 rules; else return ip unchanged."""
if ip.version != 6:
return ip
if ip.ipv4_mapped is not None:
return ip.ipv4_mapped
if getattr(ip, "sixtofour", None) is not None:
return ip.sixtofour
b = ip.packed
if b[:12] == b"\x00" * 12 and b[12:] not in (b"\x00\x00\x00\x00", b"\x00\x00\x00\x01"):
return ip_address(b[12:])
if b[:12] == b"\x00\x64\xff\x9b" + b"\x00" * 8:
return ip_address(b[12:])
if b[:6] == b"\x00\x64\xff\x9b\x00\x01":
return ip_address(b[12:])
return ip
```
Then in `_validated_addresses`:
```python
judged = _unwrap_embedded_ipv4(ip)
if not (judged.is_global or any(ip in net for net in allow)):
return []
```
Verified: this blocks the NAT64/IPv4-compat/6to4 internal forms while genuine public IPs and public NAT64-wrapped IPs still pass.
## Resolution
Addressed by [#3426](https://github.com/HKUDS/LightRAG/pull/3426) (pending merge), targeted for release in `lightrag-hku` 1.5.5. `_validated_addresses()` now decides global-routability by **starting from the stdlib's `is_global` on the literal and only ever tightening it** — the guard is never more permissive than `ipaddress` itself:
- **Fixed-position transition wrappers are decoded and the embedded IPv4 must also be global** (IPv4-mapped `::ffff:0:0/96`, IPv4-compatible `::/96`, and the NAT64 well-known prefix `64:ff9b::/96` — RFC 6052 §2.2, contiguous low-32 embedding). So `64:ff9b::7f00:1` (→ loopback) is blocked while `64:ff9b::808:808` (→ public 8.8.8.8) still passes.
- **Blocks whose embedded-IPv4 position must not be trusted are default-denied as whole blocks**, independent of the interpreter: the RFC 8215 local-use prefix `64:ff9b:1::/48` (technology-agnostic, embedded-IPv4 position not guaranteed per RFC 8215 §5 — a fixed-offset decode is itself bypassable, e.g. `64:ff9b:1:7f00:0:100:808:808` encodes `127.0.0.1` but its low 32 bits read as public `8.8.8.8`) and 6to4 `2002::/16` (IANA global-reachability N/A; current CPython classifies the whole block non-global — RFC 7526 deprecated only the 6to4 anycast relay path, not the 2002::/16 prefix itself). Decoding 6to4 by its embedded IPv4 would have re-permitted `2002::/16` addresses the stdlib already blocks, so it is denied outright instead.
Genuine global IPv6 and a NAT64 wrapper of a *public* IPv4 still pass; regression tests cover every wrapped form of loopback / RFC1918 / metadata, the RFC 6052 `/48` suffix bypass, and 6to4 of a public IPv4. Boundary: a NAT64 deployment using a *custom*, globally-routable Network-Specific Prefix (RFC 6052 permits any /32../96), or a genuine local NAT64 / legacy 6to4 in the force-denied blocks, is not auto-permitted — such operators should pin the download egress or set `NATIVE_MD_IMAGE_ALLOWED_NON_PUBLIC_CIDRS` deliberately.
## Credit
tonghuaroot (
[email protected]).