mirror of
https://github.com/open-webui/open-webui.git
synced 2026-07-10 04:20:44 +02:00
* fix: prevent redirect-based SSRF in get_image_base64_from_url Cohort follow-up to PR #24491. That PR patched three call sites (SafeWebBaseLoader._scrape, get_content_from_url, load_url_image) to pass allow_redirects=False on the underlying HTTP client; this fourth call site in utils/files.py was missed. get_image_base64_from_url() is invoked from convert_url_images_to_base64 in utils/middleware.py on every /api/chat/completions request whose message content includes an image_url part. validate_url() is called on the originally-submitted URL only; the aiohttp session.get() call had no allow_redirects argument and the shared session pool does not override the aiohttp default (allow_redirects=True). An authenticated user sending a chat message with image_url pointing at an attacker host that 302-redirects to 169.254.169.254 / 127.0.0.1 / RFC1918 reached the internal target. This is the most reachable variant in the redirect cluster: no special endpoint, no admin permission, no feature flag. Apply the same one-line fix as the other three call sites: pass allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS (defaults to False). Reported by nayakchinmohan in GHSA-88jq-grjp-jx6f; consolidated under GHSA-rh5x-h6pp-cjj6. Co-authored-by: nayakchinmohan <nayakchinmohan@users.noreply.github.com> * fix: enforce collection write access on process_file endpoint Cohort follow-up toba83613ff. That commit added _validate_collection_access to process_text and process_web (the user-supplied collection_name path) but missed process_file in the same router. process_file accepts a user-supplied collection_name and writes the file's embedded content into that collection via save_docs_to_vector_db. The file_id is gated by file ownership (line 1562) but collection_name was unchecked, so an authenticated user could append content from a file they own into another user's knowledge-base collection by passing the victim's KB UUID as collection_name. Identical pattern to the process_text and process_web gaps thatba83613ffclosed. Apply the same one-line gate as the sibling endpoints: when collection_name is user-supplied (not the default file-{file.id} fallback), require write access via _validate_collection_access. The shared validator delegates to filter_accessible_collections, which already correctly handles file-* prefixes (via has_access_to_file) and KB UUIDs (via Knowledges.check_access_by_user_id) — admins bypass. Reported by tenbbughunters (Tenable) in GHSA-4g37-7p2c-38r9 (the comprehensive write-path filing covering process_text / process_file / process_web / process_youtube and the _validate_collection_access UUID root cause), and independently re-identified for the missed process_file call site by kodareef5 in GHSA-4m74-3cmc-293g. Co-authored-by: tenbbughunters <tenbbughunters@users.noreply.github.com> Co-authored-by: kodareef5 <kodareef5@users.noreply.github.com> * fix: enforce collection write access on process_files_batch endpoint Cohort follow-up toba83613ffand the prior process_file fix on this branch. process_files_batch (line 2604) is the third write endpoint in the same router that accepts a user-supplied collection_name; it was covered in the same Tenable filing as process_file and was missed by the same cohort fix. The endpoint validates per-file ownership at line 2642 but does not check whether the caller has write access to the target collection_name before save_docs_to_vector_db writes into it at line 2683-2690 with add=True. Apply the same one-line gate as the sibling endpoints. Validate only when collection_name is user-supplied (truthy) so the existing fall through behavior for the None case is unchanged. Same Tenable / kodareef5 cohort as the previous commit. Co-authored-by: tenbbughunters <tenbbughunters@users.noreply.github.com> Co-authored-by: kodareef5 <kodareef5@users.noreply.github.com> --------- Co-authored-by: nayakchinmohan <nayakchinmohan@users.noreply.github.com> Co-authored-by: tenbbughunters <tenbbughunters@users.noreply.github.com> Co-authored-by: kodareef5 <kodareef5@users.noreply.github.com>
224 lines
7.7 KiB
Python
224 lines
7.7 KiB
Python
from open_webui.routers.images import (
|
|
get_image_data,
|
|
upload_image,
|
|
)
|
|
|
|
from fastapi import (
|
|
APIRouter,
|
|
Depends,
|
|
HTTPException,
|
|
Request,
|
|
UploadFile,
|
|
)
|
|
from typing import Optional
|
|
from pathlib import Path
|
|
|
|
from open_webui.storage.provider import Storage
|
|
|
|
from open_webui.models.chats import Chats
|
|
from open_webui.models.files import Files
|
|
from open_webui.routers.files import upload_file_handler
|
|
from open_webui.retrieval.web.utils import validate_url
|
|
|
|
import asyncio
|
|
import mimetypes
|
|
import base64
|
|
import io
|
|
import re
|
|
|
|
from open_webui.env import (
|
|
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
|
AIOHTTP_CLIENT_SESSION_SSL,
|
|
ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK,
|
|
)
|
|
from open_webui.utils.session_pool import get_session
|
|
|
|
BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE)
|
|
MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', re.IGNORECASE)
|
|
|
|
# Extension-based MIME fallback, only used when ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK is True.
|
|
_IMAGE_MIME_FALLBACK = {
|
|
'.webp': 'image/webp',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.bmp': 'image/bmp',
|
|
'.tiff': 'image/tiff',
|
|
'.tif': 'image/tiff',
|
|
'.ico': 'image/x-icon',
|
|
'.heic': 'image/heic',
|
|
'.heif': 'image/heif',
|
|
'.avif': 'image/avif',
|
|
}
|
|
|
|
|
|
async def get_image_base64_from_url(url: str) -> Optional[str]:
|
|
try:
|
|
if url.startswith('http'):
|
|
# Validate URL to prevent SSRF attacks against local/private networks.
|
|
# allow_redirects=False prevents redirect-based SSRF: validate_url() is
|
|
# called only on the originally-submitted URL; following 3xx redirects
|
|
# without re-validation would let an attacker reach private IPs via a
|
|
# public host that redirects internally (e.g. cloud-metadata exfil).
|
|
validate_url(url)
|
|
# Download the image from the URL
|
|
session = await get_session()
|
|
async with session.get(
|
|
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
|
) as response:
|
|
response.raise_for_status()
|
|
image_data = await response.read()
|
|
encoded_string = base64.b64encode(image_data).decode('utf-8')
|
|
content_type = response.headers.get('Content-Type', 'image/png')
|
|
return f'data:{content_type};base64,{encoded_string}'
|
|
else:
|
|
file = await Files.get_file_by_id(url)
|
|
|
|
if not file:
|
|
return None
|
|
|
|
file_path = await asyncio.to_thread(Storage.get_file, file.path)
|
|
file_path = Path(file_path)
|
|
|
|
if file_path.is_file():
|
|
with open(file_path, 'rb') as image_file:
|
|
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
|
content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type')
|
|
if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK:
|
|
content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower())
|
|
if not content_type:
|
|
return None
|
|
return f'data:{content_type};base64,{encoded_string}'
|
|
else:
|
|
return None
|
|
|
|
except Exception as e:
|
|
return None
|
|
|
|
|
|
async def get_image_url_from_base64(request, base64_image_string, metadata, user):
|
|
if BASE64_IMAGE_URL_PREFIX.match(base64_image_string):
|
|
image_url = ''
|
|
# Extract base64 image data from the line
|
|
image_data, content_type = await get_image_data(base64_image_string)
|
|
if image_data is not None:
|
|
_, image_url = await upload_image(
|
|
request,
|
|
image_data,
|
|
content_type,
|
|
metadata,
|
|
user,
|
|
)
|
|
|
|
return image_url
|
|
return None
|
|
|
|
|
|
async def convert_markdown_base64_images(request, content: str, metadata, user):
|
|
MIN_REPLACEMENT_URL_LENGTH = 1024
|
|
result_parts = []
|
|
last_end = 0
|
|
|
|
for match in MARKDOWN_IMAGE_URL_PATTERN.finditer(content):
|
|
result_parts.append(content[last_end : match.start()])
|
|
base64_string = match.group(2)
|
|
if len(base64_string) > MIN_REPLACEMENT_URL_LENGTH:
|
|
url = await get_image_url_from_base64(request, base64_string, metadata, user)
|
|
if url:
|
|
result_parts.append(f'')
|
|
else:
|
|
result_parts.append(match.group(0))
|
|
else:
|
|
result_parts.append(match.group(0))
|
|
last_end = match.end()
|
|
|
|
result_parts.append(content[last_end:])
|
|
return ''.join(result_parts)
|
|
|
|
|
|
def load_b64_audio_data(b64_str):
|
|
try:
|
|
if ',' in b64_str:
|
|
header, b64_data = b64_str.split(',', 1)
|
|
else:
|
|
b64_data = b64_str
|
|
header = 'data:audio/wav;base64'
|
|
audio_data = base64.b64decode(b64_data)
|
|
content_type = header.split(';')[0].split(':')[1] if ';' in header else 'audio/wav'
|
|
return audio_data, content_type
|
|
except Exception as e:
|
|
print(f'Error decoding base64 audio data: {e}')
|
|
return None, None
|
|
|
|
|
|
async def upload_audio(request, audio_data, content_type, metadata, user):
|
|
audio_format = mimetypes.guess_extension(content_type)
|
|
file = UploadFile(
|
|
file=io.BytesIO(audio_data),
|
|
filename=f'generated-{audio_format}', # will be converted to a unique ID on upload_file
|
|
headers={
|
|
'content-type': content_type,
|
|
},
|
|
)
|
|
file_item = await upload_file_handler(
|
|
request,
|
|
file=file,
|
|
metadata=metadata,
|
|
process=False,
|
|
user=user,
|
|
)
|
|
url = request.app.url_path_for('get_file_content_by_id', id=file_item.id)
|
|
return url
|
|
|
|
|
|
async def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
|
|
if 'data:audio/wav;base64' in base64_audio_string:
|
|
audio_url = ''
|
|
# Extract base64 audio data from the line
|
|
audio_data, content_type = load_b64_audio_data(base64_audio_string)
|
|
if audio_data is not None:
|
|
audio_url = await upload_audio(
|
|
request,
|
|
audio_data,
|
|
content_type,
|
|
metadata,
|
|
user,
|
|
)
|
|
return audio_url
|
|
return None
|
|
|
|
|
|
async def get_file_url_from_base64(request, base64_file_string, metadata, user):
|
|
if BASE64_IMAGE_URL_PREFIX.match(base64_file_string):
|
|
return await get_image_url_from_base64(request, base64_file_string, metadata, user)
|
|
elif 'data:audio/wav;base64' in base64_file_string:
|
|
return await get_audio_url_from_base64(request, base64_file_string, metadata, user)
|
|
return None
|
|
|
|
|
|
async def get_image_base64_from_file_id(id: str) -> Optional[str]:
|
|
file = await Files.get_file_by_id(id)
|
|
if not file:
|
|
return None
|
|
|
|
try:
|
|
file_path = await asyncio.to_thread(Storage.get_file, file.path)
|
|
file_path = Path(file_path)
|
|
|
|
# Check if the file already exists in the cache
|
|
if file_path.is_file():
|
|
with open(file_path, 'rb') as image_file:
|
|
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
|
content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type')
|
|
if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK:
|
|
content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower())
|
|
if not content_type:
|
|
return None
|
|
return f'data:{content_type};base64,{encoded_string}'
|
|
else:
|
|
return None
|
|
except Exception as e:
|
|
return None
|