Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ec5be86
fix(sglang): validate diffusion input_reference against traversal and…
nnshah1 Sep 8, 2026
4db60f8
fix(sglang): extend input_reference validation to video diffusion; sh…
nnshah1 Sep 8, 2026
92201a2
fix(common-http): percent-decode file:// media paths (review)
nnshah1 Sep 8, 2026
5070d4c
fix(sglang): fetch diffusion input_reference URLs SSRF-safe (review)
nnshah1 Sep 10, 2026
f7e48c0
Merge remote-tracking branch 'origin/main' into pr14435-fix
dmitry-tokarev-nv Sep 11, 2026
0debb20
fix(common-http): bound the media reference echoed into errors and logs
dmitry-tokarev-nv Sep 11, 2026
b716ec5
fix(common-http): let a caller cap how many bytes fetch_bytes will bu…
dmitry-tokarev-nv Sep 11, 2026
df50430
fix(common-http): make local_media_reference yield only a local path
dmitry-tokarev-nv Sep 11, 2026
1d215cd
fix(sglang): return 400 for a rejected diffusion input_reference
dmitry-tokarev-nv Sep 11, 2026
82aeb11
fix(common-http): bound the URL echoed by validate_url and the redire…
dmitry-tokarev-nv Sep 11, 2026
02346fe
fix(common-http): close four gaps from a second review pass
dmitry-tokarev-nv Sep 11, 2026
dbf9e1d
fix(common-http): bound HttpStatusError where the binding actually re…
dmitry-tokarev-nv Sep 11, 2026
4abc822
Merge remote-tracking branch 'origin/main' into pr14435-fix
dmitry-tokarev-nv Sep 11, 2026
6ffa332
fix(common-http): stop the HttpStatusError bound eating actionable gu…
dmitry-tokarev-nv Sep 11, 2026
ec106be
Merge remote-tracking branch 'origin/main' into pr14435-fix
dmitry-tokarev-nv Sep 14, 2026
1fad15c
Merge branch 'main' into pr14435-fix
dmitry-tokarev-nv Sep 15, 2026
3f7e3db
fix(common-http): bound the OSError diagnostic and the data-URI media…
dmitry-tokarev-nv Sep 15, 2026
6270823
fix(common-http): connect-time SSRF backstop against DNS rebinding
nnshah1 Sep 16, 2026
698ff06
Merge branch 'main' into pr14474-fix
dmitry-tokarev-nv Sep 16, 2026
cc1a8a9
fix(common-http): keep egress proxies working under the connect-time …
dmitry-tokarev-nv Sep 16, 2026
fcaf1af
fix(common-http): type the connector build so mypy accepts the resolver
dmitry-tokarev-nv Sep 16, 2026
4c0d4b0
fix(test): stub the aiohttp submodules the marker report collects
dmitry-tokarev-nv Sep 16, 2026
b61aef4
fix(common-http): carry the blocked-address reason into strerror
dmitry-tokarev-nv Sep 16, 2026
5e2b3c7
fix(common-http): honor a request stricter than the deployment baseline
dmitry-tokarev-nv Sep 17, 2026
b7bba50
Merge remote-tracking branch 'origin/main' into pr14474-fix
dmitry-tokarev-nv Sep 17, 2026
c9cf08e
fix(common-http): fail closed when a proxy would carry a protected fetch
dmitry-tokarev-nv Sep 18, 2026
f3d4ec3
fix(common-http): keep the proxy gate off the loop and out of the 4xx…
dmitry-tokarev-nv Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
default=100,
arg_type=int,
dest="max_connections",
help="Total pool size cap (httpx Limits.max_connections / aiohttp TCPConnector.limit).",
help="Pool size cap per connect-time policy (aiohttp TCPConnector.limit). The aiohttp client keeps one pool per connect-time policy outcome, at most two, so a deployment that sets DYN_MM_ALLOW_INTERNAL=1 and also issues stricter per-request policies can reach twice this value in total.",
)
add_argument(
g,
Expand Down
29 changes: 26 additions & 3 deletions components/src/dynamo/common/http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,32 @@ uses aiohttp.
`AiohttpClient` is the single supported backend. Its connector queues
pending connections in `O(1)`, so latency stays close to the offered
rate when one request fans out to many URLs (e.g. 100 image fetches),
and it exposes a `TCPConnector(resolver=...)` DNS hook that can pin
validated DNS answers for a connect-time SSRF backstop (the default
client here uses the stock resolver; the pinning lands as a follow-up). See the
and it exposes a `TCPConnector(resolver=...)` DNS hook that pins the
validated DNS answers as a connect-time SSRF backstop against DNS
rebinding — the default client wires a `BlocklistResolver` (see
`_ssrf_resolver.py`). The connector may return private addresses only when the
`DYN_MM_ALLOW_INTERNAL` deployment baseline **and** the request policy both
allow it, so neither side alone can weaken the other. The client keeps one
session per outcome and closes each resolver itself, because aiohttp closes
only a resolver it created.

> [!IMPORTANT]
> The backstop governs **direct** connections only. When a proxy applies
> (`HTTP_PROXY` / `HTTPS_PROXY` — the session runs `trust_env=True`), the
> connector dials the proxy and the *proxy* resolves the origin, out of this
> resolver's sight, so the check cannot govern the destination.
>
> A policy-protected fetch that a proxy would carry therefore **fails closed**.
> Set `DYN_MM_TRUST_EGRESS_PROXY=1` to assert that the proxy enforces
> destination policy itself, and the fetch proceeds. The gate asks aiohttp
> which proxy applies to that specific URL, so `NO_PROXY` is honored and a
> fetch that goes direct is never refused. It does not apply when
> `DYN_MM_ALLOW_INTERNAL=1`, which already permits private destinations.
>
> Note aiohttp never calls a resolver for an IP literal, so literal blocked
> addresses are `validate_url`'s job rather than the backstop's.

See the
[NeMo Gym aiohttp vs httpx note](https://docs.nvidia.com/nemo/gym/latest/infrastructure/engineering-notes/aiohttp-vs-httpx.html)
for the fan-out latency comparison.

Expand Down
15 changes: 12 additions & 3 deletions components/src/dynamo/common/http/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,16 @@

``DYN_HTTP_BACKEND`` accepts only ``aiohttp``; any other value logs a warning
and uses aiohttp. aiohttp scales well under fan-out and exposes a
``TCPConnector(resolver=...)`` hook that a policy-aware resolver can use to pin
validated DNS answers (the connect-time SSRF backstop lands on top of this
capability; it is not wired in the default client here).
``TCPConnector(resolver=...)`` hook. The default client wires a
``BlocklistResolver`` (``_ssrf_resolver.py``) into it that pins validated DNS
answers at connect time — the SSRF backstop against DNS rebinding. The
connector may return private addresses only when the ``DYN_MM_ALLOW_INTERNAL``
deployment baseline and the request policy both allow it. It governs
**direct** connections: when a proxy applies, the proxy resolves the origin,
so a policy-protected fetch that would be proxied fails closed unless
``DYN_MM_TRUST_EGRESS_PROXY=1`` asserts that the proxy enforces destination
policy. ``NO_PROXY`` is honored, so a fetch that goes direct is never refused.
IP literals never reach a resolver in aiohttp and stay ``validate_url``'s job.
"""

from __future__ import annotations
Expand All @@ -32,6 +39,7 @@
from .aiohttp_client import AiohttpClient
from .base import (
HttpClient,
HttpConfigurationError,
HttpConnectionError,
HttpError,
HttpStatusError,
Expand Down Expand Up @@ -97,6 +105,7 @@ async def close_http_client() -> None:
"AiohttpClient",
"HttpError",
"HttpTimeoutError",
"HttpConfigurationError",
"HttpConnectionError",
"HttpStatusError",
"HttpConfigBase",
Expand Down
98 changes: 98 additions & 0 deletions components/src/dynamo/common/http/_ssrf_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Connect-time SSRF backstop for the aiohttp backend.

``validate_url`` checks a hostname's resolved IPs, but the client re-resolves at
connect, so a DNS-rebinding server can return a public IP on the check and an
internal one at connect. aiohttp's ``TCPConnector(resolver=...)`` hook lets us
resolve + filter once and hand the connector the validated addresses to dial,
while the hostname is still used for TLS SNI / certificate verification — the
same mechanism the Rust frontend uses on reqwest's ``dns_resolver``
(``lib/llm/src/preprocessor/media/loader.rs``). Reuses
:func:`url_validator.is_blocked_ip`.

Scope: this governs **direct** connections. When an egress proxy is configured,
the proxy resolves the origin, so SSRF must be enforced at the proxy / network
layer instead (true of the Rust path as well).
"""

from __future__ import annotations

import errno
import socket

from aiohttp.abc import AbstractResolver, ResolveResult
from aiohttp.helpers import proxies_from_env
from aiohttp.resolver import DefaultResolver

from .url_validator import describe_media_source, is_blocked_ip


class SsrfBlockedAddress(OSError):
"""Raised at connect time when every resolved IP is in a blocked range."""


def _env_proxy_hosts() -> frozenset[str]:
"""Hosts of any egress proxy configured in the environment.

The session runs with ``trust_env=True``, so when a proxy is configured the
connector dials *the proxy*, and it is the proxy's own address that reaches
this resolver -- the origin is resolved by the proxy, out of our sight. A
corporate proxy on a private address would therefore be filtered here and
take out every fetch: measured, HTTP_PROXY at a private host turns each one
into ClientConnectorDNSError. Exempt the configured proxy so proxied
deployments keep working, and see the module docstring for what that means
for enforcement.
"""
return frozenset(
info.proxy.host
for info in proxies_from_env().values()
if info.proxy.host is not None
)


class BlocklistResolver(AbstractResolver):
"""aiohttp resolver that drops blocked IPs before the connector dials.

Returns the full set of non-blocked addresses (not just the first) so
aiohttp keeps its normal multi-address / Happy-Eyeballs fallback.

Note aiohttp short-circuits IP literals in ``TCPConnector._resolve_host``
and never calls a resolver for them, so literal blocked addresses are
``validate_url``'s job, not this one. This covers the hostname case, which
is the one that rebinds.
"""

def __init__(self, *, allow_private_ips: bool) -> None:
self._inner = DefaultResolver()
Comment thread
rmccorm4 marked this conversation as resolved.
Resolved
self._allow_private_ips = allow_private_ips
self._proxy_hosts = _env_proxy_hosts()

async def resolve(
self,
host: str,
port: int = 0,
family: socket.AddressFamily = socket.AF_INET,
) -> list[ResolveResult]:
hosts = await self._inner.resolve(host, port, family)
if self._allow_private_ips or host in self._proxy_hosts:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exempts the hostname regardless of whether the connection is actually going through a proxy:

if self._allow_private_ips or host in self._proxy_hosts:
    return hosts

With HTTPS_PROXY=http://proxy.review.test:3128 and NO_PROXY=proxy.review.test, a direct HTTPS request to that hostname on another port bypasses filtering. I reproduced this with the default strict policy: validation received a public DNS answer, connection-time resolution returned loopback, and the private test server received the request. Neither private-access permission nor proxy trust was enabled; the equivalent non-proxy hostname was blocked.

Can the exemption be confined to actual proxy connections, keeping direct/NO_PROXY origin lookups filtered even when their hostname matches a configured proxy? Please add this case alongside a successful trusted-private-proxy control.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. #15780 fixes this (commit e704eec).

The exemption now applies only on connections that dial the proxy. A fetch that a trusted proxy carries runs on its own session. Only the resolver of that session exempts the proxy, and only at the proxy's own host and port. Direct fetches, including the ones that NO_PROXY covers, run on a session whose resolver exempts nothing.

I did not use one shared connector with a per-request flag, because aiohttp caches DNS answers per connector, keyed by (host, port). An exemption keyed on host and port alone still exempted a direct fetch to the proxy's own port.

test_proxy_connections.py has these tests:

  • Your case, over HTTPS and over HTTP.
  • A direct fetch to the proxy's own port.
  • A direct fetch after a proxied fetch filled the DNS cache.
  • A trusted private proxy that still works.
  • The same host name with no proxy setting.

The direct-fetch tests and the cached-answer test fail on main and pass with the fix. The two controls pass on both.

return hosts
allowed = [h for h in hosts if not is_blocked_ip(h["host"])]
if not allowed:
# ``host`` is caller-supplied and unbounded; this text reaches an
# error response and a log line through the facade's connection
# error, so bound it the way every other message on this path is.
#
# Two-argument OSError on purpose: aiohttp wraps this in
# ClientConnectorDNSError and renders ``strerror``, so the
# one-argument form reaches the caller as a bare "[None]" and the
# reason is lost exactly where an operator would read it.
raise SsrfBlockedAddress(
errno.EHOSTUNREACH,
f"host {describe_media_source(host)} resolves only to blocked IPs",
)
return allowed

async def close(self) -> None:
await self._inner.close()
129 changes: 115 additions & 14 deletions components/src/dynamo/common/http/aiohttp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,27 @@

import asyncio
import logging
import os
from typing import Optional

import aiohttp
from aiohttp.helpers import get_env_proxy_for_url
from yarl import URL

from ._ssrf_resolver import BlocklistResolver
from .base import (
HttpClient,
HttpConfigurationError,
HttpConnectionError,
HttpStatusError,
HttpTimeoutError,
collect_capped,
)
from .url_validator import describe_error_detail, describe_media_source
from .url_validator import (
UrlValidationPolicy,
describe_error_detail,
describe_media_source,
)

logger = logging.getLogger(__name__)

Expand All @@ -35,13 +43,23 @@
# bounds how far past the limit a single read can carry.
_READ_CHUNK = 64 * 1024

# Set to "1" to assert that the configured egress proxy enforces destination
# policy itself. Spelled like DYN_MM_ALLOW_INTERNAL, which it sits beside.
DYN_MM_TRUST_EGRESS_PROXY = "DYN_MM_TRUST_EGRESS_PROXY"


class AiohttpClient(HttpClient):
"""aiohttp-backed concrete client."""

def __init__(self, config=None) -> None:
super().__init__(config)
self._session: Optional[aiohttp.ClientSession] = None
# Keyed by whether the connector may return private addresses. At most
# two entries, so a request that asks for a stricter policy than the
# deployment baseline gets a connector that actually enforces it.
self._sessions: dict[bool, aiohttp.ClientSession] = {}
Comment thread
rmccorm4 marked this conversation as resolved.
Resolved
# aiohttp marks an injected resolver as externally owned, so closing a
# session never closes it. Hold each one and close it ourselves.
self._resolvers: dict[bool, BlocklistResolver] = {}

def _effective_timeout(self, timeout: float) -> aiohttp.ClientTimeout:
# The override caps ``total`` (whole request). ``sock_connect``
Expand All @@ -57,21 +75,77 @@ def _effective_timeout(self, timeout: float) -> aiohttp.ClientTimeout:
total=total, sock_connect=self._config.connect_timeout
)

def _build_session(self) -> aiohttp.ClientSession:
@staticmethod
def _connect_allows_private(policy: Optional[UrlValidationPolicy]) -> bool:
"""Whether the connector may return private addresses for this fetch.

The intersection of the deployment baseline and the request policy.
Taking the baseline alone lets ``DYN_MM_ALLOW_INTERNAL=1`` override a
caller that explicitly asked for ``allow_private_ips=False``, and
taking the request alone lets any caller opt out of the deployment
setting. Only when both allow it does the backstop stand down.
"""
env_allows = UrlValidationPolicy.from_env().allow_private_ips
if policy is None:
return env_allows
return env_allows and policy.allow_private_ips

@staticmethod
async def _require_trusted_egress_proxy(url: str) -> None:
"""Refuse a protected fetch that a proxy would put out of our reach.

When a proxy applies, the connector dials the proxy and the proxy
resolves the origin, so the origin hostname never reaches the
connect-time check. Measured: the resolver sees only the proxy host,
while the proxy receives the absolute URI. Documenting that boundary
does not enforce it, so fail closed unless an operator asserts that
the proxy enforces destination policy itself.

Asks aiohttp which proxy applies to *this* URL rather than whether any
proxy variable is set, so ``NO_PROXY`` is honored and a fetch that
would go direct is not refused.
"""
if os.getenv(DYN_MM_TRUST_EGRESS_PROXY, "").strip() == "1":
return
try:
# aiohttp runs this same helper through asyncio.to_thread because
# it does proxy-bypass discovery and .netrc file reads. Match that
# rather than repeating the blocking work on the event loop.
await asyncio.to_thread(get_env_proxy_for_url, URL(url))
except LookupError:
# No proxy for this URL, so aiohttp dials the origin and the
# connect-time check governs it.
return
raise HttpConfigurationError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please preserve HttpConfigurationError in TRT-LLM’s direct video-fetch path too. That path calls fetch_bytes() independently of the common VideoLoader, then its generic handler assigns:

status if isinstance(status, int) and status >= 400 else 400

The new error has no status, so a valid video request through an untrusted configured proxy becomes HTTP 400. I confirmed HttpStatusError(status=400) with HttpConfigurationError as its cause.

Import the new exception in trtllm/multimodal_processor.py and preserve it before the generic handler:

except HttpConfigurationError:
    raise
except Exception as e:
    # existing fallback
    ...

This is an illustrative fix; please add a processor-level regression test.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I reproduced this and fixed it in #15780 (commit 214eef9).

On main, process_openai_request raised HttpStatusError with status 400 and HttpConfigurationError as its cause. The request fetched https://93.184.216.34/v.mp4 with HTTPS_PROXY set and the proxy not trusted. The video path now re-raises HttpConfigurationError before the generic handler. The import line is the same as the one in #14373.

The new processor test, test_video_through_an_untrusted_proxy_is_not_a_client_error, drives the real shared client. It fails on main with the 400 and passes with the fix.

I also traced the other callers of fetch_bytes. None of them turns HttpConfigurationError into a 4xx.

One path still ends in a 400, and it is not specific to this error. A TensorRT-LLM prefill worker receives any exception from a separate encode worker as a ValueError (check_response_error in lib/bindings/python/rust/errors.rs). The frontend answers a ValueError with 400. I found this by reading the code, not in a running deployment, so I left it for a separate change.

Other callers, with the same untrusted proxy.
Caller Result
local_media_reference raises HttpConfigurationError
SGLang image diffusion, input_reference raises HttpConfigurationError
SGLang video generation, input_reference yields status: "failed", as it does for every error
SGLang encode worker, both fetch sites raises HttpConfigurationError, and generate re-raises it
ImageLoader.load_image, AudioLoader.load_audio, VideoLoader.load_video raises HttpConfigurationError
the three load_*_batch functions raises Exception with the message, which maps to 500

f"{describe_media_source(url)} would be fetched through an egress "
"proxy, so the connect-time address check cannot govern the "
f"origin; set {DYN_MM_TRUST_EGRESS_PROXY}=1 to assert that the "
"proxy enforces destination policy, or unset the proxy"
)

