From 67023037f8ddf72f628c9bc1f0c33533d199c952 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:57:49 +0200 Subject: [PATCH] fix: replace brittle profile_image_url allowlist with safe-scheme validation (#23389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: replace brittle profile_image_url allowlist with safe-scheme validation The previous validation used a hardcoded allowlist of specific static paths and a single Gravatar prefix. This rejected OWUI's own internal API paths (e.g. /api/v1/users/{id}/profile/image) and external OAuth avatar URLs, making it impossible to save user profiles from the admin panel. Replace with scheme-based validation that allows relative paths, HTTP(S) URLs, and data:image URIs while blocking dangerous schemes like javascript:, file:, and ftp:. Fixes open-webui#23387 * fix: harden profile image URL validation per review feedback - Restrict data URIs to safe raster formats (png/jpeg/gif/webp); SVG is excluded because it can carry embedded scripts. - Block scheme-relative URLs (//host/path) which browsers resolve against the current protocol, bypassing the relative-path check. * fix: use structural validation instead of prefix checks - Use urlparse for HTTP(S) URLs: gives case-insensitive scheme matching and rejects bare schemes with no host (e.g. https://). - Use a compiled regex for data URIs: enforces the ;base64, boundary, restricts to safe raster formats, and is case-insensitive per spec. - Removes the startswith-based prefix tuple in favour of proper URL and data URI parsing. * fix: validate hostname not netloc, fix misleading comment - Use parsed.hostname instead of parsed.netloc so URLs like http://:80/path (non-empty netloc but no actual host) are rejected. - Update data URI comment to accurately state we validate MIME type and structure, not base64 payload integrity. * fix: constrain relative paths to known-safe prefixes Accepting any relative path starting with / allowed a user to set their profile_image_url to an arbitrary internal GET endpoint. When another user (e.g. an admin) views that profile, the browser fires the GET with the viewer's session cookies — an authenticated GET trigger surface. Constrain to known-safe prefixes (/api/v1/users/, /static/) and exact matches (/user.png, /favicon.png) which are the only relative paths OWUI itself generates. * fix: use exact matches and anchored regex, eliminate all prefix wildcarding Replace all startswith-based path checks with: - frozenset exact matches for static assets (/user.png, /favicon.png, /static/favicon.png) - Anchored regex for the OWUI profile image API route that accepts only /api/v1/users/{id}/profile/image (no trailing components, no path traversal across segments) This eliminates every prefix-based attack surface: - /api/v1/users/{id}/anything-else is rejected - /static/../../etc/passwd is rejected - /api/v1/users/../../admin/config is rejected - Arbitrary internal GET triggers are no longer possible * fix: exclude query/fragment delimiters from user-ID regex segment Change [^/]+ to [^/?#]+ so that inputs like /api/v1/users/alice?x=1/profile/image are rejected — the browser would interpret ? as the query string start, making the actual request target /api/v1/users/alice instead of the intended route. --- backend/open_webui/utils/validate.py | 91 ++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/backend/open_webui/utils/validate.py b/backend/open_webui/utils/validate.py index eec53317f..4decac5eb 100644 --- a/backend/open_webui/utils/validate.py +++ b/backend/open_webui/utils/validate.py @@ -1,13 +1,31 @@ """Validation utilities for user-supplied input.""" -# Known static asset paths used as default profile images -_ALLOWED_STATIC_PATHS = ( - '/user.png', - '/static/favicon.png', +import re +from urllib.parse import urlparse + +# Matches the OWUI-generated profile image route. ``[^/?#]+`` accepts +# any user-ID without allowing path-traversal or query/fragment injection, +# and the ``$`` anchor rejects trailing path components. +_USER_PROFILE_IMAGE_RE = re.compile(r'^/api/v1/users/[^/?#]+/profile/image$') + +# Validates MIME type and structure of base64 data URIs. Only the prefix +# is checked — validating the full base64 payload would mean running a +# regex across megabytes of data on every Pydantic instantiation for zero +# security benefit (corrupt base64 simply renders a broken image, same as +# a 404 URL). SVG is intentionally excluded: it can carry embedded scripts. +_SAFE_DATA_URI_RE = re.compile( + r'^data:image/(png|jpeg|gif|webp);base64,', re.IGNORECASE ) -# External URL prefixes that are explicitly trusted for profile images -_ALLOWED_URL_PREFIXES = ('https://www.gravatar.com/avatar/',) +# Exact relative paths accepted as profile images. These are the only +# static-asset paths OWUI itself assigns; no prefix/wildcard matching is +# used so that arbitrary relative paths cannot trigger authenticated GETs +# against internal endpoints when rendered as ```` sources. +_SAFE_STATIC_PATHS = frozenset({ + '/user.png', + '/favicon.png', + '/static/favicon.png', +}) def validate_profile_image_url(url: str) -> str: @@ -16,28 +34,51 @@ def validate_profile_image_url(url: str) -> str: Allowed formats: - Empty string (falls back to default avatar) - - data:image/* URIs (base64-encoded uploads from the frontend) - - Known static asset paths (/user.png, /static/favicon.png) - - Trusted external URLs (e.g. Gravatar) + - Known static-asset paths assigned by OWUI (exact match) + - The OWUI profile-image API route ``/api/v1/users/{id}/profile/image`` + - ``http://`` and ``https://`` URLs with a valid hostname + - ``data:image/{png,jpeg,gif,webp};base64,...`` URIs - Returns the url unchanged if valid, raises ValueError otherwise. + Everything else is rejected, including: + - Dangerous schemes (javascript:, file:, ftp:, …) + - SVG data URIs (can contain embedded scripts) + - Arbitrary relative paths (prevents authenticated GET triggers) + - Scheme-relative URLs (``//host/path``) """ if not url: return url - _ALLOWED_DATA_PREFIXES = ( - 'data:image/png', - 'data:image/jpeg', - 'data:image/gif', - 'data:image/webp', + # --- Relative paths (exact match + anchored regex only) ----------- + + if url in _SAFE_STATIC_PATHS: + return url + + if _USER_PROFILE_IMAGE_RE.match(url): + return url + + # --- Absolute URLs ------------------------------------------------- + + # urlparse normalises the scheme to lowercase, giving us + # case-insensitive scheme matching for free. + parsed = urlparse(url) + + # External images served over HTTP(S), e.g. OAuth provider avatars. + # Require a non-empty hostname (not just netloc, which can be ":80" + # for a URL like http://:80/path with no actual host). + if parsed.scheme in ('http', 'https'): + if not parsed.hostname: + raise ValueError( + 'Invalid profile image URL: HTTP(S) URLs must include a host.' + ) + return url + + # Base64-encoded raster images uploaded via the frontend. + # The regex enforces the ;base64, boundary and is case-insensitive + # per the data-URI / MIME-type specs. + if _SAFE_DATA_URI_RE.match(url): + return url + + raise ValueError( + 'Invalid profile image URL: must be a known internal path, ' + 'an HTTP(S) URL with a host, or a data:image URI (png/jpeg/gif/webp).' ) - if any(url.startswith(prefix) for prefix in _ALLOWED_DATA_PREFIXES): - return url - - if url in _ALLOWED_STATIC_PATHS: - return url - - if any(url.startswith(prefix) for prefix in _ALLOWED_URL_PREFIXES): - return url - - raise ValueError('Invalid profile image URL: only data URIs and default avatars are allowed.')