Skip to content

Refuse non-public destinations in http.request - #2092

Merged
ppXD merged 1 commit into
mainfrom
fix/refuse-non-public-destinations-in-http-request
Oct 8, 2026
Merged

ppXD merged 1 commit into
mainfrom
fix/refuse-non-public-destinations-in-http-request

Conversation

@ppXD

@ppXD ppXD commented Oct 8, 2026

Copy link
Copy Markdown
Owner

Summary

  • http.request built its URL from trigger text or upstream model output and sent it through the default HttpClient, with no address check and auto-followed redirects. Any team member's workflow could read the worker's loopback services and cloud metadata, and a cross-origin redirect forwarded the author's X-Api-Key / PRIVATE-TOKEN / Cookie headers. The node now uses a guarded named client (GuardedHttpClientRegistration):
    • A ConnectCallback dials only public addresses, or addresses an operator commits to OutboundHttp:AllowedInternalDestinations (hosts or CIDRs). This covers every address spelling, every redirect hop and DNS rebinding.
    • The handler keeps no shared cookie jar.
    • An author-written Host header is dropped.
    • Refusals are outbound_destination_refused and fail the node as non-retryable.
  • The worker's HTTP(S)_PROXY / NO_PROXY is still honoured, so proxy-only workers keep reaching public APIs. Each proxied hop's host is resolved and checked on the worker before it is sent. The proxy re-resolves the name; this window is documented in OutboundDestinationGuard and the appsettings _doc.
  • Redirects are followed by OriginScopedRedirectHandler, which allows at most 10 hops and never https->http.
    • A hop that changes scheme, host or port drops every request header. This includes a same-host http->https move, so authors keep their headers by writing the https URL.
    • A redirect that would carry the request body to another origin (307/308, or 300/301/302 with any method but POST) is returned as the node's 3xx.
  • The allowlist is parsed while the host is built (AddGuardedHttpClient(name, IConfiguration) in Startup), so a malformed entry stops the host instead of failing every call at run time.

Test plan

  • Unit: address spellings and IPv6 forms refused with zero accepted connections. Also covered:
    • allowlist admits
    • redirect-to-loopback refused at the hop
    • headers dropped on cross-origin redirect
    • cookie isolation between runs
    • Host override ignored
    • cross-origin 307/308 with a body returned unfollowed
    • mixed DNS answer dialled only at permitted addresses (stubbed resolver, socket level)
    • public destination through a fake loopback proxy
    • internal destinations and redirects through the proxy refused before the proxy is asked
    • malformed allowlist entry fails registration
  • Each new unit and integration test was mutation-checked: the behaviour removed means the test fails.
  • Full unit suite: 12609 passed, 0 failed.
  • Integration (real node + engine + Postgres): loopback refusals and allowlisted success. Also a public-resolving name through the operator's proxy answers in full, and a proxied metadata address fails the node with the proxy never asked. All 5 classes that run http.request: 63/63.
  • E2E (WebApplicationFactory<Program>): the real host's http.request client refuses loopback (pins the Startup registration and the committed empty allowlist), and a malformed committed entry stops the host from starting. Category=E2E&Surface=Http: 165/165.
  • Staging: on a worker with HTTP(S)_PROXY set, confirm a public http.request call goes through the proxy.

http.request sent through the default HttpClient: no address check and
auto-followed redirects. Its URL is routinely built from trigger text or
an upstream model's output, and any team Member can author one, so a
workflow could read the worker's loopback services and cloud metadata
(every spelling: localhost, 2130706433, 0x7f000001, 127.1, [::1],
[::ffff:127.0.0.1]) and return the response as node outputs. A 302
from an allowed host to loopback was followed, and the author's
X-Api-Key, PRIVATE-TOKEN and Cookie headers went along to the new
origin, since the built-in follower strips only Authorization.

The node now uses a named client whose ConnectCallback resolves the
host and dials only public addresses, or ones an operator committed to
OutboundHttp:AllowedInternalDestinations (hosts or CIDRs). Checking at
connect time covers every address spelling, every redirect hop and DNS
rebinding in one place, because the resolution checked is the one
dialled. IPv4 reuses the sandbox egress predicate; IPv6 is global
unicast only, and v4-mapped, v4-compatible, NAT64 and 6to4 forms are
judged by the IPv4 address they carry.

The worker's HTTP(S)_PROXY is still honoured, as the default client
did, so a worker that reaches the internet only through a proxy keeps
working. A connection to the proxy is dialled as operator
infrastructure; a request the proxy carries has its host resolved and
checked on the worker before it is sent. The proxy resolves the name
again, a rebinding window only the proxy's own egress rules can close.

The handler keeps no cookie jar: the factory shares one primary handler
across every run and team, so a jar sent one run's session on another's
requests. An author-written Host header is dropped, because Host and
the SNI derived from it pick the virtual host behind an admitted name.

Redirects are followed by a handler, at most 10 hops (the built-in
allows 50) and never https->http. A hop that changes scheme, host or
port drops every request header, a same-host http->https move
included; the author keeps them by writing the https URL. A redirect
that would carry the body to another origin (307/308, or 300/301/302
of anything but a POST) is returned as the node's 3xx, since the body
holds the same secrets the headers do.

The allowlist is parsed while the host is built, so a malformed entry
stops the host instead of failing every http.request call at run time.
A refusal is an IFailure (outbound_destination_refused) and fails the
node as non-retryable.

http.request is the only workflow-controlled outbound HTTP: git.* nodes
call the bound provider, llm.complete the credential's base URL, pack
import already allowlists https hosts with redirects off, and
chat.post_message makes no outbound call.
@ppXD
ppXD merged commit 5a8d061 into main Oct 8, 2026
7 checks passed
@ppXD
ppXD deleted the fix/refuse-non-public-destinations-in-http-request branch October 8, 2026 08:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant