WebSocket proxy should inject X-Forwarded-Host (and preserve original Host for downstream services) #47

Closed
opened 2026-04-22 13:47:00 +00:00 by rawan · 3 comments
Member

Summary

When hero_router tunnels a WebSocket upgrade to a service's Unix socket, it hard-codes the backend Host header to localhost and does not forward the original browser Host value to the downstream service. The browser-supplied Origin header, however, is passed through unchanged. Any downstream service that performs an Origin/Host consistency check on WS upgrades will therefore reject every proxied connection with 403 Forbidden.

This is a cross-service infrastructure bug — it currently breaks the VM console in hero_compute when 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.rsproxy_ws_tunnel():

let mut backend_builder = hyper::Request::builder()
    .method(hyper::Method::GET)
    .uri(&forward_uri)
    .header("Host", "localhost");

// ... later: copy every header except "host"
for (key, value) in &parts.headers {
    if key == "host" { continue; }
    ...
}

The original Host is dropped on the floor. ws_proxy_inner() already injects X-Forwarded-Prefix and X-Hero-Context, but not X-Forwarded-Host.

Proposed fix

In ws_proxy_inner() (and for symmetry, the non-WS service_proxy_inner() if it behaves the same way), inject the original host into the header list before tunneling:

let inject = vec![
    ("X-Forwarded-Prefix".to_string(), prefix),
    ("X-Hero-Context".to_string(), state.context.to_string()),
    // NEW:
    ("X-Forwarded-Host".to_string(), original_host),
];

where original_host is read from req.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-Host for their Origin-check logic, while keeping the backend Host: localhost convention that works for Unix-socket dispatch.

Acceptance

  • X-Forwarded-Host is present in the upgrade request reaching the backend, containing the value of the Host header the browser sent to hero_router.
  • Existing X-Forwarded-Prefix / X-Hero-Context injection behaviour is preserved.
  • VM console in hero_compute (issue #102) works both standalone and embedded after the paired fix lands.
## Summary When `hero_router` tunnels a WebSocket upgrade to a service's Unix socket, it hard-codes the backend `Host` header to `localhost` and does **not** forward the original browser `Host` value to the downstream service. The browser-supplied `Origin` header, however, is passed through unchanged. Any downstream service that performs an Origin/Host consistency check on WS upgrades will therefore reject every proxied connection with `403 Forbidden`. This is a cross-service infrastructure bug — it currently breaks the VM console in `hero_compute` when accessed through the hero_os desktop (see companion issue [lhumina_code/hero_compute#102](https://forge.ourworld.tf/lhumina_code/hero_compute/issues/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()`: ```rust let mut backend_builder = hyper::Request::builder() .method(hyper::Method::GET) .uri(&forward_uri) .header("Host", "localhost"); // ... later: copy every header except "host" for (key, value) in &parts.headers { if key == "host" { continue; } ... } ``` The original `Host` is dropped on the floor. `ws_proxy_inner()` already injects `X-Forwarded-Prefix` and `X-Hero-Context`, but not `X-Forwarded-Host`. ## Proposed fix In `ws_proxy_inner()` (and for symmetry, the non-WS `service_proxy_inner()` if it behaves the same way), inject the original host into the header list before tunneling: ```rust let inject = vec![ ("X-Forwarded-Prefix".to_string(), prefix), ("X-Hero-Context".to_string(), state.context.to_string()), // NEW: ("X-Forwarded-Host".to_string(), original_host), ]; ``` where `original_host` is read from `req.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-Host` for their Origin-check logic, while keeping the backend `Host: localhost` convention that works for Unix-socket dispatch. ## Acceptance - `X-Forwarded-Host` is present in the upgrade request reaching the backend, containing the value of the `Host` header the browser sent to hero_router. - Existing `X-Forwarded-Prefix` / `X-Hero-Context` injection behaviour is preserved. - VM console in hero_compute (issue #102) works both standalone and embedded after the paired fix lands.
Author
Member

Implementation Specification: Inject X-Forwarded-Host in WebSocket (and HTTP) Proxy

Objective

Fix hero_router issue #47 by injecting an X-Forwarded-Host header into proxied requests so downstream services can recover the original browser-supplied Host for 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

  • The upgrade request reaching the backend MUST contain X-Forwarded-Host equal to the value of the Host header the browser sent to hero_router.
  • The backend request MUST continue to use Host: localhost (required for Unix-socket dispatch).
  • Existing injection behaviour for X-Forwarded-Prefix and X-Hero-Context MUST be preserved.
  • If the inbound request has no Host header, the proxy MUST NOT panic; it omits X-Forwarded-Host.
  • If an attacker-supplied request already contains an X-Forwarded-Host header, hero_router MUST overwrite it with the authoritative value derived from Host.
  • The change MUST compile cleanly under the existing workspace Rust toolchain and MUST not introduce new warnings.

Files to Modify/Create

  • crates/hero_router/src/server/routes.rs — MODIFY. Contains both ws_proxy_inner() and service_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 Host in ws_proxy_inner and add it to the inject list

File: crates/hero_router/src/server/routes.rs
Function: ws_proxy_inner
Dependencies: none

Before the request parts are consumed for the tunnel, read the Host header off req.headers(). Build the inject vector conditionally: always push X-Forwarded-Prefix and X-Hero-Context as today, and additionally push X-Forwarded-Host when a non-empty Host was present on the inbound request.

Step 2 — Confirm proxy_ws_tunnel correctly de-duplicates the new header

File: crates/hero_router/src/server/routes.rs
Function: proxy_ws_tunnel
Dependencies: Step 1

Verify (no code change expected) that:

  • The injected vector lowercases every injected key.
  • The forward-headers loop skips the host header explicitly and skips any header whose lowercased name appears in injected.

Step 3 — Inject X-Forwarded-Host in every service_proxy_inner branch (non-WS path)

File: crates/hero_router/src/server/routes.rs
Function: service_proxy_inner
Dependencies: none (parallelizable with Step 1)

Extract the original host once, near the top of the function:

let forwarded_host: Option<String> = headers
    .get(axum::http::header::HOST)
    .and_then(|v| v.to_str().ok())
    .filter(|s| !s.is_empty())
    .map(|s| s.to_string());

Then in each branch that constructs an inject vector and calls proxy_to_socket (rpc, admin, rest/api, default), append X-Forwarded-Host when forwarded_host is Some. Change the admin and default-branch inject bindings to mut so the push compiles. The python arm returns early and does not call proxy_to_socket, so it is skipped.

Step 4 — Verify proxy_to_socket header-forwarding logic is consistent

File: crates/hero_router/src/server/routes.rs
Function: proxy_to_socket
Dependencies: Step 3

No code change expected. Re-read the header-forwarding loop and verify:

  • Host is unconditionally skipped.
  • The injected-keys de-duplication uses case-insensitive comparison.

Step 5 — Build

Dependencies: Steps 1–4

Run cargo build -p hero_router from 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-Host matching the inbound browser Host, while Host: localhost is preserved.

Acceptance Criteria

  • ws_proxy_inner extracts the inbound Host and injects X-Forwarded-Host alongside X-Forwarded-Prefix / X-Hero-Context.
  • service_proxy_inner injects X-Forwarded-Host in the rpc, admin, rest/api, and default webname branches.
  • Host: localhost is still sent to the backend over the Unix socket.
  • Existing X-Forwarded-Prefix and X-Hero-Context injection is unchanged in behaviour.
  • An inbound request that already carries an X-Forwarded-Host header has it overwritten by the router-injected value.
  • A request with no Host header proxies successfully.
  • cargo build -p hero_router compiles with no new warnings.
  • When paired with the downstream fix in hero_compute (issue #102), the VM console works both standalone and when embedded via the hero_os desktop.

Notes

  • Trust boundary. hero_router is the edge of the trust boundary: the new X-Forwarded-Host is injected (overriding), not merely forwarded, so a client cannot forge a different host to bypass Origin/Host checks on downstream services.
  • Missing Host edge case. If Host is absent or non-UTF8, X-Forwarded-Host is omitted entirely; downstream services must treat its absence as "unknown origin" and fall back to their existing policy.
  • Header name casing. The lowercased injected list plus case-insensitive de-dup in proxy_to_socket means there is no risk of a header being injected twice due to case differences.
  • No signature changes. Both proxy_ws_tunnel and proxy_to_socket accept the inject headers as generic Vec/slice of (key, value) tuples.
  • Scope discipline. This change deliberately does NOT touch terminal.rs, sse.rs, rpc.rs, or mcp.rs.
# Implementation Specification: Inject `X-Forwarded-Host` in WebSocket (and HTTP) Proxy ## Objective Fix hero_router issue #47 by injecting an `X-Forwarded-Host` header into proxied requests so downstream services can recover the original browser-supplied `Host` for 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 - The upgrade request reaching the backend MUST contain `X-Forwarded-Host` equal to the value of the `Host` header the browser sent to hero_router. - The backend request MUST continue to use `Host: localhost` (required for Unix-socket dispatch). - Existing injection behaviour for `X-Forwarded-Prefix` and `X-Hero-Context` MUST be preserved. - If the inbound request has no `Host` header, the proxy MUST NOT panic; it omits `X-Forwarded-Host`. - If an attacker-supplied request already contains an `X-Forwarded-Host` header, hero_router MUST overwrite it with the authoritative value derived from `Host`. - The change MUST compile cleanly under the existing workspace Rust toolchain and MUST not introduce new warnings. ## Files to Modify/Create - `crates/hero_router/src/server/routes.rs` — MODIFY. Contains both `ws_proxy_inner()` and `service_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 `Host` in `ws_proxy_inner` and add it to the inject list **File:** `crates/hero_router/src/server/routes.rs` **Function:** `ws_proxy_inner` **Dependencies:** none Before the request parts are consumed for the tunnel, read the `Host` header off `req.headers()`. Build the inject vector conditionally: always push `X-Forwarded-Prefix` and `X-Hero-Context` as today, and additionally push `X-Forwarded-Host` when a non-empty `Host` was present on the inbound request. ### Step 2 — Confirm `proxy_ws_tunnel` correctly de-duplicates the new header **File:** `crates/hero_router/src/server/routes.rs` **Function:** `proxy_ws_tunnel` **Dependencies:** Step 1 Verify (no code change expected) that: - The `injected` vector lowercases every injected key. - The forward-headers loop skips the `host` header explicitly and skips any header whose lowercased name appears in `injected`. ### Step 3 — Inject `X-Forwarded-Host` in every `service_proxy_inner` branch (non-WS path) **File:** `crates/hero_router/src/server/routes.rs` **Function:** `service_proxy_inner` **Dependencies:** none (parallelizable with Step 1) Extract the original host once, near the top of the function: ```rust let forwarded_host: Option<String> = headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); ``` Then in each branch that constructs an `inject` vector and calls `proxy_to_socket` (rpc, admin, rest/api, default), append `X-Forwarded-Host` when `forwarded_host` is `Some`. Change the `admin` and default-branch `inject` bindings to `mut` so the push compiles. The `python` arm returns early and does not call `proxy_to_socket`, so it is skipped. ### Step 4 — Verify `proxy_to_socket` header-forwarding logic is consistent **File:** `crates/hero_router/src/server/routes.rs` **Function:** `proxy_to_socket` **Dependencies:** Step 3 No code change expected. Re-read the header-forwarding loop and verify: - `Host` is unconditionally skipped. - The injected-keys de-duplication uses case-insensitive comparison. ### Step 5 — Build **Dependencies:** Steps 1–4 Run `cargo build -p hero_router` from 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-Host` matching the inbound browser `Host`, while `Host: localhost` is preserved. ## Acceptance Criteria - [ ] `ws_proxy_inner` extracts the inbound `Host` and injects `X-Forwarded-Host` alongside `X-Forwarded-Prefix` / `X-Hero-Context`. - [ ] `service_proxy_inner` injects `X-Forwarded-Host` in the rpc, admin, rest/api, and default webname branches. - [ ] `Host: localhost` is still sent to the backend over the Unix socket. - [ ] Existing `X-Forwarded-Prefix` and `X-Hero-Context` injection is unchanged in behaviour. - [ ] An inbound request that already carries an `X-Forwarded-Host` header has it overwritten by the router-injected value. - [ ] A request with no `Host` header proxies successfully. - [ ] `cargo build -p hero_router` compiles with no new warnings. - [ ] When paired with the downstream fix in hero_compute (issue #102), the VM console works both standalone and when embedded via the hero_os desktop. ## Notes - **Trust boundary.** hero_router is the edge of the trust boundary: the new `X-Forwarded-Host` is *injected* (overriding), not merely *forwarded*, so a client cannot forge a different host to bypass Origin/Host checks on downstream services. - **Missing `Host` edge case.** If `Host` is absent or non-UTF8, `X-Forwarded-Host` is omitted entirely; downstream services must treat its absence as "unknown origin" and fall back to their existing policy. - **Header name casing.** The lowercased injected list plus case-insensitive de-dup in `proxy_to_socket` means there is no risk of a header being injected twice due to case differences. - **No signature changes.** Both `proxy_ws_tunnel` and `proxy_to_socket` accept the inject headers as generic `Vec`/slice of (key, value) tuples. - **Scope discipline.** This change deliberately does NOT touch `terminal.rs`, `sse.rs`, `rpc.rs`, or `mcp.rs`.
Author
Member

Test Results

Command: cargo test -p hero_router
Branch: development_ws_proxy_forwarded_host

  • Total: 71
  • Passed: 71
  • Failed: 0
  • Ignored: 0

Build: PASS

## Test Results Command: `cargo test -p hero_router` Branch: `development_ws_proxy_forwarded_host` - Total: 71 - Passed: 71 - Failed: 0 - Ignored: 0 Build: PASS
Author
Member

Implementation Summary

Branch: development_ws_proxy_forwarded_host

Changes

  • crates/hero_router/src/server/routes.rs
    • ws_proxy_inner: extract the inbound Host header and inject X-Forwarded-Host alongside the existing X-Forwarded-Prefix / X-Hero-Context entries before calling proxy_ws_tunnel.
    • service_proxy_inner: read the inbound Host once near the top of the function and inject X-Forwarded-Host in every branch that calls proxy_to_socket (rpc, admin, rest/api, default). The admin and default-branch inject bindings were changed to let mut to support the conditional push; the python branch was left alone since it returns early without proxying.
    • Host: localhost is still sent to the backend over the Unix socket in both paths.
    • If the inbound request has no Host header (or it is empty / non-UTF8), X-Forwarded-Host is simply not injected — no panic, no empty-string leak.
    • Header de-dup in proxy_ws_tunnel and proxy_to_socket already uses lowercased / case-insensitive comparisons, so any attacker-supplied X-Forwarded-Host on the inbound request is transparently overwritten by the router-injected value.

Test Results

  • Command: cargo test -p hero_router
  • Total: 71, Passed: 71, Failed: 0, Ignored: 0
  • Build: PASS, no new warnings

Notes

  • No public or internal signatures were changed.
  • No files other than routes.rs were touched; terminal.rs, sse.rs, rpc.rs, and mcp.rs were out of scope.
  • The paired downstream change lives in hero_compute issue #102 and will consume X-Forwarded-Host for the VM console Origin/Host check.
## Implementation Summary Branch: `development_ws_proxy_forwarded_host` ### Changes - `crates/hero_router/src/server/routes.rs` - `ws_proxy_inner`: extract the inbound `Host` header and inject `X-Forwarded-Host` alongside the existing `X-Forwarded-Prefix` / `X-Hero-Context` entries before calling `proxy_ws_tunnel`. - `service_proxy_inner`: read the inbound `Host` once near the top of the function and inject `X-Forwarded-Host` in every branch that calls `proxy_to_socket` (rpc, admin, rest/api, default). The `admin` and default-branch `inject` bindings were changed to `let mut` to support the conditional push; the `python` branch was left alone since it returns early without proxying. - `Host: localhost` is still sent to the backend over the Unix socket in both paths. - If the inbound request has no `Host` header (or it is empty / non-UTF8), `X-Forwarded-Host` is simply not injected — no panic, no empty-string leak. - Header de-dup in `proxy_ws_tunnel` and `proxy_to_socket` already uses lowercased / case-insensitive comparisons, so any attacker-supplied `X-Forwarded-Host` on the inbound request is transparently overwritten by the router-injected value. ### Test Results - Command: `cargo test -p hero_router` - Total: 71, Passed: 71, Failed: 0, Ignored: 0 - Build: PASS, no new warnings ### Notes - No public or internal signatures were changed. - No files other than `routes.rs` were touched; `terminal.rs`, `sse.rs`, `rpc.rs`, and `mcp.rs` were out of scope. - The paired downstream change lives in hero_compute issue #102 and will consume `X-Forwarded-Host` for the VM console Origin/Host check.
rawan closed this issue 2026-04-23 09:31:21 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_router#47
No description provided.