def _build_session(self, allow_private_ips: bool) -> aiohttp.ClientSession:
resolver = BlocklistResolver(allow_private_ips=allow_private_ips)
self._resolvers[allow_private_ips] = resolver
connector = aiohttp.TCPConnector(
limit=self._config.max_connections,
# Single-origin fan-out is the whole point; capping per-host
# would defeat it. Hard-coded rather than env-tunable.
limit_per_host=0,
keepalive_timeout=self._config.keepalive_timeout,
enable_cleanup_closed=True,
# Connect-time SSRF backstop against DNS rebinding (see
# _ssrf_resolver). See _connect_allows_private for how the flag is
# derived. DYN_MM_ALLOW_INTERNAL is the deployment knob.
resolver=resolver,
)
return aiohttp.ClientSession(connector=connector, trust_env=True)
Comment thread
dmitry-tokarev-nv marked this conversation as resolved.
Resolved
Comment thread
rmccorm4 marked this conversation as resolved.
Resolved

async def _get_session(self) -> aiohttp.ClientSession:
async def _get_session(self, allow_private_ips: bool) -> aiohttp.ClientSession:
async with self._lock:
if self._session is None or self._session.closed:
self._session = self._build_session()
session = self._sessions.get(allow_private_ips)
if session is None or session.closed:
session = self._build_session(allow_private_ips)
self._sessions[allow_private_ips] = session
logger.info(
"aiohttp backend initialized: limit=%d, limit_per_host=0, "
"keepalive_timeout=%.1fs%s",
Expand All @@ -81,12 +155,22 @@ async def _get_session(self) -> aiohttp.ClientSession:
if self._config.per_call_timeout_override is not None
else "; total timeout set per-request",
)
return self._session
return session

async def _fetch_simple(
self, url: str, timeout: float, *, max_bytes: Optional[int] = None
self,
url: str,
timeout: float,
*,
max_bytes: Optional[int] = None,
policy: Optional[UrlValidationPolicy] = None,
) -> bytes:
session = await self._get_session()
allow_private = self._connect_allows_private(policy)
# Only when the check is meant to bite. If private destinations are
# already permitted for this fetch, the proxy gate protects nothing.
if not allow_private:
await self._require_trusted_egress_proxy(url)
session = await self._get_session(allow_private)
client_timeout = self._effective_timeout(timeout)
try:
async with session.get(
Expand Down Expand Up @@ -116,9 +200,19 @@ async def _fetch_simple(
) from e

async def _fetch_body_or_redirect(
self, url: str, timeout: float, *, max_bytes: Optional[int] = None
self,
url: str,
timeout: float,
*,
max_bytes: Optional[int] = None,
policy: Optional[UrlValidationPolicy] = None,
) -> tuple[bytes | None, str | None]:
session = await self._get_session()
allow_private = self._connect_allows_private(policy)
# Only when the check is meant to bite. If private destinations are
# already permitted for this fetch, the proxy gate protects nothing.
if not allow_private:
await self._require_trusted_egress_proxy(url)
session = await self._get_session(allow_private)
client_timeout = self._effective_timeout(timeout)
try:
async with session.get(
Expand Down Expand Up @@ -165,6 +259,13 @@ async def _fetch_body_or_redirect(

async def close(self) -> None:
async with self._lock:
if self._session is not None and not self._session.closed:
await self._session.close()
self._session = None
for session in self._sessions.values():
if not session.closed:
await session.close()
self._sessions = {}
# TCPConnector sets _resolver_owner=False for an injected resolver
# and only closes the one it made itself, so closing the session
# above leaves ours open.
for resolver in self._resolvers.values():
await resolver.close()
self._resolvers = {}
Loading
Loading