Skip to content

Feat/upstream proxy hostname passthrough - #2152

Open
fchinch wants to merge 2 commits into
unclecode:developfrom
fchinch:feat/upstream-proxy-hostname-passthrough
Open

Feat/upstream proxy hostname passthrough#2152
fchinch wants to merge 2 commits into
unclecode:developfrom
fchinch:feat/upstream-proxy-hostname-passthrough

Conversation

@fchinch

@fchinch fchinch commented Aug 18, 2026

Copy link
Copy Markdown

Builds on #2142 — that PR routes the egress proxy through an upstream, this one covers the case where the upstream must resolve the name itself.

Problem

The egress proxy resolves and pins every target before dialing, then asks the upstream to CONNECT to the pinned IP. That is the right default, but it assumes the name resolves here and that the upstream accepts an address. Two deployments where it does not:

  • the upstream fronts a network whose hostnames do not resolve outside it, so resolve_and_pin fails before the upstream is ever consulted;
  • the upstream enforces hostname ACLs and refuses CONNECT to a bare IP.

Today the only way through is CRAWL4AI_ALLOW_INTERNAL_URLS=true, which drops the entry-point check for every destination.

Change

CRAWL4AI_UPSTREAM_PROXY_DNS_SUFFIXES — a comma-separated allowlist of DNS suffixes (e.g. .corp.example,.internal). A name matching a listed suffix is handed to the upstream unresolved; the upstream then decides where it lands. Everything else is unchanged.

The same list exempts those names from the entry-point URL check, which runs before the browser starts — without that they would be rejected there and never reach the proxy.

Wildcard entries are rejected. Only explicit DNS suffixes are accepted. Delegating every name would turn any caller-supplied URL into a lookup performed by the upstream, which is the one shape this must not allow.

Behaviour

Configuration Behaviour
Variable unset Unchanged — resolve_and_pin() for everything
Explicit suffix, port 80/443 Hostname delegated to the upstream
Host outside the list resolve_and_pin()
IP literal Never delegated, whatever is listed
Port outside the allowed set Never delegated — falls back to resolve_and_pin()
Wildcard entry Ignored, with a warning
Invalid suffix Ignored, with a warning

Names are matched on the DNS label boundary and in normalised form — case-folded, root dot stripped, IDNA-encoded, length checked before and after encoding — so .corp.example covers wiki.corp.example and wiki.corp.example. but never corp.example.attacker.com or notcorp.example.

Delegated names are reachable on ports 80 and 443 only, configurable via CRAWL4AI_UPSTREAM_PROXY_DNS_PORTS. On the pinned path the port is unconstrained but the address must be global, so a non-web port still only reaches the public internet. Passthrough gives up exactly that address check, which leaves the port as the only thing still narrowing where a listed name can land — without it, a listed suffix could reach :9200, :5432 or :2375 rather than a web server. An explicit list replaces the default rather than extending it.

What you are trusting

For a delegated name there is no pin, so the rebinding and non-global checks do not apply to it — that is the point of the mode, and worth being explicit about. A subdomain under a listed suffix reaches whatever its DNS resolves to, including a private address, because we neither resolve nor pin it. The connection is still only ever handed to your configured upstream and never dialed directly, so this cannot reach the crawler's own network — but it can reach whatever the upstream is willing to reach, on the allowed ports.

This is strictly narrower than CRAWL4AI_ALLOW_INTERNAL_URLS=true, which an operator in this situation has had to reach for anyway: that drops the entry-point check for every destination, while the suffix list confines the relaxation to named hosts, keeps IP literals blocked, restricts ports, and never dials a target directly.

Tests

deploy/docker/tests/test_security_egress_proxy.py — 60 passing.

  • TestHostnamePassthrough — a listed name reaches the upstream unresolved; a name outside the list stays pinned; an IP literal never bypasses the pin; an unset variable changes nothing.
  • TestSuffixMatching — equivalent spellings (case, trailing root dot, IDNA) all match; near-misses (corp.example.attacker.com, notcorp.example) stay pinned; DNS label boundaries; wildcards dropped rather than honoured, without poisoning the rest of the list; listing an IP literal does not bypass the pin.
  • TestPassthroughPorts — 80/443 delegated by default; 22/2375/5432/6379/8080/9200 fall back to the pin; an operator can widen the list; a set-but-unusable list closes rather than opens; blank means unset.
  • Cross-layer consistency — the entry-point check and the proxy are asserted together on the same names and ports, so they cannot drift into disagreeing. Both import the same normalize_host() / passthrough_suffixes() / passthrough_ports() rather than repeating the parsing.

The one failure in that file (test_enforce_egress_sets_proxy) is pre-existing and unrelated — it needs the crawl4ai package installed.

