From de8ea08f5ca591c85a55c087167504f55cd8c4df Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:36:53 +0200 Subject: [PATCH] Route user-supplied image-URL fetches through an SSRF-safe session (DNS rebinding, CWE-918) (#25960) The connection-layer DNS-rebinding guard (_SSRFSafeResolver / _SSRFSafeAdapter, PR #24759) was mounted only on SafeWebBaseLoader. Two user-reachable image fetches validate the URL then fetch it through the shared get_session() pool with the default resolver, so a TTL-0 rebinding answer that passed validate_url reaches an internal address at connect: - get_image_base64_from_url (utils/files.py): user image_url on every chat completion. - load_url_image (routers/images.py, POST /api/v1/images/edit): user-supplied image field. Add get_ssrf_safe_session() (a one-off aiohttp session mounting _SSRFSafeResolver) and use it for both fetches, so the connect-time IP is re-validated and a rebound loopback / RFC1918 / metadata address is rejected. The shared pool is left untouched for the admin-configured image-generation callers, which legitimately reach internal hosts. Co-authored-by: dhyabi2 <32069256+dhyabi2@users.noreply.github.com> Co-authored-by: geo-chen <2404584+geo-chen@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- backend/open_webui/retrieval/web/utils.py | 20 ++++++++++++++++++- backend/open_webui/routers/images.py | 18 +++++++++-------- backend/open_webui/utils/files.py | 24 +++++++++++------------ 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index afa73a9e0e..3e4bc8a25d 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -46,7 +46,12 @@ from open_webui.config import ( WEB_LOADER_TIMEOUT, ) from open_webui.constants import ERROR_MESSAGES -from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, USER_AGENT +from open_webui.env import ( + AIOHTTP_CLIENT_ALLOW_REDIRECTS, + AIOHTTP_CLIENT_SESSION_SSL, + AIOHTTP_CLIENT_TIMEOUT, + USER_AGENT, +) from open_webui.retrieval.loaders.external_web import ExternalWebLoader from open_webui.retrieval.loaders.tavily import TavilyLoader from open_webui.retrieval.web.firecrawl import scrape_firecrawl_url @@ -197,6 +202,19 @@ class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver): return results +def get_ssrf_safe_session() -> aiohttp.ClientSession: + """A one-off aiohttp session that re-validates the connect-time IP via _SSRFSafeResolver, + defeating DNS rebinding. Use for validate_url-gated fetches of user-supplied URLs that must + not use the shared (rebinding-vulnerable) pool. Use as a context manager so it is closed: + ``async with get_ssrf_safe_session() as session: ...``. + """ + return aiohttp.ClientSession( + connector=aiohttp.TCPConnector(resolver=_SSRFSafeResolver()), + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + trust_env=True, + ) + + def extract_metadata(soup, url): metadata = {'source': url} if title := soup.find('title'): diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 9d65cebfb8..716a73738a 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -24,7 +24,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS from open_webui.internal.db import get_async_session from open_webui.models.chats import Chats -from open_webui.retrieval.web.utils import validate_url +from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url from open_webui.routers.files import get_file_content_by_id, upload_file_handler from open_webui.utils.access_control import has_permission from open_webui.utils.auth import get_admin_user, get_verified_user @@ -851,14 +851,16 @@ async def image_edits( # without re-validation would let an attacker reach private IPs via a # public host that redirects internally (e.g. cloud-metadata exfil). validate_url(data) - session = await get_session() - async with session.get( - data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS - ) as r: - r.raise_for_status() + # SSRF-safe session: re-checks the connect-time IP so a rebinding DNS answer + # that passed validate_url cannot reach an internal address. + async with get_ssrf_safe_session() as session: + async with session.get( + data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS + ) as r: + r.raise_for_status() - image_data = base64.b64encode(await r.read()).decode('utf-8') - return f'data:{r.headers["content-type"]};base64,{image_data}' + image_data = base64.b64encode(await r.read()).decode('utf-8') + return f'data:{r.headers["content-type"]};base64,{image_data}' else: file_id = None diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index 94f63a21cd..b91350ca4b 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -20,7 +20,7 @@ from open_webui.env import ( ) from open_webui.models.chats import Chats from open_webui.models.files import Files -from open_webui.retrieval.web.utils import validate_url +from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url from open_webui.routers.files import upload_file_handler from open_webui.utils.access_control.files import has_access_to_file from open_webui.routers.images import ( @@ -28,7 +28,6 @@ from open_webui.routers.images import ( upload_image, ) from open_webui.storage.provider import Storage -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) @@ -60,16 +59,17 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]: # 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}' + # Fetch through an SSRF-safe session that re-checks the connect-time IP, so a + # rebinding DNS answer that passed validate_url cannot reach an internal address. + async with get_ssrf_safe_session() as 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: # Non-URL string — treat as file_id. Delegate to the canonical # file-ID resolver which enforces ownership/access checks.