fix(security): guard remaining preflighted HTTP fetches

Several platform fetch paths called is_safe_url before constructing ordinary httpx clients, leaving a second DNS lookup at connection time. This preserved the rebinding window for Slack batch images, Feishu documents, Telegram URL-photo fallback, and WeCom remote media.

Route each path through create_ssrf_safe_async_client and the shared redirect guard so direct connections validate and dial vetted IPs while configured proxies remain an explicit trusted egress boundary. Add per-path regressions that change DNS from public at preflight to metadata at connect time.

The Skills Hub provenance fixture intentionally serves content over loopback. Opt that test-scoped server into private-address access so it keeps exercising the real HTTP transport without weakening production blocking.

Related #8033

Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
This commit is contained in:
Eugeniusz Gilewski 2026-07-15 20:16:40 +02:00 committed by Teknium
parent 42626da1ce
commit 0cd4afeafd
9 changed files with 268 additions and 20 deletions

View file

@ -3446,13 +3446,17 @@ class FeishuAdapter(BasePlatformAdapter):
default_ext: str,
preferred_name: str,
) -> tuple[str, str]:
from tools.url_safety import is_safe_url
from gateway.platforms.base import _ssrf_redirect_guard
from tools.url_safety import create_ssrf_safe_async_client, is_safe_url
if not is_safe_url(file_url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {file_url[:80]}")
import httpx
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
async with create_ssrf_safe_async_client(
timeout=30.0,
follow_redirects=True,
event_hooks={"response": [_ssrf_redirect_guard]},
) as client:
response = await client.get(
file_url,
headers={

View file

@ -2906,9 +2906,12 @@ class SlackAdapter(BasePlatformAdapter):
chat_id, team_id=self._metadata_team_id(metadata)
)
try:
import httpx as _httpx
from urllib.parse import unquote as _unquote
from tools.url_safety import is_safe_url as _is_safe_url
from gateway.platforms.base import _ssrf_redirect_guard
from tools.url_safety import (
create_ssrf_safe_async_client,
is_safe_url as _is_safe_url,
)
except Exception:
await super().send_multiple_images(chat_id, images, metadata, human_delay)
return
@ -2925,7 +2928,7 @@ class SlackAdapter(BasePlatformAdapter):
file_uploads: List[Dict[str, Any]] = []
initial_comment_parts: List[str] = []
try:
async with _httpx.AsyncClient(
async with create_ssrf_safe_async_client(
timeout=30.0,
follow_redirects=True,
event_hooks={"response": [_ssrf_redirect_guard]},

View file

@ -6925,8 +6925,13 @@ class TelegramAdapter(BasePlatformAdapter):
)
# Fallback: download and upload as file (supports up to 10MB)
try:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
from gateway.platforms.base import _ssrf_redirect_guard
from tools.url_safety import create_ssrf_safe_async_client
async with create_ssrf_safe_async_client(
timeout=30.0,
event_hooks={"response": [_ssrf_redirect_guard]},
) as client:
resp = await client.get(image_url)
resp.raise_for_status()
image_data = resp.content

View file

@ -219,8 +219,14 @@ class WeComAdapter(BasePlatformAdapter):
try:
# Tighter keepalive so idle CLOSE_WAIT drains promptly (#18451).
from gateway.platforms._http_client_limits import platform_httpx_limits
self._http_client = httpx.AsyncClient(
timeout=30.0, follow_redirects=True, limits=platform_httpx_limits(),
from gateway.platforms.base import _ssrf_redirect_guard
from tools.url_safety import create_ssrf_safe_async_client
self._http_client = create_ssrf_safe_async_client(
timeout=30.0,
follow_redirects=True,
event_hooks={"response": [_ssrf_redirect_guard]},
limits=platform_httpx_limits(),
)
await self._open_connection()
self._mark_connected()
@ -1095,14 +1101,20 @@ class WeComAdapter(BasePlatformAdapter):
url: str,
max_bytes: int,
) -> Tuple[bytes, Dict[str, str]]:
from tools.url_safety import is_safe_url
from gateway.platforms.base import _ssrf_redirect_guard
from tools.url_safety import create_ssrf_safe_async_client, is_safe_url
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {url[:80]}")
if not HTTPX_AVAILABLE:
raise RuntimeError("httpx is required for WeCom media download")
client = self._http_client or httpx.AsyncClient(timeout=30.0, follow_redirects=True)
client = self._http_client or create_ssrf_safe_async_client(
timeout=30.0,
follow_redirects=True,
event_hooks={"response": [_ssrf_redirect_guard]},
)
created_client = client is not self._http_client
try:
async with client.stream(