fix(proxy): stop leaking the /proxy prefix and the sandbox address to clients - #174
fix(proxy): stop leaking the /proxy prefix and the sandbox address to clients#174suixinio wants to merge 2 commits into
Conversation
The prefix routes matched `/proxy` exactly and `/proxy/{*proxy_path}`
with a non-empty segment, so `/proxy/` matched neither and fell through
to the header-routed fallback, which forwards the path unmodified. The
sandbox then received `/proxy/` instead of `/`: applications without an
SPA fallback answer that with 404, and dev servers normalize the
trailing slash into a redirect that hands the internal `/proxy` prefix
back to the client.
The distributed gateway rewrites data-plane traffic to `"/proxy" + path`,
so a client request for `/` always arrives in exactly this shape. Every
proxied home page and every root-path websocket (Vite HMR) was affected,
while non-root paths were not.
There was a problem hiding this comment.
Pull request overview
This PR fixes two proxy-behavior leaks in the node-side sandbox reverse proxy (src/api/proxy.rs): (1) ensuring the gateway-generated root shape /proxy/ is routed through the prefix proxy handler (so the sandbox sees /), and (2) rewriting response Location / Content-Location headers that self-reference the sandbox’s internal ip:port so clients don’t see or follow unreachable internal addresses.
Changes:
- Add an explicit
/proxy/route that reuses the existingproxy_via_prefixhandler so/proxy/correctly forwards to upstream/. - Rewrite
LocationandContent-Locationheaders when they point back at the upstream authority, replacing them with a path-relative target. - Add unit tests covering
/proxy/forwarding, wildcard trailing-slash preservation, and redirect rewriting behavior.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
🔍 OpenCodeReview found 1 issue(s) in this PR.
|
| .and_then(|value| value.to_str().ok()) | ||
| .and_then(|value| value.parse::<Uri>().ok()) |
There was a problem hiding this comment.
http::Uri does not support URI fragments, but Location is allowed to contain a URI-reference such as http://10.x.y.z:5173/next#section. Parsing that value fails, so this code silently leaves the internal sandbox authority exposed and the redirect unusable. Split and preserve the optional fragment before parsing/comparing the URI, then append it to the rewritten path/query; cover this with a test.
There was a problem hiding this comment.
Fixed, with one correction to the mechanism. With http 1.x (this crate's dependency; 1.4.0 in the lockfile) "http://10.0.0.1:5173/next#section".parse::<Uri>() succeeds — it does not fail — and path_and_query() returns /next. So the fragment was silently dropped rather than the sandbox authority being left exposed. I checked 0.2.12 as well, same behavior:
http://10.0.0.1:5173/next#section OK auth=Some("10.0.0.1:5173") path="/next" pq=Some("/next")
The rewrite now splits the fragment off before parsing and appends it verbatim, so Location keeps #section. Covered by a unit case and by the HTTP-level test (/proxy/fragment).
| target | ||
| .authority() | ||
| .is_some_and(|authority| authority.as_str() == upstream_authority.as_str()) |
There was a problem hiding this comment.
Raw authority equality misses semantically identical self-references. In particular, when the sandbox target port is 80, URL libraries commonly normalize http://10.x.y.z:80/... to http://10.x.y.z/...; that redirect will bypass this rewrite and expose the internal address. Compare parsed hosts case-insensitively and compare effective ports (including the scheme's default port) rather than authority text, and test a target using port 80.
There was a problem hiding this comment.
Fixed. Self-references are now matched by host (case-insensitive) and effective port — the scheme's default is filled in (80 for http/ws, 443 for https/wss) — instead of by authority text. The upstream URL always carries an explicit port, so a target whose port cannot be determined never compares equal, which keeps the matcher conservative in the other direction.
Covered by a unit case with an upstream on port 80 and a target that omits it.
While there, I closed the same class for scheme-relative references: //10.0.0.1:5173/next parses with host() == None, so the old authority comparison let it through as well. It is now parsed against the upstream scheme before the comparison.
| target | ||
| .path_and_query() | ||
| .map(|path_and_query| path_and_query.as_str().to_owned()) | ||
| .unwrap_or_else(|| "/".to_owned()) |
There was a problem hiding this comment.
An absolute self-reference with an empty path and a query (for example, http://10.x.y.z:5173?x=1) has a path_and_query of ?x=1, so this emits a query-relative Location. The client then retains its current path rather than redirecting to the upstream root represented by the absolute URI. Normalize a query-only value to /?x=1 (or construct the result from target.path() plus target.query()) and add a regression test.
Suggestion:
| target | |
| .path_and_query() | |
| .map(|path_and_query| path_and_query.as_str().to_owned()) | |
| .unwrap_or_else(|| "/".to_owned()) | |
| let path = target.path(); | |
| match target.query() { | |
| Some(query) => format!("{path}?{query}"), | |
| None => path.to_owned(), | |
| } |
There was a problem hiding this comment.
Fixed, thanks — confirmed that "http://10.0.0.1:5173?x=1" has path_and_query() == "?x=1" while path() == "/". The result is now built from path() + query() (+ fragment) as suggested, so it emits /?x=1. Regression covered by a unit case and by the HTTP-level test (/proxy/query-only).
Requests are forwarded with `Host` set to the sandbox's internal `ip:port`, so an application that builds absolute URLs from `Host` returns that address in `Location`. It reached the client unchanged, which both exposes the sandbox network layout and points the client at an address it cannot reach. Rewrite `Location` and `Content-Location` into their path-relative form when they address the sandbox itself, on the HTTP response path and on the rejected websocket handshake alike. Self-references are matched by host and effective port instead of by authority text, so a URL that omits the scheme's default port or uses the scheme-relative form is recognized as well; fragments survive the rewrite and a query-only URL keeps an absolute path. Absolute URLs to any other host are the application's own semantics and stay untouched.
0790e7c to
bf54f6e
Compare
| target_host.eq_ignore_ascii_case(upstream_host) | ||
| && effective_port(target) == effective_port(upstream_uri) |
There was a problem hiding this comment.
Include the scheme in the endpoint comparison. As written, an HTTP upstream at host:5173 also matches https://host:5173/path (and ws similarly matches wss) because only host and effective port are checked. Rewriting that value to /path silently removes an application-requested protocol transition and makes the client retain the gateway's current scheme. Scheme-relative references already inherit the upstream scheme before this check, so requiring equal schemes preserves those while leaving explicit cross-scheme redirects untouched.
Suggestion:
| target_host.eq_ignore_ascii_case(upstream_host) | |
| && effective_port(target) == effective_port(upstream_uri) | |
| target_host.eq_ignore_ascii_case(upstream_host) | |
| && target.scheme_str() == upstream_uri.scheme_str() | |
| && effective_port(target) == effective_port(upstream_uri) |
What
Two fixes on the sandbox proxy hop in
src/api/proxy.rs, both about internal proxy details reaching the client:/proxy/— the prefix with nothing after it — matched neither prefix route, fell through to the header-routed fallback, and was forwarded to the sandbox unmodified, so the sandbox saw/proxy/instead of/.Location/Content-Locationvalues pointing back at the sandbox's internalip:portwere passed through to the client verbatim.Why
Bare
/proxy/. The router matches/proxyexactly and/proxy/{*proxy_path}with a non-empty segment, so the empty-remainder shape reachesproxy_via_fallback, whose contract is to forward the path unchanged. The distributed gateway rewrites data-plane traffic to"/proxy" + path(upstreamTargetPath), so a client request for/always arrives at the node in exactly that shape. Consequences on a real deployment:Location: /proxy, which is relative, so it lands in the client's address bar and 404s on the next request;Every non-root path was fine, which makes this look like an application bug rather than a proxy one.
sandbox_proxy_classifieralready treatspath == "/proxy" || path.starts_with("/proxy/")as proxy traffic, so the classifier and the router disagreed about this one shape.Sandbox address in redirects. Requests are forwarded with
Hostset to the upstream authority, so an application that builds absolute URLs fromHostreturns the sandbox's internal address inLocation. Passing it through both exposes the sandbox network layout and points the client's next request at an address that is only reachable from that node.Related issue
None. Scope is small and obvious, submitted directly per CONTRIBUTING.
Scope and non-goals
Included:
/proxy/, reusing the existingproxy_via_prefixhandler;Location/Content-Locationon the HTTP response path and on a rejected websocket handshake;Not included:
Hosthandling, or the fallback contract;Design and behavior changes
Routing.
.route("/proxy/", any(proxy_via_prefix::<I>)). The handler is unchanged:strip_proxy_prefix("/proxy/")already yields/. The set of paths the gateway can produce is closed —"/proxy" + <path starting with />is either/proxy/or/proxy/<non-empty>— and only the first was unrouted. Trailing slashes are not the criterion:/proxy/station/(remainderstation/) and/proxy//(remainder/) already matched the catch-all and keep their exact upstream paths.Responses.
rewrite_upstream_self_references()runs after hop-by-hop stripping inmap_upstream_responseandmap_websocket_handshake_rejection_response. It rewrites a header only when the value parses as a URI whose authority equals the upstream authority, replacing it withpath_and_query(/when absent). Relative values and absolute URLs to any other host — an OAuth hop, a CDN — are left untouched.Compatibility and operations
Locationnow receives a path-relative value — that address was never reachable off-node.docs/src/concepts/proxy.mdalready documentsANY /proxyas forwarding to/inside the sandbox, so it describes the fixed behavior and needs no change.Validation
make fmtmake clippy— scoped to the changed package, see belowmake test-unit— first command of the target only, see below/dev/kvmand network namespaces, unavailable in the container used for these checksmake -C services test— N/A, noservices/changemaketarget — N/A, no generated code touchedCommands and results:
The same six tests fail before and after the change (
privileges::tests::scoped_spawn_clears_child_capabilities_without_mutating_caller,sandbox::ublk::overlaybd::tests::compact_layers_mixed_input_and_configured_output, twosnapshot::p2p::tests::local_overlaybd_layers_*,snapshot::repository::backends::common::acr::source_image::tests::plans_descriptorless_sparse_overlaybd_delta_for_dense_export,snapshot::repository::backends::posixfs::artifacts::tests::import_built_artifacts_preserves_zfile_memory_lower_bytes). They need privileges and real devices that the unprivileged container does not provide; the delta of this branch is exactly the three new passing tests.Skipped checks and reasons: integration tests and the capability-runner steps of
make test-unitneed root,/dev/kvmand netns; the environment available for these checks is an unprivileged container.Beyond unit tests, both fixes were verified on a two-node k3s deployment (gateway + node DaemonSet, one microVM sandbox):
Risks and reviewer notes
/proxy/; a client that deliberately used header routing to reach a sandbox path literally named/proxy/now reaches/instead. That path was already unreachable through the explicit prefix form.src/api/proxy.rsonly, and they are independent: happy to split them into separate pull requests if you prefer to review or revert them separately.Checklist