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
This commit is contained in:
DrMelone 2026-04-03 22:47:34 +02:00
parent 8dd015368c
commit a8f7b743ff

View file

@ -3,26 +3,28 @@
import re
from urllib.parse import urlparse
# Validates the MIME type and structure of base64 data URIs, not payload
# integrity — corrupt base64 simply produces a broken image, same as a 404 URL.
# SVG is intentionally excluded: it can carry embedded scripts.
# Matches the OWUI-generated profile image route. The ``[^/]+`` segment
# accepts any user-ID without allowing path-traversal across segments,
# 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
)
# Relative path prefixes that are safe to accept as profile image URLs.
# These are the only patterns OWUI itself generates; arbitrary relative
# paths are rejected to prevent authenticated GET triggers against
# internal endpoints when other users view a profile.
_SAFE_RELATIVE_PATH_PREFIXES = (
'/api/v1/users/', # OWUI-generated profile image routes
'/static/', # Static assets served by the frontend
)
# Exact relative paths accepted (default avatars).
_SAFE_RELATIVE_PATHS = frozenset({
# 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 ``<img>`` sources.
_SAFE_STATIC_PATHS = frozenset({
'/user.png',
'/favicon.png',
'/static/favicon.png',
})
@ -32,25 +34,30 @@ def validate_profile_image_url(url: str) -> str:
Allowed formats:
- Empty string (falls back to default avatar)
- Known-safe relative paths (default avatars, static assets,
and OWUI profile image API routes)
- ``http://`` and ``https://`` URLs with a valid host
- 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
All other schemes (javascript:, file:, ftp:, etc.) are rejected.
SVG data URIs are rejected because SVG can contain embedded scripts.
Arbitrary relative paths are rejected to prevent authenticated GET
requests against internal endpoints.
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
# Known-safe relative paths generated by OWUI itself.
if url in _SAFE_RELATIVE_PATHS:
# --- Relative paths (exact match + anchored regex only) -----------
if url in _SAFE_STATIC_PATHS:
return url
if any(url.startswith(prefix) for prefix in _SAFE_RELATIVE_PATH_PREFIXES):
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)
@ -66,8 +73,8 @@ def validate_profile_image_url(url: str) -> str:
return url
# Base64-encoded raster images uploaded via the frontend.
# The regex enforces format boundaries (;base64,) and is
# case-insensitive per the data URI / MIME type specs.
# 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
@ -75,5 +82,3 @@ def validate_profile_image_url(url: str) -> str:
'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).'
)