fix(proxy): add authenticated WebSocket tunneling for CONNECT/TLS-intercept routes - #1443
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
PR Review SummarySize
Affected crates
Blast radius — ModerateThis PR touches: source code,configuration / policy files Updated automatically on each push to this PR. |
…tercept routes Codex-style credential-provider routes hang for ~1min on WebSocket upgrades because they route through the CONNECT/TLS-intercept path, not the reverse-proxy path Part 1 covered. Adds fail-fast upgrade classification, a declarative per-route upgrade allow-list (UpgradeProtocol/UpgradeRuleConfig, compiled into LoadedRoute and CredentialRouteDef), and handle_websocket_upgrade in tls_intercept/handle.rs, which reuses the existing header filtering, credential injection, and phantom-nonce resolution pipeline before dialing the upstream raw and relaying bytes via copy_bidirectional after a validated 101 handshake. Signed-off-by: Christine Le <christine.le@datadoghq.com>
…P/1 framing Move WebSocket upgrade-rule matching into route selection itself so an unmatched upgrade path is rejected before any upstream is contacted, add an explicit 501 for SPIFFE/AWS-authenticated routes (WebSocket tunneling is unsupported for those credential mechanisms), and make phantom-nonce resolution fail closed: an unresolved phantom nonce in a forwarded header now yields a 403 instead of leaking the unresolved token upstream. Simplify the declarative upgrade-rule schema to just a path (protocol and method are implied for classic WebSocket), and split handshake parsing and strict, framing-aware response relaying into new http1.rs/websocket.rs modules shared between the WS and non-WS intercept paths. Signed-off-by: Christine Le <christine.le@datadoghq.com>
main gained a disjoint-credential-routes regression test (nolabs-ai#1437) written against the pre-refactor select_intercept_route signature. Update it to use InterceptRouteRequest/SelectedRoute so it compiles after the rebase. Signed-off-by: Christine Le <christine.le@datadoghq.com>
7989f69 to
4851f34
Compare
Two RouteConfig literals in tests gated by #[cfg(not(target_os = "macos"))] were missing the new upgrades field, so they only failed to compile on Linux CI (invisible on a macOS dev machine). Signed-off-by: Christine Le <christine.le@datadoghq.com>
|
nice fine @christine-at-datadog |
…eling for CONNECT/TLS-intercept routes
| @@ -270,8 +270,11 @@ async fn handle_h2_stream( | |||
| &ctx.route_store, | |||
| &ctx.host, | |||
| ctx.port, | |||
There was a problem hiding this comment.
Caution
🔒 [HIGH · security] The HTTP/2 forwarder (handle_h2_stream) completely bypasses the new WebSocket upgrade restrictions. It passes websocket_path: None to select_intercept_route, skipping the upgrade_rules validation check entirely. Under RFC 8441, clients can initiate a WebSocket upgrade over an HTTP/2 stream using the CONNECT method and a :protocol: websocket pseudo-header. Because the HTTP/2 path does not check for or propagate the WebSocket upgrade status, a client can negotiate HTTP/2 with the proxy and successfully tunnel un-allowlisted WebSocket upgrades to the upstream, completely bypassing the route's default-deny WebSocket policy. If WebSocket-over-HTTP/2 (RFC 8441) is out of scope, the proxy must explicitly detect and reject HTTP/2 streams containing the :protocol: websocket pseudo-header, or accurately parse and pass the path to select_intercept_route so it fails closed.
There was a problem hiding this comment.
Extended-CONNECT now gets rejected before route selection instead of silently falling.
| /// request path. | ||
| #[must_use] | ||
| pub fn matches(&self, path: &str) -> bool { | ||
| let normalized = normalize_path(path); |
There was a problem hiding this comment.
Warning
🔒 [MEDIUM · security] In CompiledUpgradeRules::matches and CompiledUpgradeRules::compile, path comparison is performed after calling normalize_path. If normalize_path does not resolve and collapse relative path segments (such as . and ..), a client can craft a WebSocket upgrade request containing directory traversal components (e.g., /allowed/../evil) to bypass the exact-path allow-list. The upstream server may subsequently collapse the traversal and route the upgraded WebSocket connection to an unapproved endpoint. Ensure that path segment canonicalization (collapsing . and .. segments) is explicitly performed at the path-matching boundary or within normalize_path.
There was a problem hiding this comment.
Updated to collapse paths :)
| let mut request = Zeroizing::new(format!( | ||
| "{} {} {}\r\nHost: {}\r\n", | ||
| req.method, req.path, req.version, upstream_authority | ||
| )); |
There was a problem hiding this comment.
Warning
🔒 [MEDIUM · security] In build_websocket_upstream_request, headers are constructed using request.push_str(&format!("{}: {}\r\n", field.name, resolved_value));. While raw HTTP headers are validated upon initial parsing, the resolved_value obtained from resolve_nonce_in_header_value is not checked for containing carriage return (\r) or line feed (\n) characters. If a compromised or misbehaving nonce resolver returns a value containing CRLF characters, it could lead to HTTP header injection or request splitting. Add defensive checks to guarantee that resolved_value does not contain \r or \n before formatting it into the upstream request string.
There was a problem hiding this comment.
Added checks for CRLF characters!
…eling for CONNECT/TLS-intercept routes
…eling for CONNECT/TLS-intercept routes
|
One gap while trying this out: the new
|
|
@christine-at-datadog , can you pick up @kipz schema related change? |
kipz reported that profiles using the new upgrades field on credential_routes parse and pass `nono profile validate --strict` but fail validation against the published schema, since CredentialRouteDef there still only lists [base_url_env_var, endpoint_policy, env_var, name, provider]. Add upgrades (CredentialWebSocketRuleDef: origin, path) to match the Rust model, plus a schema_shape regression test.
…tion The prior commit's CredentialWebSocketRuleDef used bare unconstrained strings for origin/path, looser than both the sibling CredentialProviderTokenEndpoint schema (host/path) and the actual runtime checks in validate_provider_origin/validate_provider_path, which require an https-only origin with no path/query/fragment and an absolute path starting with '/'. Use the existing UrlOrigin $ref for origin and add minLength/pattern for path so malformed profiles fail schema validation instead of only failing later at `nono profile validate --strict`.
@kipz thank you for calling this out! Added :) |
lukehinds
left a comment
There was a problem hiding this comment.
lgtm thanks @christine-at-datadog , appreciate the efforts that went into this. just some clippy stuff failing in ci, but lets get this shipped.
Resolutions: - websocket.rs: kept nolabs-ai#1469's bounded line reads, added RFC 6455 Sec-WebSocket-Accept validation from nolabs-ai#1443. - handle.rs: adopted nolabs-ai#1443's run_websocket_tunnel split; kept the 4-arg resolve_nonce_in_header_value (redeem_phantoms) from nolabs-ai#1469 and nolabs-ai#1489's templated-phantom rewrite path. - token.rs: CRLF/NUL fail-closed guard moved into rewrite_first_phantom so the grant-set/templated path is covered too. - reverse.rs: header_pairs replaced by http1::parse_header_fields. - Deduplicated RouteConfig 'upgrades' fields introduced twice by the merge. Signed-off-by: James Carnegie <me@kipz.org>
…eling for CONNECT/TLS-intercept routes
The approval-flow tests in tls_intercept::handle were written against a pre-merge version of select_intercept_route/SelectedRoute/RouteConfig and no longer compiled after merging main: the function now takes an InterceptRouteRequest struct instead of positional method/path args, SelectedRoute needs Debug for panic! formatting, and RouteConfig gained a required `upgrades` field. Signed-off-by: christine.le <christine.le@datadoghq.com>
Summary
Fixes #1433.
Codex-style credential-provider routes go through nono-proxy's CONNECT/TLS-intercept path, which had no concept of a WebSocket upgrade: it forced
Connection: closeon every request and handed it to a strictly request/response-shaped forwarder that can't carry the raw bytes after a101. The upstream never completed the upgrade, and the client hung.Notable pieces:
403.Content-Length/chunked/close-delimited) instead of a naiveio::copy.upgrades: [{ path }]); origin and method aren't separately configurable since they're implied by the route itself.This is an agent-assisted contribution
This PR was prepared with the assistance of Claude Code (Anthropic). The author (@christine-at-datadog) reviewed the approach, the diff, and the test runs before opening this PR.
Agent Compliance Check
helpers are pre-existing project code, called as-is; no external code
adapted)