Repository navigation
fix: check the TLS server name inside a CONNECT tunnel - #24
Merged
Merged
Conversation
The egress checked a CONNECT tunnel by its host and port only. After the 200, the client chose the TLS server name. A shared front end routes on that name. Thus a client could open a tunnel to an allowed host and reach another tenant. Now the egress reads the first ClientHello before it dials. The server name must be the CONNECT host. The comparison ignores ASCII case and one trailing dot. The egress refuses a hello with another name, two names, or no name. It accepts no name only when the CONNECT names an address. It refuses a hello with Encrypted Client Hello or encrypted SNI. It refuses bytes that are not a TLS handshake. It reads at most 16 KiB and 5 bytes, for at most 10 seconds. A hello can span records and reads. On a refusal, the egress logs the reason, sends an access_denied alert, and closes the tunnel. It dials nothing. On a pass, it sends the read bytes upstream first, then copies both ways. The parser is in the new hello module, with no new dependency. The check cannot see inside TLS. Host-header fronting and paths on shared hosts stay open. The module documentation says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A comment on the google tunnel entries said that the egress does not check the TLS name inside a tunnel. It now does. The comment now says what the check cannot see: the paths and the HTTP Host behind the name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The egress checked only the first ClientHello of a tunnel. A front end can answer with a HelloRetryRequest. Then the client sends a second ClientHello. Some front ends read the server name again from the second hello. Thus a client could name an allowed host first and another tenant second. Now the egress reads the upstream's ServerHello and sends it on to the client. If the ServerHello is a HelloRetryRequest, the egress reads the second ClientHello. One ChangeCipherSpec record can come before it. The second hello must name the CONNECT host, as the first hello must. Until the ServerHello is read, the egress holds the other client bytes. The egress refuses a ClientHello whose record carries more bytes after it. The same size bound applies to each side. One wait applies to all the hellos together. The wait is a field on the egress, so a test can set it. The egress also refuses the draft ECH codepoints 0xfe00 to 0xfe0d. The log line names ECH when ECH is the reason. New tests: - a second hello that names another host goes nowhere, - a matching second hello goes upstream, - the egress tells a retry request from other answers, - a refused client gets the exact alert, - a tunnel with no hello closes when its wait ends. The module documentation says what the egress checks and what it does not. It cannot see a TLS 1.2 renegotiation, because that hello is encrypted. The README says that the egress refuses ECH, and GREASE ECH too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cond hello TLS 1.3 allows one HelloRetryRequest. The egress checked only the hello after the first retry request. Now the egress reads the answer to the second hello too. If that answer is a second retry request, the egress refuses the tunnel. A client that offers early data sends records of type 23 after its first hello. After a retry request, its second hello follows these records. The egress refused this order. Now it allows one ChangeCipherSpec and early-data records before the second hello. The early data is at most four full records. The egress refuses all other record types there. The bound for the second hello now includes the ChangeCipherSpec and the early data. Thus a second hello that fills a full record can follow them. New tests: - Bytes sent with the first hello stay held until the upstream answers. - One deadline applies to all reads, not one deadline for each read or step. - A second retry request closes the tunnel. - Early data before the second hello passes, and other records do not. - The bound for the second hello is tested at its limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd hello After a HelloRetryRequest, a server sends a dummy ChangeCipherSpec record. This record can arrive in its own read. The egress read it as the answer to the second hello. It took the record for an answer that asks for no retry. Thus a second retry request and a third hello went through. Now the answer reader skips one ChangeCipherSpec record before the ServerHello. It waits for more bytes while the record is not complete. It refuses a record of that type that is not a ChangeCipherSpec. The bytes after the first retry request start the next answer. Thus the egress also reads a record that is split across reads correctly. The tests now hold these rules: - The third hello stays held when it comes in the same write as the second. - The ChangeCipherSpec can come alone or split across reads. - The reason for too much early data must be exact. - A slow runner that refuses before the retry request does not make the timing test panic. The HELLO_WAIT doc now says that one deadline covers all the hellos and answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The answer reader skips one ChangeCipherSpec record before the ServerHello. A second record of that type made the reader return "no retry". Thus the egress opened the tunnel without reading what came after the record. Now the reader refuses any record of that type after the skipped one. It refuses at the first byte of the record. No server sends a second ChangeCipherSpec there, so the refusal stops no valid handshake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The timeout reads the inner future before it checks the deadline. Thus after a stall, bytes that were ready after the deadline passed the check. Now read_hello and read_answer compare the time with the deadline first. If the deadline has passed, they refuse with the timeout reason. A new test holds the bound for an answer. In this test, a ServerHello that fills one record comes after a ChangeCipherSpec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sstriker
requested review from
andrewmusselman
and
a balanced review from Copilot
October 25, 2026 00:55
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.
The egress checked a CONNECT tunnel only by its authority, the host and the port. After the 200, it copied bytes both ways. Inside the tunnel, the harness chose the TLS server name (SNI). So a harness could send
CONNECT lh3.googleusercontent.com:443and then a ClientHello with the name of another tenant on the same front end. An adversarial pass on #20 showed this through the real egress: GCS buckets and App Engine apps throughlh3.googleusercontent.com, other Cloudflare customers throughchatgpt.com, andraw.githubusercontent.comthroughapi.githubcopilot.com.The change
A new module,
crates/egress/src/hello.rs, parses the ClientHello by hand, with no new dependency. Every length is checked against the bytes that remain, and there is no indexing that can panic.connect_or_not_foundincrates/egress/src/lib.rsnow does these steps, in this order:A ClientHello passes only if its server name is the CONNECT host. Case and a trailing dot do not matter. The egress refuses these:
0xfe0dand the drafts0xfe00to0xfe0c) and the draft encrypted SNI (0xffce), because then the name in the clear is a decoy;Limits:
On a refusal, the egress writes
egress: tunnel to <host>: <reason>; closedto its log. It sends a TLSaccess_deniedalert to the client and closes the tunnel.What this does not close
The module documentation says this too. The egress cannot see inside TLS, so these stay open:
Hostheader inside TLS, and not on the server name.storage.googleapis.com, any repository onraw.githubusercontent.com.Effect on harnesses
An adversarial pass captured the first ClientHello of each TLS stack on this host through a fake CONNECT proxy. Each one passes: the name is the CONNECT host, and the hello has no ECH extension. The stacks were:
codex0.160.0.agyuses Go'scrypto/tls, which sends ECH only when it has an ECH config. A Chromium-based client sends GREASE ECH by default, so the egress refuses it. No harness here uses that stack.local/README.mdsays this.Tests
hello.rshas unit tests for these cases:lib.rshas tests through theNetworkstand-in from #13:Failing first
The new integration test, against
mainwith only the tests added:Each later commit shows its new assertions failing first, against the commit before it or under a mutation. Examples:
fa8ae79:the upstream hears nothing after it asked. The second hello reached the upstream.80fca25: the third hello, which namesother-tenant.example, reached the upstream.0d47686:taken after the deadline, withleft: ["a hello: Ok(77)", "a hello: Ok(77)", "an answer: Ok(Other)", "an answer: Ok(Other)"].make check: 680 tests, 680 passed, 6 skipped.Adversarial passes
Four fresh agents attacked this branch. Each round of findings is fixed in the commit after it.
c0591fe. It found the gap after a HelloRetryRequest: only the first hello was checked. It found four parts of the wiring that no test held: the alert, the close, the wait at the call site, and a record with more bytes. It found the ECH drafts. Fixed in3184fd1.3184fd1. It found no way to give the upstream a second name. Real rustls TLS 1.3 handshakes, with and without a real HelloRetryRequest, and TLS 1.2 handshakes passed through. It found that no test held the held bytes or the single deadline. It also found three gaps: a second HelloRetryRequest, early data before the second hello, and the size bound. Fixed in80fca25.80fca25. It found that the server's ChangeCipherSpec in its own read let a second HelloRetryRequest through. It also found one more untested hold and a test that could panic on a slow runner. Fixed inb7296d9. A second ChangeCipherSpec before the ServerHello is now refused (0d47686).0d47686. It found no correctness or security bug. A rustls handshake with a forced HelloRetryRequest passed byte for byte under five ways to split the server's writes. It found that no test held the size bound for an answer, and that the deadline was not strict after a stall. Fixed in6ac0e3d.One finding is declined. A refusal by name writes no telemetry row. The CONNECT refusals before this change also write none.
Posted by Claude on behalf of @sstriker