fix(renderer): connect CDP over every resolved address, not just the first - #565
Merged
Merged
Conversation
…first The DevTools ws_url host was resolved and the URL rewritten to the first address the resolver returned, which discarded the rest of the list. A `ws://localhost` endpoint against a browser bound to 127.0.0.1 only then failed outright whenever the resolver answered ::1 first, and every JS render fell back to the HTTP tier. Override the `Host` header instead of rewriting the URL. The hostname stays in the URI, so TcpStream::connect walks every resolved address, and the header is still a literal Chromium's DNS-rebinding guard accepts. Narrow on purpose: plaintext ws:// only, and only for a host that is not already an IP literal, so wss:// endpoints keep their Host, SNI and certificate name.
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
ref #564
What changed
crates/crw-renderer/src/cdp_conn.rs:CdpConnection::connectno longer resolves the ws_url host and rewrites the URL to the first resolved address. It builds the handshake request and overrides theHostheader instead.TcpStream::connectresolves, so it walks every address the resolver returns instead of the one we pinned.ws://only, and only when the host is not already an IP literal.wss://is untouched, so a hosted CDP endpoint keeps its real Host, SNI and certificate name. An IPv6 literal's brackets are stripped before the literal check, sows://[::1]:9222keeps its own Host.ws://localhost, asserting theHostheader the server receives.Why
ws://localhost:9223with a browser bound to127.0.0.1only hard-failed withCDP connect failed: io errorwhenever the resolver answered::1first, and every JS render silently fell back to the HTTP tier. HTTP discovery against the same host in the same request succeeded, because reqwest walks the address list.Chromium 148+ validates the DevTools WebSocket
Hostheader against a DNS-rebinding allowlist: onlylocalhostand IP literals pass. The earlier workaround for that resolved the host and rewrote the URL, which is what threw the address list away. The header was always the target; rewriting the URL was the side effect.Measured with raw WebSocket handshakes against a headless Chrome 153 bound to
127.0.0.1:9223:Hostsentlocalhost:9223HTTP/1.1 101localhostHTTP/1.1 101127.0.0.1:9223HTTP/1.1 101[::1]:9223HTTP/1.1 101chrome:9223HTTP/1.1 500localtest.me:9223HTTP/1.1 500Verified
Live
POST /v1/scrapeagainst a running server, same IPv4-only Chrome,https://quotes.toscrape.com/js/:ws://localhost:9223/renderedWith: http,js_escalation_failedrenderedWith: chrome, JS content, no warningws://localtest.me:9223/renderedWith: chromerenderedWith: chrome, JS content, no warningws://127.0.0.1:9223/renderedWith: chromerenderedWith: chrome, JS content, no warningws://localhost:9221/(lightpanda)renderedWith: http,CDP connect failed: io errorrenderedWith: lightpanda, JS contentws://localhost:9224/chromium?token=...(browserless)renderedWith: http,CDP connect failed: io errorrenderedWith: chrome, JS contentlocaltest.meis a real hostname resolving to127.0.0.1, so that row is the dockerws://chrome:9222shape. Chromium answersHost: localtest.me:9223with a 500, so it only works because the header is forced, which keeps the Chromium 148 guard fix intact.Run against a real
lightpanda serve --host 127.0.0.1 --port 9221(v0.27.1) and a realghcr.io/browserless/chromium:v2.27.0published on127.0.0.1:9224:3000, the same binddocker-compose.ymluses. So Chromium, LightPanda and browserless all accept the forcedHost, measured rather than assumed.cargo test -p crw-renderer --features cdp: 977 passed, 0 failed.cargo fmt --checkandcargo clippy --features cdp --all-targets -D warningsclean.Effect for self-hosters
ws://localhost:PORTworks as a renderer ws_url again when the browser listens on IPv4 only. Thews://127.0.0.1:PORTliteral is no longer needed as a workaround.This also unbreaks a shape we ship ourselves:
config.stealth.toml:47and:59point atws://localhost:9222/andws://localhost:9224/chromium?token=..., anddocker-compose.ymlpublisheschrome-stealthas127.0.0.1:9224:3000, an IPv4-only bind. On a host whose resolver answers::1first, that overlay hit the same failure.Production impact
Read off the running production engine, not inferred:
ws://172.30.40.31:9222/ws://172.30.40.32:9222/ws://lightpanda-cdp:9222/Hostbecomeslocalhost:9222Both Chromium tiers are byte-identical: an IP literal skips the override, so the request matches what
connect_asyncbuilds on its own, header for header, which a test locks. Only the lightpanda tier changes on the wire. Production pinslightpanda/browser@sha256:15213bb6...; that exact image, started with production's own command line, answers101to everyHosttried, includinglocalhost:9222, and a full render through it over a non-localhost hostname succeeds.Deploy caveats
No config change, no migration, no new dependency.
One behaviour change worth knowing: DNS resolution now happens inside the 10s CDP connect timeout instead of before it. A box whose first nameserver is dead could previously connect at around 11s and would now time out; in exchange, a wedged resolver can no longer hang the connect without a bound, which it could before.