fix(security): SSRF-harden server-side image fetch + stop logging raw image URLs

INJ-01 (high): fetch_image_block used follow_redirects=True with no per-hop
revalidation and discarded the resolved-IP pin, so an attacker-authored Slack/
Linear image URL could 302-redirect the fetch to an internal host / cloud
metadata endpoint (blind SSRF), and DNS-rebinding could bypass the one-shot
is_url_safe check. Route image fetches through the same per-hop resolve+pin+
revalidate loop the http_request tool uses, lifted into url_safety as the shared
request_with_safe_redirects. Also strip the per-host Slack/Linear bearer token
on redirect so it can't be replayed to a redirect target.

SC-1 (low): linear.py logged full image URLs (which can carry signed tokens) at
DEBUG; multimodal logged them at INFO on every fetch. Log host-only.

Sink lived in multimodal.py (unchanged by the feature work) but PR #128 widened
its reach by no longer dropping images for text-only models. Fixing on the base
branch so #130/#129 inherit it on rebase. Adds fetch_image_block SSRF regression
tests (redirect-to-internal blocked; auth stripped on redirect).
This commit is contained in:
Adam Moussa 2026-07-08 18:18:07 -04:00
parent 0b1cc54173
commit 2028208237
No known key found for this signature in database
5 changed files with 196 additions and 100 deletions

View file

