From 5e7244ab8bb35d709b3757da2d6d2dec6f6bf30e Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Sun, 10 May 2026 21:52:16 +0200 Subject: [PATCH] fix: gate OAuth profile picture MIME at ingestion + storage layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/open_webui/models/users.py | 13 ++++++++++++ backend/open_webui/utils/oauth.py | 32 +++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 025e79bd8a..615015cf57 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -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() diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 320124ba4d..60932657c6 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -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'