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) <noreply@anthropic.com>
This commit is contained in:
3 files changed
+41
-21
No files matched your search
@@ -46,7 +46,12 @@ from open_webui.config import (
|
|||||||
WEB_LOADER_TIMEOUT,
|
WEB_LOADER_TIMEOUT,
|
||||||
)
|
)
|
||||||
from open_webui.constants import ERROR_MESSAGES
|
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.external_web import ExternalWebLoader
|
||||||
from open_webui.retrieval.loaders.tavily import TavilyLoader
|
from open_webui.retrieval.loaders.tavily import TavilyLoader
|
||||||
from open_webui.retrieval.web.firecrawl import scrape_firecrawl_url
|
from open_webui.retrieval.web.firecrawl import scrape_firecrawl_url
|
||||||
@@ -197,6 +202,19 @@ class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver):
|
|||||||
return results
|
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):
|
def extract_metadata(soup, url):
|
||||||
metadata = {'source': url}
|
metadata = {'source': url}
|
||||||
if title := soup.find('title'):
|
if title := soup.find('title'):
|
||||||
|
|||||||
@@ -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.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.internal.db import get_async_session
|
||||||
from open_webui.models.chats import Chats
|
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.routers.files import get_file_content_by_id, upload_file_handler
|
||||||
from open_webui.utils.access_control import has_permission
|
from open_webui.utils.access_control import has_permission
|
||||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
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
|
# without re-validation would let an attacker reach private IPs via a
|
||||||
# public host that redirects internally (e.g. cloud-metadata exfil).
|
# public host that redirects internally (e.g. cloud-metadata exfil).
|
||||||
validate_url(data)
|
validate_url(data)
|
||||||
session = await get_session()
|
# SSRF-safe session: re-checks the connect-time IP so a rebinding DNS answer
|
||||||
async with session.get(
|
# that passed validate_url cannot reach an internal address.
|
||||||
data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
async with get_ssrf_safe_session() as session:
|
||||||
) as r:
|
async with session.get(
|
||||||
r.raise_for_status()
|
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')
|
image_data = base64.b64encode(await r.read()).decode('utf-8')
|
||||||
return f'data:{r.headers["content-type"]};base64,{image_data}'
|
return f'data:{r.headers["content-type"]};base64,{image_data}'
|
||||||
|
|
||||||
else:
|
else:
|
||||||
file_id = None
|
file_id = None
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from open_webui.env import (
|
|||||||
)
|
)
|
||||||
from open_webui.models.chats import Chats
|
from open_webui.models.chats import Chats
|
||||||
from open_webui.models.files import Files
|
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.routers.files import upload_file_handler
|
||||||
from open_webui.utils.access_control.files import has_access_to_file
|
from open_webui.utils.access_control.files import has_access_to_file
|
||||||
from open_webui.routers.images import (
|
from open_webui.routers.images import (
|
||||||
@@ -28,7 +28,6 @@ from open_webui.routers.images import (
|
|||||||
upload_image,
|
upload_image,
|
||||||
)
|
)
|
||||||
from open_webui.storage.provider import Storage
|
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)
|
BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE)
|
||||||
MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', 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
|
# without re-validation would let an attacker reach private IPs via a
|
||||||
# public host that redirects internally (e.g. cloud-metadata exfil).
|
# public host that redirects internally (e.g. cloud-metadata exfil).
|
||||||
validate_url(url)
|
validate_url(url)
|
||||||
# Download the image from the URL
|
# Fetch through an SSRF-safe session that re-checks the connect-time IP, so a
|
||||||
session = await get_session()
|
# rebinding DNS answer that passed validate_url cannot reach an internal address.
|
||||||
async with session.get(
|
async with get_ssrf_safe_session() as session:
|
||||||
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
async with session.get(
|
||||||
) as response:
|
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
||||||
response.raise_for_status()
|
) as response:
|
||||||
image_data = await response.read()
|
response.raise_for_status()
|
||||||
encoded_string = base64.b64encode(image_data).decode('utf-8')
|
image_data = await response.read()
|
||||||
content_type = response.headers.get('Content-Type', 'image/png')
|
encoded_string = base64.b64encode(image_data).decode('utf-8')
|
||||||
return f'data:{content_type};base64,{encoded_string}'
|
content_type = response.headers.get('Content-Type', 'image/png')
|
||||||
|
return f'data:{content_type};base64,{encoded_string}'
|
||||||
else:
|
else:
|
||||||
# Non-URL string — treat as file_id. Delegate to the canonical
|
# Non-URL string — treat as file_id. Delegate to the canonical
|
||||||
# file-ID resolver which enforces ownership/access checks.
|
# file-ID resolver which enforces ownership/access checks.
|
||||||
|
|||||||
Reference in new issue
Block a user