Skip to content

Feat: Enforce the CONNECT-host check on decrypted requests - #1538

Closed
mrsabath wants to merge 1 commit into
mainfrom
feat/ws-upgrade-host-check
Closed

mrsabath wants to merge 1 commit into
mainfrom
feat/ws-upgrade-host-check

Conversation

@mrsabath

Copy link
Copy Markdown
Member

Summary

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 (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.

bridgedHandler already sets r.URL.Host to the CONNECT authority
(server.go:952), so the bytes always reach the origin the CONNECT named, whatever
Host says. What a mismatched Host reaches is the pipeline: pctx.Host is what a
plugin 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: elsewhere inside the tunnel shows one name to the policy layer and another to
the 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

bridgedHandler is the only insertion point. It is the sole path where a decrypted
request has a known CONNECT authority to be checked against. On an ordinary
forward-proxy request there is no second name to disagree with — the Host is the
destination — so there is nothing to check.

Compared on host only, port excluded. A client may legitimately omit a default
:443 from Host; hostOnly(authority) is already how the terminator is given its
name (server.go:826); and a port disagreement cannot reach a different origin because
the dial uses the authority, not Host. A port check would refuse correct requests and
buy no safety.

Read from r.Host, not the Host header. net/http moves HTTP/1.1's Host header
there, and for HTTP/2 it is already the :authority pseudo-header. Reading the header
directly 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 Upgrade strip is deliberate and asserted, not a bug: PR #1414 added the
UpgradeStripped marker so the resulting Codex 405 would be explainable, and
TestStripForUpstream_StillRemovesTheUpgrade pins the strip with a comment saying a
change 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.Do never surrenders the connection on a 101.

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 note
empirically, and answers three of its four open questions:

Question #1467's answer
UpgradeAllowed field? Not needed — a carried socket leaves UpgradeStripped empty and is identified by tunnelReason: "websocket"
Idle timeouts for a long-lived socket? Model it as a tunnel, not a request; tunnel rows are already out of /v1/usage
h2c? Moot — Go's HTTP/2 server refuses a request carrying Upgrade with a 400, so h2 arrives negotiated, never upgraded

Notably #1467 did not amend TestStripForUpstream_StillRemovesTheUpgrade — the
strip survives as an explicit forward mode — which is a useful precedent that the
carve-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 wsframe relay with parsing switched off once it lands, rather
than writing a second codec — and recommends not starting before then, since it is
under review and its internals may move.

Test plan

  • Mismatch → 421 plus a host_mismatch denial event naming both hosts (either alone looks unremarkable; the disagreement is the diagnostic)
  • Matching Host reaches a TLS origin untouched and records no mismatch event
  • Four port/case variants all match: default :443 omitted, port present, non-443 origin, differing case
  • Empty Host refused — pins against reading the Host header instead of r.Host
  • Full core/listener/forwardproxy suite passes, including the bridged and tlsbridge integration tests
  • go vet ./listener/forwardproxy/ clean; gofmt -l clean

core/plugins and core/plugins/opa fail to build in my sandbox (they need cgo and
xcode-select is unavailable there); I confirmed they fail identically on unmodified
main, so it is environmental and unrelated. Every other core package passes.

Refs #1488

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

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>
@mrsabath
mrsabath requested a review from a team as a code owner October 29, 2026 01:14
@coderabbitai

coderabbitai Bot commented Oct 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You'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.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a54ed3a7-819c-4a47-ac14-d0912987bb18
Commits

Reviewing files that changed from the base of the PR and between e195bb1 and 05159bc.

Files selected for processing: 3
  • core/listener/forwardproxy/hostmismatch_test.go
  • core/listener/forwardproxy/server.go
  • docs/superpowers/specs/2026-10-28-intercepted-websocket-upgrades-design.md
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

// 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
@mrsabath

Copy link
Copy Markdown
Member Author

Closing: this duplicates work already on main, and does it worse.

872327e ("fix(forwardproxy): refuse bridged requests whose Host is not the
tunnel's", 2026-10-16) already implements the §8.5 item 5 Host check via
tunnelAuthorityMatches (server.go:1115), called at server.go:357. I missed it
because git fetch failed in my sandbox and I worked from a stale local main
predating that commit.

The existing implementation is better than what this PR proposed, and CI caught the
difference as a real bug in mine:

  • Ports must be compared, and mine did not. A bare Host implies :443, so
    Host: support.example against a tunnel to support.example:8443 is a genuine
    mismatch. TestTunnelAuthorityMatches pins exactly that case; my version treated it
    as a match, and my own test asserting the wrong behaviour failed in CI.
  • tunnelAuthorityMatches also handles trailing dots, IPv6 normalisation
    ([::1] vs [0:0::1]), empty ports and invalid ports. Mine covered none of these.
  • Its insertion point is better: inside the request path, recording a paired
    request + response row, rather than a standalone denial event beside the pipeline.

Nothing here is worth salvaging, so there is no replacement PR.

Refs #1488

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

@mrsabath mrsabath closed this Oct 29, 2026
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants