Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 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
19 changes: 16 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,22 @@ 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`) keyed to the `DYN_MM_ALLOW_INTERNAL` env baseline.

> [!IMPORTANT]
> The backstop governs **direct** connections only. With an egress proxy
> configured (`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. The configured proxy is therefore exempt from filtering, so
> a proxy on a private address keeps working; enforcement for proxied fetches
> has to happen at the proxy or network layer. 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
11 changes: 8 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,14 @@

``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, keyed to the
``DYN_MM_ALLOW_INTERNAL`` env baseline. It governs **direct** connections: with
an egress proxy configured the proxy resolves the origin, so the configured
proxy is exempt from filtering and enforcement for proxied fetches belongs at
the proxy or network layer. IP literals never reach a resolver in aiohttp and
stay ``validate_url``'s job.
"""

from __future__ import annotations
Expand Down
91 changes: 91 additions & 0 deletions components/src/dynamo/common/http/_ssrf_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 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 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.
raise SsrfBlockedAddress(
f"host {describe_media_source(host)} resolves only to blocked IPs"
)
return allowed

async def close(self) -> None:
await self._inner.close()
17 changes: 15 additions & 2 deletions components/src/dynamo/common/http/aiohttp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,19 @@
import aiohttp
from yarl import URL

from ._ssrf_resolver import BlocklistResolver
from .base import (
HttpClient,
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 Down Expand Up @@ -58,14 +63,22 @@ def _effective_timeout(self, timeout: float) -> aiohttp.ClientTimeout:
)

def _build_session(self) -> aiohttp.ClientSession:
connector = aiohttp.TCPConnector(
connector_kwargs = dict(
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).
# Deliberately keyed to the env baseline, not the per-call fetch policy:
# this shared connector must not let a per-request allow_private_ips=True
# loosen the SSRF backstop. DYN_MM_ALLOW_INTERNAL is the deployment knob.
connector_kwargs["resolver"] = BlocklistResolver(
allow_private_ips=UrlValidationPolicy.from_env().allow_private_ips
)
connector = aiohttp.TCPConnector(**connector_kwargs)
Comment thread
dmitry-tokarev-nv marked this conversation as resolved.
Resolved
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:
Expand Down
108 changes: 108 additions & 0 deletions components/src/dynamo/common/tests/http/test_ssrf_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Unit tests for the aiohttp connect-time SSRF resolver (``_ssrf_resolver``).

Exercise the DNS-rebinding case deterministically: the resolver is fed a mix of
public and blocked answers (as if a rebinding server flipped between check and
connect) and must drop the blocked ones, keep the rest, and fail closed when
none remain. No network.
"""

from __future__ import annotations

import pytest

from dynamo.common.http._ssrf_resolver import BlocklistResolver, SsrfBlockedAddress

pytestmark = [
pytest.mark.unit,
pytest.mark.pre_merge,
pytest.mark.gpu_0,
]


class _FakeInner:
"""Stand-in for aiohttp's DefaultResolver returning canned entries."""

def __init__(self, ips: list[str]) -> None:
self._ips = ips

async def resolve(self, host, port=0, family=0):
return [{"hostname": host, "host": ip, "port": port} for ip in self._ips]

async def close(self):
pass


def _resolver_with(ips: list[str]) -> BlocklistResolver:
r = BlocklistResolver(allow_private_ips=False)
r._inner = _FakeInner(ips)
return r


async def test_resolver_drops_blocked_and_keeps_the_rest() -> None:
# Rebinding answer (public + metadata IP): the blocked one is dropped, and
# every non-blocked address survives for multi-address fallback.
resolver = _resolver_with(["93.184.216.34", "169.254.169.254", "8.8.8.8"])
out = await resolver.resolve("evil.example.com")
assert [h["host"] for h in out] == ["93.184.216.34", "8.8.8.8"]


async def test_resolver_fails_closed_when_only_blocked() -> None:
resolver = _resolver_with(["169.254.169.254"])
with pytest.raises(SsrfBlockedAddress):
await resolver.resolve("evil.example.com")


async def test_allow_private_ips_bypasses_filtering() -> None:
# The env baseline (DYN_MM_ALLOW_INTERNAL=1) opts the deployment into
# internal targets, so the resolver returns every answer unfiltered.
resolver = BlocklistResolver(allow_private_ips=True)
resolver._inner = _FakeInner(["169.254.169.254", "10.0.0.5"])
out = await resolver.resolve("internal.svc")
assert [h["host"] for h in out] == ["169.254.169.254", "10.0.0.5"]


async def test_configured_egress_proxy_is_not_filtered(monkeypatch) -> None:
"""A corporate proxy on a private address must not be filtered out.

The session runs with ``trust_env=True``, so with a proxy configured the
connector dials the proxy and it is the proxy's own address that reaches
this resolver. Filtering it turns every fetch into a connection error --
measured before the exemption, HTTP_PROXY at a private host failed them all.
"""
# aiohttp's proxies_from_env() prefers the lowercase name, and CI images can
# carry one already -- clear both so this test controls what is configured.
monkeypatch.delenv("http_proxy", raising=False)
monkeypatch.setenv("HTTP_PROXY", "http://proxy.internal:3128")

resolver = BlocklistResolver(allow_private_ips=False)
resolver._inner = _FakeInner(["10.1.2.3"])

hosts = await resolver.resolve("proxy.internal")

assert [h["host"] for h in hosts] == ["10.1.2.3"]


async def test_a_non_proxy_host_is_still_filtered(monkeypatch) -> None:
"""Control: the exemption is for the configured proxy only."""
monkeypatch.delenv("http_proxy", raising=False)
monkeypatch.setenv("HTTP_PROXY", "http://proxy.internal:3128")

resolver = BlocklistResolver(allow_private_ips=False)
resolver._inner = _FakeInner(["10.1.2.3"])

with pytest.raises(SsrfBlockedAddress):
await resolver.resolve("origin.example.com")


async def test_blocked_message_bounds_the_hostname() -> None:
"""The host is caller-supplied; this text reaches a response and a log."""
resolver = _resolver_with(["169.254.169.254"])

with pytest.raises(SsrfBlockedAddress) as excinfo:
await resolver.resolve("h" * 200_000 + ".example.com")

assert len(str(excinfo.value)) < 500
assert "h" * 200 not in str(excinfo.value)
Loading