Feat/upstream proxy hostname passthrough - #2152
Open
fchinch wants to merge 2 commits into
Open
Conversation
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
force-pushed
the
feat/upstream-proxy-hostname-passthrough
branch
5 times, most recently
from
August 18, 2026 21:53
1d5429d to
672c282
Compare
6 tasks
fchinch
force-pushed
the
feat/upstream-proxy-hostname-passthrough
branch
from
August 19, 2026 03:32
672c282 to
eb3dbf9
Compare
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
force-pushed
the
feat/upstream-proxy-hostname-passthrough
branch
from
August 19, 2026 03:34
eb3dbf9 to
4dd8b3b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
CONNECTto 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:resolve_and_pinfails before the upstream is ever consulted;CONNECTto 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
resolve_and_pin()for everythingresolve_and_pin()resolve_and_pin()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.examplecoverswiki.corp.exampleandwiki.corp.example.but nevercorp.example.attacker.comornotcorp.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,:5432or:2375rather 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.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 thecrawl4aipackage installed.