Repository navigation
Conversation
MOCA MI1 §8.5 item 5 requires that a decrypted request's Host equal the host its CONNECT named, else 421 and an audit event. Cortex enforced nothing like it: a search for 421 / StatusMisdirectedRequest across the tree returns only an unrelated number in a comment. bridgedHandler already sets r.URL.Host to the CONNECT authority, so the bytes always reached the right origin whatever Host said. What a mismatched Host reached was the PIPELINE — pctx.Host is what a plugin reads to choose a credential, a route or a policy. A guest that opened a CONNECT to an allowlisted host and then sent `Host: elsewhere` inside the tunnel therefore showed one name to the policy layer and another to the socket, and a credential chosen for the first would travel to the second. bridgedHandler is the only insertion point: it is the sole path where a decrypted request has a known CONNECT authority to check against. On an ordinary forward-proxy request there is no second name to disagree with. Compared on host only. A client may legitimately omit a default :443 from Host, hostOnly(authority) is already how the terminator is given its name, and a port disagreement cannot reach a different origin because the dial uses the authority — so a port check would refuse correct requests for no safety. Read from r.Host, not the Host header: net/http moves HTTP/1.1's there and fills it from :authority on h2, so reading the header sees "" on both and would pass everything. A test pins that. The WebSocket half of item 5 is designed, not implemented, and the design note says why: the Upgrade strip is deliberate and asserted (TestStripForUpstream_StillRemovesTheUpgrade, from #1414's marker work), and allowing an upgrade needs a relay path — Client.Do does not surrender the connection on a 101 — rather than a header-strip tweak. Refs #1488 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 6 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| // requests and buy no safety. | ||
| func (s *Server) hostMatchesConnect(w http.ResponseWriter, r *http.Request, authority string, tl *tunnelLog) bool { | ||
| want := hostOnly(authority) | ||
| // r.Host, not r.Header.Get("Host"): net/http moves HTTP/1.1's Host header |
| // requests and buy no safety. | ||
| func (s *Server) hostMatchesConnect(w http.ResponseWriter, r *http.Request, authority string, tl *tunnelLog) bool { | ||
| want := hostOnly(authority) | ||
| // r.Host, not r.Header.Get("Host"): net/http moves HTTP/1.1's Host header |
|
Closing: this duplicates work already on 872327e ("fix(forwardproxy): refuse bridged requests whose Host is not the The existing implementation is better than what this PR proposed, and CI caught the
Nothing here is worth salvaging, so there is no replacement PR. Refs #1488 Assisted-By: Claude (Anthropic AI) noreply@anthropic.com |
Summary
MOCA MI1 §8.5 item 5 requires that a decrypted request's
Hostequal the host itsCONNECT named, else
421and an audit event. Cortex enforced nothing like it — asearch for
421/StatusMisdirectedRequestacross the tree returns only an unrelatednumber in a comment (
core/pipeline/promptcontext.go:34).This PR implements that check for every decrypted request, and adds a design note for
the WebSocket half of the same clause, which is deliberately not implemented here.
Why it matters, given the request is dialed correctly anyway
This is the part worth reading, because the check looks redundant at first glance.
bridgedHandleralready setsr.URL.Hostto the CONNECT authority(
server.go:952), so the bytes always reach the origin the CONNECT named, whateverHostsays. What a mismatchedHostreaches is the pipeline:pctx.Hostis what aplugin reads to choose a credential, a route, or a policy.
So a guest that opens a CONNECT to an allowlisted host and then sends
Host: elsewhereinside the tunnel shows one name to the policy layer and another tothe socket — and a credential chosen for the first would travel to the second.
Refusing the mismatch is what makes §8.5's "the credential decision is always made for
the host the connection is dialed to" true rather than merely usually true.
Design decisions
bridgedHandleris the only insertion point. It is the sole path where a decryptedrequest has a known CONNECT authority to be checked against. On an ordinary
forward-proxy request there is no second name to disagree with — the
Hostis thedestination — so there is nothing to check.
Compared on host only, port excluded. A client may legitimately omit a default
:443fromHost;hostOnly(authority)is already how the terminator is given itsname (
server.go:826); and a port disagreement cannot reach a different origin becausethe dial uses the authority, not
Host. A port check would refuse correct requests andbuy no safety.
Read from
r.Host, not theHostheader.net/httpmoves HTTP/1.1'sHostheaderthere, and for HTTP/2 it is already the
:authoritypseudo-header. Reading the headerdirectly sees
""on both, which would silently pass everything. A test pins this.The WebSocket half: designed, not built
docs/superpowers/specs/2026-10-28-intercepted-websocket-upgrades-design.md.The
Upgradestrip is deliberate and asserted, not a bug: PR #1414 added theUpgradeStrippedmarker so the resulting Codex405would be explainable, andTestStripForUpstream_StillRemovesTheUpgradepins the strip with a comment saying achange that kept the header "would pass the two tests above while breaking the behaviour
they describe." Allowing an upgrade also needs a relay path, not a header tweak —
Client.Donever surrenders the connection on a101.The note records that open PR #1467 ("Carry Codex's Responses WebSocket") already
builds that relay, scoped to OpenAI's hosts and gated by
listener.responses_websockets. That PR confirms every cost argument in the noteempirically, and answers three of its four open questions:
UpgradeAllowedfield?UpgradeStrippedempty and is identified bytunnelReason: "websocket"/v1/usageUpgradewith a 400, so h2 arrives negotiated, never upgradedNotably #1467 did not amend
TestStripForUpstream_StillRemovesTheUpgrade— thestrip survives as an explicit
forwardmode — which is a useful precedent that thecarve-out should be additive.
What #1467 does not do, and #1488 still needs: no Host check (this PR), and its
carry is limited to OpenAI because the payload is only understood for Codex, whereas a
MOCA sandbox's tools are arbitrary and need an opaque relay. The note recommends
generalising #1467's
wsframerelay with parsing switched off once it lands, ratherthan writing a second codec — and recommends not starting before then, since it is
under review and its internals may move.
Test plan
421plus ahost_mismatchdenial event naming both hosts (either alone looks unremarkable; the disagreement is the diagnostic)Hostreaches a TLS origin untouched and records no mismatch event:443omitted, port present, non-443 origin, differing caseHostrefused — pins against reading theHostheader instead ofr.Hostcore/listener/forwardproxysuite passes, including the bridged and tlsbridge integration testsgo vet ./listener/forwardproxy/clean;gofmt -lcleancore/pluginsandcore/plugins/opafail to build in my sandbox (they need cgo andxcode-selectis unavailable there); I confirmed they fail identically on unmodifiedmain, so it is environmental and unrelated. Every othercorepackage passes.Refs #1488
Assisted-By: Claude (Anthropic AI) noreply@anthropic.com