WebSocket proxy should inject X-Forwarded-Host (and preserve original Host for downstream services) #47
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_router#47
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
When
hero_routertunnels a WebSocket upgrade to a service's Unix socket, it hard-codes the backendHostheader tolocalhostand does not forward the original browserHostvalue to the downstream service. The browser-suppliedOriginheader, however, is passed through unchanged. Any downstream service that performs an Origin/Host consistency check on WS upgrades will therefore reject every proxied connection with403 Forbidden.This is a cross-service infrastructure bug — it currently breaks the VM console in
hero_computewhen accessed through the hero_os desktop (see companion issue lhumina_code/hero_compute#102), and any other service that adds the same defensive check will hit the same problem.Where the header is lost
crates/hero_router/src/server/routes.rs→proxy_ws_tunnel():The original
Hostis dropped on the floor.ws_proxy_inner()already injectsX-Forwarded-PrefixandX-Hero-Context, but notX-Forwarded-Host.Proposed fix
In
ws_proxy_inner()(and for symmetry, the non-WSservice_proxy_inner()if it behaves the same way), inject the original host into the header list before tunneling:where
original_hostis read fromreq.headers().get("host")before the request is taken apart.Downstream services (hero_compute in the linked issue, and any future WS-using service) can then use
X-Forwarded-Hostfor their Origin-check logic, while keeping the backendHost: localhostconvention that works for Unix-socket dispatch.Acceptance
X-Forwarded-Hostis present in the upgrade request reaching the backend, containing the value of theHostheader the browser sent to hero_router.X-Forwarded-Prefix/X-Hero-Contextinjection behaviour is preserved.Implementation Specification: Inject
X-Forwarded-Hostin WebSocket (and HTTP) ProxyObjective
Fix hero_router issue #47 by injecting an
X-Forwarded-Hostheader into proxied requests so downstream services can recover the original browser-suppliedHostfor Origin/Host consistency checks on WebSocket upgrades. The primary fix targets WebSocket tunneling (ws_proxy_inner/proxy_ws_tunnel); for symmetry and consistency, the non-WS HTTP proxy path (service_proxy_inner) is also updated.Requirements
X-Forwarded-Hostequal to the value of theHostheader the browser sent to hero_router.Host: localhost(required for Unix-socket dispatch).X-Forwarded-PrefixandX-Hero-ContextMUST be preserved.Hostheader, the proxy MUST NOT panic; it omitsX-Forwarded-Host.X-Forwarded-Hostheader, hero_router MUST overwrite it with the authoritative value derived fromHost.Files to Modify/Create
crates/hero_router/src/server/routes.rs— MODIFY. Contains bothws_proxy_inner()andservice_proxy_inner(). All injection sites and the WS tunnel builder live here.No new files are created.
Step-by-step Implementation Plan
Step 1 — Extract original
Hostinws_proxy_innerand add it to the inject listFile:
crates/hero_router/src/server/routes.rsFunction:
ws_proxy_innerDependencies: none
Before the request parts are consumed for the tunnel, read the
Hostheader offreq.headers(). Build the inject vector conditionally: always pushX-Forwarded-PrefixandX-Hero-Contextas today, and additionally pushX-Forwarded-Hostwhen a non-emptyHostwas present on the inbound request.Step 2 — Confirm
proxy_ws_tunnelcorrectly de-duplicates the new headerFile:
crates/hero_router/src/server/routes.rsFunction:
proxy_ws_tunnelDependencies: Step 1
Verify (no code change expected) that:
injectedvector lowercases every injected key.hostheader explicitly and skips any header whose lowercased name appears ininjected.Step 3 — Inject
X-Forwarded-Hostin everyservice_proxy_innerbranch (non-WS path)File:
crates/hero_router/src/server/routes.rsFunction:
service_proxy_innerDependencies: none (parallelizable with Step 1)
Extract the original host once, near the top of the function:
Then in each branch that constructs an
injectvector and callsproxy_to_socket(rpc, admin, rest/api, default), appendX-Forwarded-Hostwhenforwarded_hostisSome. Change theadminand default-branchinjectbindings tomutso the push compiles. Thepythonarm returns early and does not callproxy_to_socket, so it is skipped.Step 4 — Verify
proxy_to_socketheader-forwarding logic is consistentFile:
crates/hero_router/src/server/routes.rsFunction:
proxy_to_socketDependencies: Step 3
No code change expected. Re-read the header-forwarding loop and verify:
Hostis unconditionally skipped.Step 5 — Build
Dependencies: Steps 1–4
Run
cargo build -p hero_routerfrom the hero_router root and resolve any compilation errors.Step 6 — Smoke test (optional)
Dependencies: Step 5
End-to-end verification: hit a WS-tunneling service through hero_router and confirm the backend receives
X-Forwarded-Hostmatching the inbound browserHost, whileHost: localhostis preserved.Acceptance Criteria
ws_proxy_innerextracts the inboundHostand injectsX-Forwarded-HostalongsideX-Forwarded-Prefix/X-Hero-Context.service_proxy_innerinjectsX-Forwarded-Hostin the rpc, admin, rest/api, and default webname branches.Host: localhostis still sent to the backend over the Unix socket.X-Forwarded-PrefixandX-Hero-Contextinjection is unchanged in behaviour.X-Forwarded-Hostheader has it overwritten by the router-injected value.Hostheader proxies successfully.cargo build -p hero_routercompiles with no new warnings.Notes
X-Forwarded-Hostis injected (overriding), not merely forwarded, so a client cannot forge a different host to bypass Origin/Host checks on downstream services.Hostedge case. IfHostis absent or non-UTF8,X-Forwarded-Hostis omitted entirely; downstream services must treat its absence as "unknown origin" and fall back to their existing policy.proxy_to_socketmeans there is no risk of a header being injected twice due to case differences.proxy_ws_tunnelandproxy_to_socketaccept the inject headers as genericVec/slice of (key, value) tuples.terminal.rs,sse.rs,rpc.rs, ormcp.rs.Test Results
Command:
cargo test -p hero_routerBranch:
development_ws_proxy_forwarded_hostBuild: PASS
Implementation Summary
Branch:
development_ws_proxy_forwarded_hostChanges
crates/hero_router/src/server/routes.rsws_proxy_inner: extract the inboundHostheader and injectX-Forwarded-Hostalongside the existingX-Forwarded-Prefix/X-Hero-Contextentries before callingproxy_ws_tunnel.service_proxy_inner: read the inboundHostonce near the top of the function and injectX-Forwarded-Hostin every branch that callsproxy_to_socket(rpc, admin, rest/api, default). Theadminand default-branchinjectbindings were changed tolet mutto support the conditional push; thepythonbranch was left alone since it returns early without proxying.Host: localhostis still sent to the backend over the Unix socket in both paths.Hostheader (or it is empty / non-UTF8),X-Forwarded-Hostis simply not injected — no panic, no empty-string leak.proxy_ws_tunnelandproxy_to_socketalready uses lowercased / case-insensitive comparisons, so any attacker-suppliedX-Forwarded-Hoston the inbound request is transparently overwritten by the router-injected value.Test Results
cargo test -p hero_routerNotes
routes.rswere touched;terminal.rs,sse.rs,rpc.rs, andmcp.rswere out of scope.X-Forwarded-Hostfor the VM console Origin/Host check.