InternLM / InternLM/lmdeploy

Unauthenticated SSRF via image_url in the OpenAI-compatible chat endpoint (redirect bypass of the private-IP guard)

Open
#4,761 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
8.1k
Forks
748
Avg merge
6d 2h
Merged PRs (30d)
54

Description

### Checklist

- [x] 1. I have searched related issues but cannot get the expected help.
- [x] 2. The bug has not been fixed in the latest version.
- [x] 3. Please note that if the bug-related issue you submitted lacks corresponding environment info and a minimal reproducible demo, it will be challenging for us to reproduce and resolve the issue, reducing the likelihood of receiving feedback.

### Describe the bug

reported https://github.com/InternLM/lmdeploy/security/advisories/GHSA-7rf4-7gg3-8vqx on 12 June 2026 - no response.

### Summary

The lmdeploy OpenAI-compatible API server fetches a multimodal `image_url` server-side. It has a private-IP guard (`_is_safe_url`) that resolves the host and rejects non-global addresses, but the guard runs only on the original URL; the HTTP client then follows redirects with no per-hop revalidation. An attacker-controlled public host that returns a 302 to an internal or cloud-metadata address is fetched, and its content is returned through the model pipeline. The API server binds `0.0.0.0` and runs without authentication by default, so any unauthenticated client of a vision-model deployment can reach internal services and metadata. Confirmed against the fetch code: a direct internal URL is blocked, but a redirect to the same internal target is followed and its bytes are returned.

### Details

`lmdeploy/vl/media/connection.py`:

```python
def _is_safe_url(url): # ~line 25: resolves host via getaddrinfo, rejects non-global IPs
...
def _load_http_url(url): # ~line 54
session = requests.Session()
session.max_redirects = 3
if not _is_safe_url(url): raise ... # ~line 56: validates ONLY the original URL
resp = client.get(url, allow_redirects=True) # ~line 68: follows redirects, no per-hop check
```

`requests` follows the `Location` of a 302 without re-running `_is_safe_url`, so an `http(s)://attacker/` URL whose host resolves to a global IP (passing the guard) can redirect to `http://127.0.0.1:.../` or `http://169.254.169.254/...`. Reached unauthenticated from `/v1/chat/completions`: a message `image_url.url` flows through `serve/openai/processors/multimodal.py` (~line 143) to `load_from_url` -> `_load_http_url`. Defaults (`serve/openai/api_server.py`): `server_name='0.0.0.0'` (~line 1445), `api_keys=None` (~line 1452), and the auth check is applied only when `api_keys is not None` (~line 1573).

### Reproduction

### PoC

The guard validates only the original URL, not redirect hops (`lmdeploy/vl/media/connection.py:56` validates the original; `:68` does `get(..., allow_redirects=True)`). Stand up an internal listener (e.g. `127.0.0.1:9000` serving a secret image) and a public redirector that returns `302 Location: http://127.0.0.1:9000/internal-secret`, then drive the functions:

```python
from lmdeploy.vl.media.connection import _is_safe_url, load_from_url
# control: a direct internal URL is blocked
print(_is_safe_url("http://127.0.0.1:9000/internal-secret")) # (False, "Blocked non-global IP ...")
# bypass: a public host (resolves to a global IP, passes the guard) that 302-redirects internally
img = load_from_url("http://public.example:8080/start") # client follows 302 -> 127.0.0.1:9000
# -> INTERNAL LISTENER HIT; the internal bytes are returned as an Image
```

Over HTTP (default `api_server.py`: `server_name` `0.0.0.0` at `:1445`, `api_keys` `None` at `:1452` = unauth): `POST /v1/chat/completions` with `image_url.url` set to the redirector reaches the internal target. Fix: `allow_redirects=False` and re-run `_is_safe_url` per `Location` (or pin the validated IP).

### Impact

An unauthenticated client of a vision-model lmdeploy server can coerce server-side requests to internal-only services and cloud instance-metadata endpoints by hosting a public URL that redirects there, bypassing the private-IP guard, and receive the response content through the image pipeline. This enables internal service access and cloud-metadata credential theft from the lmdeploy host on the default (0.0.0.0, no-auth) configuration.

### Remediation

Do not auto-follow redirects when fetching `image_url` (`allow_redirects=False`) and instead loop manually, re-running `_is_safe_url` on every `Location` and rejecting non-global resolutions; or pin the connection to the IP validated by `_is_safe_url` so a redirect cannot switch hosts. Also reject non-http(s) schemes (the `file://` loader has no containment) and consider requiring authentication by default.

### Environment

```Shell
Affected version: commit 648df3b
```

### Error traceback

```Shell

```

Contributor guide

Open the contributing guide

Research direction

Start in lmdeploy/vl/media/connection.py at _is_safe_url and _load_http_url, then trace the image_url flow through serve/openai/processors/multimodal.py and /v1/chat/completions. Verify the direct private-IP control and redirect bypass from the PoC. Done means redirects no longer reach non-global targets and the existing direct-URL protection still works.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, backend, security
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.