Dial via the corporate proxy by CONNECT-to-the-pinned-IP when proxy env vars
are set, restoring crawls on proxy-only hosts (discussion unclecode#2041) without
weakening SSRF/rebinding guarantees.
@fchinch
fchinch force-pushed the feat/upstream-proxy-hostname-passthrough branch 5 times, most recently from 1d5429d to 672c282 Compare August 18, 2026 21:53
@fchinch
fchinch force-pushed the feat/upstream-proxy-hostname-passthrough branch from 672c282 to eb3dbf9 Compare August 19, 2026 03:32
Builds on the upstream-chaining work in unclecode#2142, which asks the upstream to
CONNECT to an already-pinned IP so the rebinding guarantee holds. That is the
right default, but it cannot serve an upstream that fronts a network whose
names do not resolve on our side, or one that enforces hostname ACLs and so
refuses CONNECT-to-an-IP — both listed there as known limitations.

CRAWL4AI_UPSTREAM_PROXY_DNS_SUFFIXES names the suffixes an operator wants the
upstream to resolve. Matching hostnames skip resolve_and_pin and are sent to
the upstream unresolved; the upstream then owns where the connection lands.

Two layers had to honour it. The pinning proxy is the obvious one. The entry
point check in validate_url_destination runs before the browser starts, and
rejected delegated names there — so the request never reached the proxy and
the suffix list had no effect. Verified against a live tunnel: without the
second change the crawl still returned "URL blocked (SSRF protection)", and
the only way through was CRAWL4AI_ALLOW_INTERNAL_URLS, which is exactly the
blunt instrument this is meant to avoid. Both layers now share one set of
helpers rather than parsing the list twice, so they cannot drift into
disagreeing about which names are delegated.

Deliberately an allowlist rather than a boolean, so the pin is given up only
for names an operator named, never wholesale:

  - unset (the default) leaves every path byte-identical to today;
  - a name outside the list keeps resolve-and-pin;
  - an IP literal never qualifies, because there is no name to delegate — this
    stops a suffix entry from reaching 169.254.169.254;
  - a wildcard entry is refused with a warning rather than honoured, since
    delegating every name would turn any caller-supplied URL into a lookup
    performed by the upstream;
  - delegated names are reachable on ports 80 and 443 only, configurable via
    CRAWL4AI_UPSTREAM_PROXY_DNS_PORTS. The pinned path leaves the port open but
    requires a global address; passthrough gives up exactly that check, so
    without a port policy a listed suffix could reach :9200 or :5432 rather
    than a web server;
  - NO_PROXY still exempts hosts, matched by name since no IP is learned.

Names are compared in normalised form — case-folded, root dot stripped,
IDNA-encoded, length checked before and after encoding — and that normalised
name is what is sent to the upstream, so the name the allowlist authorised is
the name that goes on the wire rather than the caller's original spelling.

Fifty-four tests cover the contract, including what the upstream actually
receives: a listed name reaches it unresolved while a name outside the list
still arrives pinned, three spellings of one name all arrive as the single
authorised form, an IP literal is refused with zero upstream traffic, ports
outside the allowed set fall back to the pin, a set-but-unusable port list
closes rather than opens, and an unset variable changes nothing. The delegated
plain-HTTP path carries upstream auth and Connection: close, so a reused client
connection cannot smuggle a second, unchecked request upstream.

CRAWL4AI_UPSTREAM_PROXY_ONLY_SUFFIXES narrows which destinations are chained at
all. Chaining is otherwise all-or-nothing — with an upstream set every target
goes through it, and NO_PROXY only subtracts exceptions from that — so a single
deployment cannot serve an internal-only upstream and direct public egress at
the same time, because the public set cannot be enumerated in NO_PROXY. Sending
public crawls through the upstream anyway is not just wasteful: an upstream that
authorises per destination refuses them, and the fetch is attributed to its
network rather than ours. Listing suffixes here inverts the rule for those names
— they are chained, everything else dials direct.

It composes with CRAWL4AI_UPSTREAM_PROXY_DNS_SUFFIXES rather than overriding it:
a name may be handed over unresolved only if it is also allowed to use the
upstream, so neither list can widen what the other permits. Unset, the default,
leaves every path byte-identical to today. The change only ever adds a reason to
skip the upstream, so a target that is not chained is resolved and pinned exactly
as before — the pin covers strictly more traffic than it did, never less.

Six further tests cover it: an unset variable changes nothing, a listed suffix is
still chained as the pinned IP, an unlisted host produces zero upstream traffic,
a substring such as notcorp.example does not match .corp.example, delegation is
refused when the DNS list allows a name the upstream allowlist does not, and
wildcard or malformed entries are ignored with the list then behaving as absent.
@fchinch
fchinch force-pushed the feat/upstream-proxy-hostname-passthrough branch from eb3dbf9 to 4dd8b3b Compare August 19, 2026 03:34
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.

2 participants