VDB
Sign up
HIGH7.1

GHSA-vv3m-f8x4-7377

lightrag-hku: SSRF via IPv6-transition address bypass (NAT64, IPv4-compatible, 6to4) of the native-markdown image-download guard

Quick fix

GHSA-vv3m-f8x4-7377 — lightrag-hku: upgrade to the fixed version with the command below.

pip install --upgrade 'lightrag-hku>=1.5.5'

Details

## 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 `![x](http://[64:ff9b::<internal-hex>]/a.png)`.

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 (tonghuaroot@gmail.com).

Are you affected?

Enter the version of the package you're using.

Affected packages

PyPI/lightrag-hku
Introduced in: 0Fixed in: 1.5.5
Fixpip install --upgrade 'lightrag-hku>=1.5.5'

References