Files
OpenMAIC/tests/api
wyuc 68aed2932a fix(proxy-media): connect only to addresses the SSRF guard validated (#1488)
* fix(proxy-media): connect only to addresses the SSRF guard validated

The route validated a URL with validateUrlForSSRF and then handed the
hostname to fetch(), which resolved it again at connect time. An
attacker-controlled name could answer a public address to the guard and an
internal address (loopback, RFC1918, link-local, cloud metadata) to the
socket, and every redirect hop repeated the same validate-then-refetch gap.

Add a shared policy-aware pinned dispatcher
(lib/server/pinned-dispatcher.ts) whose connect.lookup resolves all
addresses, runs the same local-network policy as validateUrlForSSRF over
every answer (cloud metadata is always blocked, even with
ALLOW_LOCAL_NETWORKS=true), and passes only the vetted list to the
connection. proxy-media creates one dispatcher per request and uses it on
every hop, mapping a connect-time refusal to 403 INVALID_URL instead of a
generic 500. The agent-runtime pinned agent now reuses the same builder
with its strict assertSafeIp policy unchanged.

The other routes that validate with validateUrlForSSRF and then hand the
URL to provider SDKs (parse-pdf, transcription, generate/*, verify-*,
probe-models, azure-voices) are not covered; that is a follow-up.

Tests: new tests/server/proxy-media-rebinding.test.ts covers the two-answer
rebind, the ALLOW_LOCAL_NETWORKS opt-in through the pinned path, metadata
refusal under the opt-in, and a redirect-hop rebind.

* fix(proxy-media): destroy the per-request dispatcher and align non-unicast policy across layers

Follow-up hardening for the proxy-media SSRF pin.

1. Handler hang: the route awaited `dispatcher.close()` in `finally`.
   undici's `close()` drains in-flight requests, and the response body is
   intentionally left unread on the 30x (`redirect: 'manual'`), non-2xx,
   oversize and connect-refusal paths, so an upstream that keeps its body open
   never completes and the POST never settles. Tear the pool down with a
   non-awaited `destroy()` instead; the response blob is already in memory by
   the time `finally` runs. Regression tests drive a loopback server that
   answers 404, a 30x, and an oversize 200 while trickling its body forever and
   assert the route settles in well under a second.

2. Headline rebinding test sensitivity: install an attacker-controlled global
   dispatcher (`setGlobalDispatcher` plus a lookup that always answers
   loopback) in `beforeEach` so the unpinned fetch path really reaches the
   loopback server. Removing the route's dispatcher now fails on
   `internal.requests() === 0` rather than on an unrelated DNS resolution
   error. The positive control also asserts the pinned connect lookup was
   invoked, so a runtime that silently ignores `init.dispatcher` goes red.

3. Non-unicast policy parity: `validateUrlForSSRF` only classified IP-literal
   URLs through `isPrivateIP`, so a literal in CGNAT 100.64/10 or an IANA
   reserved/special-use range (240/4, 198.18/15, the TEST-NET blocks,
   192.0.0.0/24, multicast, broadcast) passed the URL layer while the same
   address reached through a hostname was refused at connect time. Both layers
   now share `isNeverAllowedRange`, applied to IP-literal URLs and to every
   resolved answer, with or without `ALLOW_LOCAL_NETWORKS`.
   `connectionAddressBlockReason` drops its blunt `range() !== 'unicast'` test
   so the two layers agree exactly, while private, loopback and link-local
   answers keep following the opt-in.

   Behaviour change: a hostname resolving into CGNAT 100.64/10 or an IANA
   reserved/special-use range is now refused (403 INVALID_URL) where it was
   previously proxied, including under `ALLOW_LOCAL_NETWORKS=true`, since these
   ranges are never legitimate proxy targets.

4. Do not echo an unparseable connect-time answer in the refusal. Return the
   generic block message instead of `Unable to classify network address:
   <value>`; the route relays that message to the client in a 403 body.

Tests: new trickle-body regression tests and CGNAT/reserved cases for both
literals and resolved answers, plus a `connectionAddressBlockReason` parity
suite. One existing fixture is updated from the IANA documentation prefix
2001:db8::/32 (reserved) to a real public prefix 2001:4860::/32.

* fix(ssrf-guard): let the local-network opt-in cover CGNAT and decode local-use NAT64 prefixes
2026-09-13 18:00:56 +02:00
..