@ -1,13 +1,10 @@
from typing import Any from typing import Any
from urllib.parse import urljoin, urlparse, urlunparse
import httpx import httpx
from ..utils.url_safety import resolve_and_validate as _resolve_and_validate from ..utils.url_safety import pinned_url as _pinned_url # noqa: F401 (kept for tests)
from ..utils.url_safety import request_with_safe_redirects
_MAX_REDIRECTS = 5 from ..utils.url_safety import resolve_and_validate as _resolve_and_validate # noqa: F401
_REDIRECT_CODES = {301, 302, 303, 307, 308}
def _blocked_response(url: str, reason: str) -> dict[str, Any]: def _blocked_response(url: str, reason: str) -> dict[str, Any]:
@ -20,79 +17,19 @@ def _blocked_response(url: str, reason: str) -> dict[str, Any]:
} }
def _pinned_url(url: str, ip: str) -> str:
"""Rewrite ``url`` so the connection targets ``ip`` while keeping the path/query.
The original hostname is preserved separately for the ``Host`` header and TLS
SNI/cert verification (via httpx's ``sni_hostname`` request extension).
"""
parsed = urlparse(url)
host_literal = f"[{ip}]" if ":" in ip else ip
netloc = f"{host_literal}:{parsed.port}" if parsed.port else host_literal
return urlunparse(parsed._replace(netloc=netloc))
async def _request_with_safe_redirects( async def _request_with_safe_redirects(
client: httpx.AsyncClient, client: httpx.AsyncClient,
method: str, method: str,
url: str, url: str,
**kwargs: Any, **kwargs: Any,
) -> tuple[httpx.Response | None, dict[str, Any] | None]: ) -> tuple[httpx.Response | None, dict[str, Any] | None]:
"""Issue a request while validating every redirect target before following it. """Thin wrapper over the shared SSRF-safe redirect loop that shapes a blocked
hop into the tool's error-response dict."""
The hostname is resolved once per hop and the connection is pinned to the response, blocked = await request_with_safe_redirects(client, method, url, **kwargs)
validated IP, closing the DNS-rebinding race where a controlled resolver if blocked is not None:
returns a public IP at validation time and a private IP at connect time. blocked_url, reason = blocked
""" return None, _blocked_response(blocked_url, reason)
current_method = method.upper() return response, None
current_url = url
request_kwargs = dict(kwargs)
# Pop caller headers/extensions ONCE so they're reused on every redirect hop
# (the per-hop Host + SNI are layered on top each time). Popping inside the
# loop dropped the caller's Authorization/Accept/etc. on the first redirect.
caller_headers = dict(request_kwargs.pop("headers", None) or {})
caller_extensions = dict(request_kwargs.pop("extensions", None) or {})
for redirect_count in range(_MAX_REDIRECTS + 1):
is_safe, reason, hostname, addr_infos = _resolve_and_validate(current_url)
if not is_safe or hostname is None or addr_infos is None:
return None, _blocked_response(current_url, reason)
pinned_ip = addr_infos[0][4][0]
parsed = urlparse(current_url)
headers = {**caller_headers, "Host": parsed.netloc}
extensions = {**caller_extensions, "sni_hostname": hostname}
response = await client.request(
current_method,
_pinned_url(current_url, pinned_ip),
follow_redirects=False,
headers=headers,
extensions=extensions,
**request_kwargs,
)
if response.status_code not in _REDIRECT_CODES:
return response, None
location = response.headers.get("Location")
if not location:
return response, None
if redirect_count == _MAX_REDIRECTS:
return None, _blocked_response(current_url, "Too many redirects")
current_url = urljoin(current_url, location)
if response.status_code == 303 or (
response.status_code in {301, 302} and current_method not in {"GET", "HEAD"}
):
current_method = "GET"
request_kwargs.pop("data", None)
request_kwargs.pop("content", None)
request_kwargs.pop("json", None)
return None, _blocked_response(current_url, "Too many redirects")
async def http_request( async def http_request(

View file

@ -13,7 +13,7 @@ from urllib.parse import urlparse
import httpx import httpx
from langchain_core.messages.content import create_image_block from langchain_core.messages.content import create_image_block
from .url_safety import is_url_safe from .url_safety import request_with_safe_redirects
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -52,23 +52,23 @@ async def fetch_image_block(
image_url: str, image_url: str,
client: httpx.AsyncClient, client: httpx.AsyncClient,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Fetch image bytes and build an image content block.""" """Fetch image bytes and build an image content block.
The fetch validates and pins every redirect hop (SSRF guard) and drops any
Authorization header once the URL redirects, so a per-host token is never
replayed to a redirect target the caller never chose to authenticate to.
URLs are logged host-only — a signed image URL can carry a bearer token.
"""
host = (urlparse(image_url).hostname or "").lower()
try: try:
safe, reason = is_url_safe(image_url)
if not safe:
logger.warning("Refusing to fetch image (SSRF guard) %s: %s", image_url, reason)
return None
logger.debug("Fetching image from %s", image_url)
headers = None headers = None
host = (urlparse(image_url).hostname or "").lower()
if host == "uploads.linear.app" or host.endswith(".uploads.linear.app"): if host == "uploads.linear.app" or host.endswith(".uploads.linear.app"):
linear_api_key = os.environ.get("LINEAR_API_KEY", "") linear_api_key = os.environ.get("LINEAR_API_KEY", "")
if linear_api_key: if linear_api_key:
headers = {"Authorization": linear_api_key} headers = {"Authorization": linear_api_key}
else: else:
logger.warning( logger.warning(
"LINEAR_API_KEY not set; cannot authenticate image fetch for %s", "LINEAR_API_KEY not set; cannot authenticate image fetch for %s", host
image_url,
) )
elif host == "files.slack.com" or host.endswith(".files.slack.com"): elif host == "files.slack.com" or host.endswith(".files.slack.com"):
slack_bot_token = os.environ.get("SLACK_BOT_TOKEN", "") slack_bot_token = os.environ.get("SLACK_BOT_TOKEN", "")
@ -76,41 +76,39 @@ async def fetch_image_block(
headers = {"Authorization": f"Bearer {slack_bot_token}"} headers = {"Authorization": f"Bearer {slack_bot_token}"}
else: else:
logger.warning( logger.warning(
"SLACK_BOT_TOKEN not set; cannot authenticate image fetch for %s", "SLACK_BOT_TOKEN not set; cannot authenticate image fetch for %s", host
image_url,
) )
response = await client.get(image_url, headers=headers, follow_redirects=True)
response, blocked = await request_with_safe_redirects(
client, "GET", image_url, headers=headers, strip_auth_on_redirect=True
)
if blocked is not None:
_, reason = blocked
logger.warning("Refusing to fetch image (SSRF guard) from %s: %s", host, reason)
return None
response.raise_for_status() response.raise_for_status()
content_type = response.headers.get("Content-Type", "").split(";")[0].strip() content_type = response.headers.get("Content-Type", "").split(";")[0].strip()
if not content_type: if not content_type:
guessed, _ = mimetypes.guess_type(image_url) guessed, _ = mimetypes.guess_type(image_url)
if not guessed: if not guessed:
logger.warning( logger.warning("Could not determine content type from %s; skipping image", host)
"Could not determine content type for %s; skipping image",
image_url,
)
return None return None
content_type = guessed content_type = guessed
supported_types = {"image/jpeg", "image/png", "image/gif", "image/webp"} supported_types = {"image/jpeg", "image/png", "image/gif", "image/webp"}
if content_type not in supported_types: if content_type not in supported_types:
logger.warning( logger.warning(
"Unsupported content type '%s' for %s; skipping image", "Unsupported content type '%s' from %s; skipping image", content_type, host
content_type,
image_url,
) )
return None return None
encoded = base64.b64encode(response.content).decode("ascii") encoded = base64.b64encode(response.content).decode("ascii")
logger.info( logger.info(
"Fetched image %s (%s, %d bytes)", "Fetched image from %s (%s, %d bytes)", host, content_type, len(response.content)
image_url,
content_type,
len(response.content),
) )
return create_image_block(base64=encoded, mime_type=content_type) return create_image_block(base64=encoded, mime_type=content_type)
except Exception: except Exception:
logger.exception("Failed to fetch image from %s", image_url) logger.exception("Failed to fetch image from %s", host)
return None return None

View file

@ -9,7 +9,13 @@ from __future__ import annotations
import ipaddress import ipaddress
import socket import socket
from urllib.parse import urlparse from typing import Any
from urllib.parse import urljoin, urlparse, urlunparse
import httpx
DEFAULT_MAX_REDIRECTS = 5
_REDIRECT_CODES = {301, 302, 303, 307, 308}
def resolve_and_validate(url: str) -> tuple[bool, str, str | None, list | None]: def resolve_and_validate(url: str) -> tuple[bool, str, str | None, list | None]:
@ -61,3 +67,92 @@ def is_url_safe(url: str) -> tuple[bool, str]:
"""Check if a URL is safe to request (not targeting private/internal networks).""" """Check if a URL is safe to request (not targeting private/internal networks)."""
is_safe, reason, _, _ = resolve_and_validate(url) is_safe, reason, _, _ = resolve_and_validate(url)
return is_safe, reason return is_safe, reason
def pinned_url(url: str, ip: str) -> str:
"""Rewrite ``url`` so the connection targets ``ip`` while keeping the path/query.
The original hostname is preserved separately for the ``Host`` header and TLS
SNI/cert verification (via httpx's ``sni_hostname`` request extension).
"""
parsed = urlparse(url)
host_literal = f"[{ip}]" if ":" in ip else ip
netloc = f"{host_literal}:{parsed.port}" if parsed.port else host_literal
return urlunparse(parsed._replace(netloc=netloc))
async def request_with_safe_redirects(
client: httpx.AsyncClient,
method: str,
url: str,
*,
max_redirects: int = DEFAULT_MAX_REDIRECTS,
strip_auth_on_redirect: bool = False,
**kwargs: Any,
) -> tuple[httpx.Response | None, tuple[str, str] | None]:
"""Issue a request, validating every redirect target before following it.
The hostname is resolved once per hop and the connection is pinned to the
validated IP, closing the DNS-rebinding race where a controlled resolver
returns a public IP at validation time and a private IP at connect time.
Returns ``(response, None)`` on success, or ``(None, (blocked_url, reason))``
when a hop fails validation or the redirect budget is exhausted. When
``strip_auth_on_redirect`` is set, the caller's ``Authorization`` header is
dropped once the request leaves the original URL, so a bearer token can't be
replayed to a redirect target the caller never chose to authenticate to.
"""
current_method = method.upper()
current_url = url
request_kwargs = dict(kwargs)
# Pop caller headers/extensions ONCE so they're reused on every redirect hop
# (the per-hop Host + SNI are layered on top each time). Popping inside the
# loop dropped the caller's Authorization/Accept/etc. on the first redirect.
caller_headers = dict(request_kwargs.pop("headers", None) or {})
caller_extensions = dict(request_kwargs.pop("extensions", None) or {})
for redirect_count in range(max_redirects + 1):
is_safe, reason, hostname, addr_infos = resolve_and_validate(current_url)
if not is_safe or hostname is None or addr_infos is None:
return None, (current_url, reason)
pinned_ip = addr_infos[0][4][0]
parsed = urlparse(current_url)
headers = {**caller_headers, "Host": parsed.netloc}
extensions = {**caller_extensions, "sni_hostname": hostname}
response = await client.request(
current_method,
pinned_url(current_url, pinned_ip),
follow_redirects=False,
headers=headers,
extensions=extensions,
**request_kwargs,
)
if response.status_code not in _REDIRECT_CODES:
return response, None
location = response.headers.get("Location")
if not location:
return response, None
if redirect_count == max_redirects:
return None, (current_url, "Too many redirects")
current_url = urljoin(current_url, location)
if strip_auth_on_redirect:
caller_headers = {
k: v for k, v in caller_headers.items() if k.lower() != "authorization"
}
if response.status_code == 303 or (
response.status_code in {301, 302} and current_method not in {"GET", "HEAD"}
):
current_method = "GET"
request_kwargs.pop("data", None)
request_kwargs.pop("content", None)
request_kwargs.pop("json", None)
return None, (current_url, "Too many redirects")

View file

@ -5,6 +5,7 @@ object (``webapp.X``) so tests that monkeypatch them keep working.
""" """
from typing import Any from typing import Any
from urllib.parse import urlparse
import httpx import httpx
from langchain_core.messages.content import create_text_block from langchain_core.messages.content import create_text_block
@ -182,7 +183,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
resolved_model_id = fallback_model_id resolved_model_id = fallback_model_id
image_model_override = (fallback_model_id, fallback_effort) image_model_override = (fallback_model_id, fallback_effort)
webapp.logger.info("Preparing %d image(s) for multimodal content", len(image_urls)) webapp.logger.info("Preparing %d image(s) for multimodal content", len(image_urls))
webapp.logger.debug("Image URLs: %s", image_urls) webapp.logger.debug("Image hosts: %s", [urlparse(u).hostname for u in image_urls])
async with httpx.AsyncClient(timeout=webapp.DEFAULT_HTTP_TIMEOUT) as client: async with httpx.AsyncClient(timeout=webapp.DEFAULT_HTTP_TIMEOUT) as client:
for image_url in image_urls: for image_url in image_urls:

View file

@ -20,6 +20,7 @@ fetch_url_tool = sys.modules["agent.tools.fetch_url"]
http_request_tool = sys.modules["agent.tools.http_request"] http_request_tool = sys.modules["agent.tools.http_request"]
# SSRF resolution now lives in the shared validator; patch DNS there. # SSRF resolution now lives in the shared validator; patch DNS there.
url_safety = importlib.import_module("agent.utils.url_safety") url_safety = importlib.import_module("agent.utils.url_safety")
multimodal = importlib.import_module("agent.utils.multimodal")
_NO_JSON = object() _NO_JSON = object()
@ -310,3 +311,67 @@ async def test_http_request_returns_timeout_result(monkeypatch) -> None:
assert result["success"] is False assert result["success"] is False
assert result["status_code"] == 0 assert result["status_code"] == 0
assert "timed out after 7 seconds" in result["content"] assert "timed out after 7 seconds" in result["content"]
# --- fetch_image_block SSRF (INJ-01 regression) ------------------------------
def _image_dns(monkeypatch, ip_map: dict[str, str]) -> None:
monkeypatch.setattr(
url_safety.socket,
"getaddrinfo",
lambda host, port, *a, **k: [_addr_info(ip_map[host], port)],
)
async def test_fetch_image_block_blocks_redirect_to_internal_host(monkeypatch) -> None:
"""A public image URL that 302-redirects to the metadata endpoint must not be
followed: the redirect target is re-validated and the internal hop blocked."""
_image_dns(
monkeypatch,
{"public.example": "93.184.216.34", "internal.evil": "169.254.169.254"},
)
def responder(method, url, **kwargs):
return FakeResponse(
status_code=302,
url=url,
headers={"Location": "http://internal.evil/latest/meta-data/"},
)
client = FakeAsyncClient(responder)
result = await multimodal.fetch_image_block("http://public.example/x.png", client)
assert result is None
# Exactly one hop was issued (to the validated public host); the redirect to
# the internal host was blocked before any request left the process.
assert len(client.calls) == 1
assert all("169.254.169.254" not in call["url"] for call in client.calls)
async def test_fetch_image_block_strips_auth_header_on_redirect(monkeypatch) -> None:
"""The per-host Slack bearer token must not be replayed to a redirect target."""
monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-secret")
_image_dns(
monkeypatch,
{"files.slack.com": "93.184.216.34", "cdn.public": "93.184.216.35"},
)
def responder(method, url, **kwargs):
if len(client.calls) == 1:
return FakeResponse(
status_code=302, url=url, headers={"Location": "https://cdn.public/x.png"}
)
resp = FakeResponse(status_code=200, url=url, headers={"Content-Type": "image/png"})
resp.content = b"\x89PNG\r\n"
return resp
client = FakeAsyncClient(responder)
result = await multimodal.fetch_image_block("https://files.slack.com/x.png", client)
assert result is not None
assert len(client.calls) == 2
first_auth = client.calls[0]["headers"].get("Authorization")
second_auth = client.calls[1]["headers"].get("Authorization")
assert first_auth == "Bearer xoxb-secret"
assert second_auth is None