mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-22 00:31:30 +00:00
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:
parent
18ff358c15
commit
5e7244ab8b
2 changed files with 36 additions and 9 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue