diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 14a64fed60..4c676705f0 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -180,8 +180,12 @@ def get_content_from_url(request, url: str) -> str: validate_url(url) # Streamed GET to check Content-Type without downloading the body. + # allow_redirects=False prevents redirect-based SSRF: validate_url() above is + # called on the originally-submitted URL only; following 3xx redirects without + # re-validation would let an attacker reach private IPs (RFC1918, loopback, + # cloud-metadata 169.254.169.254) via a public host that redirects internally. try: - response = requests.get(url, stream=True, timeout=30) + response = requests.get(url, stream=True, timeout=30, allow_redirects=False) response.raise_for_status() content_type = response.headers.get('Content-Type', '') except Exception: diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 6ee0e3781a..f745c296e1 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -485,6 +485,17 @@ class SafeWebBaseLoader(WebBaseLoader): """ super().__init__(*args, **kwargs) self.trust_env = trust_env + # Prevent redirect-based SSRF on the synchronous _scrape() path. + # validate_url() is called once on the originally-submitted URL, but the + # parent WebBaseLoader's _scrape() invokes self.session.get(url, **self.requests_kwargs) + # which by default follows redirects. Without the override below, an attacker + # can submit a public URL that 302-redirects to an internal address (RFC1918, + # 127.0.0.1, 169.254.169.254, etc.) and the redirected target is fetched without + # re-validation. Matches the policy enforced on the async _fetch() path below. + self.requests_kwargs = { + **(self.requests_kwargs or {}), + 'allow_redirects': False, + } async def _fetch(self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5) -> str: async with aiohttp.ClientSession(trust_env=self.trust_env) as session: diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 7ef4938b2e..f1559bc034 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -807,10 +807,14 @@ async def image_edits( return data if data.startswith('http://') or data.startswith('https://'): - # Validate URL to prevent SSRF attacks against local/private networks + # 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(data) session = await get_session() - async with session.get(data, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r: + async with session.get(data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=False) as r: r.raise_for_status() image_data = base64.b64encode(await r.read()).decode('utf-8')