fix: gate OAuth profile picture MIME at ingestion + storage layer

Three defense-in-depth gaps from GHSA-3wgj-c2hg-vm6q that the v0.9.5
serving-side fix didn't address:

1. _process_picture_url (utils/oauth.py): MIME was inferred from the
   URL extension via mimetypes.guess_type, the upstream Content-Type
   was discarded, and there was no allowlist. An SVG picture URL
   produced data:image/svg+xml;base64,... in the user's
   profile_image_url. Switch to the upstream Content-Type and gate it
   against PROFILE_IMAGE_ALLOWED_MIME_TYPES (the same env var the
   serving endpoint already uses); fall back to /user.png if the MIME
   isn't in the allowlist. Also pass allow_redirects=AIOHTTP_CLIENT_
   ALLOW_REDIRECTS on the aiohttp session.get — the existing
   validate_url() only checks the initial URL, redirects to internal
   targets would otherwise still be followed (same class as the
   rh5x-h6pp-cjj6 cluster, sixth call site).

2. update_user_profile_image_url_by_id (models/users.py): SQLAlchemy
   write path bypassed the Pydantic form validators, so anything
   stored via OAuth or any other non-form caller landed in the column
   unchallenged. Run validate_profile_image_url at the storage layer
   before the assignment.

3. insert_new_user (models/users.py): same gap on the new-user path
   used by Auths.insert_new_auth (OAuth signup, LDAP). Same
   storage-layer call to validate_profile_image_url, falling back to
   /user.png if the supplied value doesn't pass.

The serving-endpoint allowlist landed in v0.9.5 already broke the
exploit chain matte1782 demonstrated (browser never receives
Content-Type: image/svg+xml), but bad data was still being written
to the DB and the upstream MIME was never trusted. These three
fixes harden the ingestion + storage layers so future serving paths
or DB readers don't have to assume the column is clean.

Reported by matte1782 in GHSA-3wgj-c2hg-vm6q.

Co-authored-by: matte1782 <matte1782@users.noreply.github.com>
This commit is contained in:
Classic298 2026-05-10 21:52:16 +02:00
parent 18ff358c15
commit 5e7244ab8b
2 changed files with 36 additions and 9 deletions

View file

@ -265,6 +265,14 @@ class UsersTable:
oauth: Optional[dict] = None,
db: Optional[AsyncSession] = None,
) -> Optional[UserModel]:
# Storage-layer gate: same allowlist the form validators apply, so any
# write path that bypasses Pydantic forms (OAuth signup, LDAP, etc.)
# cannot land an SVG / unknown-MIME data URI in the column.
try:
profile_image_url = validate_profile_image_url(profile_image_url)
except Exception:
profile_image_url = '/user.png'
async with get_async_db_context(db) as db:
user = UserModel(
**{
@ -597,6 +605,11 @@ class UsersTable:
self, id: str, profile_image_url: str, db: Optional[AsyncSession] = None
) -> Optional[UserModel]:
try:
# Storage-layer gate: enforce the same allowlist the Pydantic
# form validators apply, so any write path that bypasses the
# form layer (e.g. OAuth) cannot land an SVG / unknown-MIME
# data URI in the column.
profile_image_url = validate_profile_image_url(profile_image_url)
async with get_async_db_context(db) as db:
result = await db.execute(select(User).filter_by(id=id))
user = result.scalars().first()

View file

@ -72,6 +72,7 @@ from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
PROFILE_IMAGE_ALLOWED_MIME_TYPES,
WEBUI_NAME,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
@ -1454,17 +1455,30 @@ class OAuthManager:
'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:
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}'
else:
async with session.get(
picture_url,
**get_kwargs,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
) as resp:
if not resp.ok:
log.warning(f'Failed to fetch profile picture from {picture_url}')
return '/user.png'
# Use upstream Content-Type (not URL extension) and gate against
# the same allowlist the serving endpoint enforces — keeps SVG
# / unknown MIME types out of profile_image_url at ingestion.
upstream_mime = (resp.headers.get('Content-Type', '') or '').split(';', 1)[0].strip().lower()
if upstream_mime not in PROFILE_IMAGE_ALLOWED_MIME_TYPES:
log.warning(
f"Rejected OAuth profile picture from {picture_url}: "
f"MIME {upstream_mime!r} not in allowlist"
)
return '/user.png'
picture = await resp.read()
base64_encoded_picture = base64.b64encode(picture).decode('utf-8')
return f'data:{upstream_mime};base64,{base64_encoded_picture}'
except Exception as e:
log.error(f"Error processing profile picture '{picture_url}': {e}")
return '/user.png'