From ec51817eb7481e209125bbc69ce42f54b336f2c1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 7 Oct 2026 14:59:39 +0530 Subject: [PATCH 1/7] Rewrite same-origin Origin to the upstream origin with --rewrite-host With --rewrite-host the proxy sends Host: TO but forwarded the browser's Origin: https://FROM unchanged, so upstream endpoints that verify a same-origin Origin against their own origin rejected proxied requests. Replace a single same-origin Origin with the TO origin (scheme from --upstream-plaintext, non-default port kept) so Origin and Host name the same authority. Cross-site, null, plain-http and duplicated Origin values pass through unchanged. --- .../src/commands/dev/proxy/rewrite.rs | 65 +++++++++++ .../src/commands/dev/proxy/server.rs | 106 ++++++++++++++++++ docs/guide/ts-dev-proxy.md | 8 ++ 3 files changed, 179 insertions(+) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs index cf02db079..df1e5f219 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -190,11 +190,28 @@ impl Rule { VerifyMode::Secure }; let origin_key = OriginKey::new(transport, reference, to.port, verify, address_policy); + // The browser→proxy leg is always TLS, so the browser's own origin is `https://FROM`. + let first_party_origin_text = format!("https://{from}"); + let first_party_origin = + HeaderValue::from_str(&first_party_origin_text).map_err(|_| RuleError::Header { + value: first_party_origin_text.clone(), + })?; + let upstream_origin = if rewrite_host { + let scheme = if plaintext { "http" } else { "https" }; + let text = format!("{scheme}://{}", to.host_with_port()); + Some(HeaderValue::from_str(&text).map_err(|_| RuleError::Header { + value: text.clone(), + })?) + } else { + None + }; let outcome = RewriteOutcome { sni, host_header, orig_host, scheme_is_tls: !plaintext, + first_party_origin, + upstream_origin, }; Ok(Self { from, @@ -242,6 +259,12 @@ pub struct RewriteOutcome { pub orig_host: HeaderValue, /// Whether the upstream leg is TLS (`!plaintext`). pub scheme_is_tls: bool, + /// The browser's origin for `FROM` (`https://FROM`). + pub first_party_origin: HeaderValue, + /// With `--rewrite-host`, the `TO` origin that replaces a same-origin + /// `Origin: https://FROM`, so `Origin` and `Host` name the same authority. + /// `None` without `--rewrite-host`, where `Host` stays `FROM`. + pub upstream_origin: Option, } /// Computes the rewrite outcome for a matched rule (spec §8.3). @@ -446,6 +469,48 @@ mod tests { ); } + #[test] + fn rewrite_host_derives_upstream_origin_from_to_scheme_and_port() { + let plaintext = rule("www.example-publisher.com", "127.0.0.1:7676", true, true); + let tls = rule( + "www.example-publisher.com", + "ts.example-publisher.com", + true, + false, + ); + + assert_eq!( + rewrite_for(&plaintext).first_party_origin, + "https://www.example-publisher.com", + "should derive the browser origin from FROM over TLS" + ); + assert_eq!( + rewrite_for(&plaintext).upstream_origin, + Some(HeaderValue::from_static("http://127.0.0.1:7676")), + "should use the plaintext scheme and non-default port" + ); + assert_eq!( + rewrite_for(&tls).upstream_origin, + Some(HeaderValue::from_static("https://ts.example-publisher.com")), + "should omit the default TLS port" + ); + } + + #[test] + fn default_host_keeps_no_upstream_origin() { + let r = rule( + "www.example-publisher.com", + "to.edgecompute.app", + false, + false, + ); + assert_eq!( + rewrite_for(&r).upstream_origin, + None, + "should leave Origin alone when Host stays FROM" + ); + } + #[test] fn rejects_empty_or_missing_port() { let err = diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index 424613638..f5210de5b 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -712,6 +712,7 @@ fn rewrite_headers( // headers we stamp below as connection-specific and have them dropped. strip_hop_by_hop(headers); headers.insert(hyper::header::HOST, outcome.host_header.clone()); + rewrite_first_party_origin(headers, outcome); // Tell the upstream the original first-party host (always `FROM`). Trusted // Server resolves the request host from `Forwarded` → `X-Forwarded-Host` → // `Host`, so a client-supplied `Forwarded` would outrank the value we inject. @@ -749,6 +750,31 @@ fn rewrite_headers( trailer_metadata.regenerate(headers); } +/// With `--rewrite-host`, replaces a single same-origin `Origin: https://FROM` +/// with the `TO` origin, so an upstream that compares `Origin` against its own +/// origin (as Trusted Server's state-changing endpoints do) sees the same +/// authority it receives in `Host`. Cross-site, opaque, or duplicated `Origin` +/// values pass through unchanged for the upstream to judge. +fn rewrite_first_party_origin( + headers: &mut hyper::HeaderMap, + outcome: &super::rewrite::RewriteOutcome, +) { + let Some(upstream_origin) = &outcome.upstream_origin else { + return; + }; + let is_single_first_party_origin = { + let mut origins = headers.get_all(hyper::header::ORIGIN).iter(); + origins.next().is_some_and(|origin| { + origin + .as_bytes() + .eq_ignore_ascii_case(outcome.first_party_origin.as_bytes()) + }) && origins.next().is_none() + }; + if is_single_first_party_origin { + headers.insert(hyper::header::ORIGIN, upstream_origin.clone()); + } +} + fn status_response(status: StatusCode) -> Response> { let body = Full::new(Bytes::new()) .map_err(|never| match never {}) @@ -791,9 +817,22 @@ mod tests { host_header: HeaderValue::from_static(host), orig_host: HeaderValue::from_static("www.example-publisher.com"), scheme_is_tls: true, + first_party_origin: HeaderValue::from_static("https://www.example-publisher.com"), + upstream_origin: Some( + HeaderValue::from_str(&format!("https://{host}")) + .expect("should build upstream origin"), + ), } } + fn origins(headers: &hyper::HeaderMap) -> Vec<&str> { + headers + .get_all(hyper::header::ORIGIN) + .iter() + .map(|value| value.to_str().expect("should encode origin")) + .collect() + } + fn head(method: &str, target: &str) -> RequestHead { RequestHead { method: method.to_string(), @@ -1024,6 +1063,73 @@ mod tests { ); } + #[test] + fn rewrite_headers_maps_same_origin_origin_to_upstream_origin() { + let outcome = rewrite_outcome("to.edgecompute.app"); + let mut headers = hyper::HeaderMap::new(); + headers.insert( + hyper::header::ORIGIN, + HeaderValue::from_static("https://WWW.example-publisher.com"), + ); + + rewrite_headers(&mut headers, &outcome, None); + + assert_eq!( + origins(&headers), + ["https://to.edgecompute.app"], + "should present the same authority in Origin as in Host" + ); + } + + #[test] + fn rewrite_headers_keeps_foreign_opaque_and_duplicate_origins() { + let outcome = rewrite_outcome("to.edgecompute.app"); + for values in [ + &["https://evil.example.com"][..], + &["null"][..], + &["http://www.example-publisher.com"][..], + &[ + "https://www.example-publisher.com", + "https://www.example-publisher.com", + ][..], + ] { + let mut headers = hyper::HeaderMap::new(); + for value in values { + headers.append(hyper::header::ORIGIN, HeaderValue::from_static(value)); + } + + rewrite_headers(&mut headers, &outcome, None); + + assert_eq!( + origins(&headers), + values, + "should forward a non-first-party or ambiguous Origin unchanged" + ); + } + } + + #[test] + fn rewrite_headers_keeps_origin_without_rewrite_host() { + let outcome = RewriteOutcome { + host_header: HeaderValue::from_static("www.example-publisher.com"), + upstream_origin: None, + ..rewrite_outcome("www.example-publisher.com") + }; + let mut headers = hyper::HeaderMap::new(); + headers.insert( + hyper::header::ORIGIN, + HeaderValue::from_static("https://www.example-publisher.com"), + ); + + rewrite_headers(&mut headers, &outcome, None); + + assert_eq!( + origins(&headers), + ["https://www.example-publisher.com"], + "should keep Origin aligned with an unchanged Host" + ); + } + #[test] fn rewrite_headers_strips_connection_named_headers_but_keeps_injected() { // A client naming the proxy's own headers in `Connection` must not cause diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index ebd449bb9..0b9e6c39f 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -325,6 +325,14 @@ hostname (e.g. a Fastly Deliver service that rejects an unconfigured `Host`), pa `X-Forwarded-Host: `, so first-party URL rewriting stays anchored to `FROM` **as long as the upstream preserves that header**. +With `--rewrite-host`, the proxy also replaces a single same-origin +`Origin: https://` with the `TO` origin (`http://` with +`--upstream-plaintext`, `https://` otherwise, plus any non-default port), so +`Origin` names the same authority as `Host`. Upstream endpoints that verify a +same-origin `Origin` against their own origin then accept proxied same-origin +requests. Cross-site, `null`, and duplicated `Origin` values pass through +unchanged. + > **Caveat with real Trusted Server adapters.** The Fastly and Spin adapter > request paths strip inbound `X-Forwarded-Host` before routing, so with > `--rewrite-host` a real Trusted Server upstream falls back to `Host` (`TO`) and From 4ac34aaa05a36587b303c3bf9d4fc82ab5b6f4a4 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 7 Oct 2026 18:38:48 +0530 Subject: [PATCH 2/7] Scope the Origin rewrite to the /_ts namespace Derive the first-party origin from the inbound Host so non-443 sessions are rewritten too, and apply the rewrite only to Trusted Server's /_ts endpoints, so publisher and integration requests keep the browser's real Origin. Cover the behavior end to end through the proxy. --- .../src/commands/dev/proxy/rewrite.rs | 21 +-- .../src/commands/dev/proxy/server.rs | 163 ++++++++++++++---- crates/trusted-server-cli/tests/proxy_e2e.rs | 72 ++++++++ .../trusted-server-cli/tests/support/mod.rs | 76 ++++++-- docs/guide/ts-dev-proxy.md | 27 ++- 5 files changed, 286 insertions(+), 73 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs index df1e5f219..cf041dbf2 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -190,12 +190,6 @@ impl Rule { VerifyMode::Secure }; let origin_key = OriginKey::new(transport, reference, to.port, verify, address_policy); - // The browser→proxy leg is always TLS, so the browser's own origin is `https://FROM`. - let first_party_origin_text = format!("https://{from}"); - let first_party_origin = - HeaderValue::from_str(&first_party_origin_text).map_err(|_| RuleError::Header { - value: first_party_origin_text.clone(), - })?; let upstream_origin = if rewrite_host { let scheme = if plaintext { "http" } else { "https" }; let text = format!("{scheme}://{}", to.host_with_port()); @@ -210,7 +204,6 @@ impl Rule { host_header, orig_host, scheme_is_tls: !plaintext, - first_party_origin, upstream_origin, }; Ok(Self { @@ -259,11 +252,10 @@ pub struct RewriteOutcome { pub orig_host: HeaderValue, /// Whether the upstream leg is TLS (`!plaintext`). pub scheme_is_tls: bool, - /// The browser's origin for `FROM` (`https://FROM`). - pub first_party_origin: HeaderValue, - /// With `--rewrite-host`, the `TO` origin that replaces a same-origin - /// `Origin: https://FROM`, so `Origin` and `Host` name the same authority. - /// `None` without `--rewrite-host`, where `Host` stays `FROM`. + /// With `--rewrite-host`, the `TO` origin that replaces the browser's + /// same-origin `Origin` on Trusted Server requests, so `Origin` and `Host` + /// name the same authority. `None` without `--rewrite-host`, where `Host` + /// stays `FROM`. pub upstream_origin: Option, } @@ -479,11 +471,6 @@ mod tests { false, ); - assert_eq!( - rewrite_for(&plaintext).first_party_origin, - "https://www.example-publisher.com", - "should derive the browser origin from FROM over TLS" - ); assert_eq!( rewrite_for(&plaintext).upstream_origin, Some(HeaderValue::from_static("http://127.0.0.1:7676")), diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index f5210de5b..271e25b90 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -571,6 +571,10 @@ async fn proxy_to_upstream( ); let metadata = super::upstream::RequestMetadata::capture(&req); + // Runs before `rewrite_headers` replaces the inbound `Host` it compares against. + if is_trusted_server_path(req.uri().path()) { + rewrite_first_party_origin(req.headers_mut(), outcome); + } rewrite_headers(req.headers_mut(), outcome, basic_auth); let mut response = upstream.send(req, metadata, rule, outcome).await?; @@ -712,7 +716,6 @@ fn rewrite_headers( // headers we stamp below as connection-specific and have them dropped. strip_hop_by_hop(headers); headers.insert(hyper::header::HOST, outcome.host_header.clone()); - rewrite_first_party_origin(headers, outcome); // Tell the upstream the original first-party host (always `FROM`). Trusted // Server resolves the request host from `Forwarded` → `X-Forwarded-Host` → // `Host`, so a client-supplied `Forwarded` would outrank the value we inject. @@ -750,11 +753,27 @@ fn rewrite_headers( trailer_metadata.regenerate(headers); } -/// With `--rewrite-host`, replaces a single same-origin `Origin: https://FROM` -/// with the `TO` origin, so an upstream that compares `Origin` against its own -/// origin (as Trusted Server's state-changing endpoints do) sees the same -/// authority it receives in `Host`. Cross-site, opaque, or duplicated `Origin` -/// values pass through unchanged for the upstream to judge. +/// Whether `path` (query excluded) is in Trusted Server's `/_ts` namespace: +/// `/_ts` itself or anything under `/_ts/`, but not `/_tsx` or `/_ts-foo`. +/// +/// Only those requests get their `Origin` rewritten. Trusted Server's own +/// endpoints there compare `Origin` against the `Host` they receive; every +/// other path may be forwarded to the publisher origin or a third-party vendor, +/// which must keep seeing the browser's real `Origin`, as in production. +fn is_trusted_server_path(path: &str) -> bool { + path.strip_prefix("/_ts") + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) +} + +/// With `--rewrite-host`, replaces a single same-origin `Origin` with the `TO` +/// origin, so an upstream that compares `Origin` against its own origin (as +/// Trusted Server's state-changing endpoints do) sees the same authority it +/// receives in `Host`. +/// +/// Must run before `Host` is rewritten: the browser's origin is `https://` plus +/// the inbound `Host`, which keeps any non-default port the browser connected +/// to. Cross-site, opaque, or duplicated `Origin` values — and requests with an +/// ambiguous `Host` — pass through unchanged for the upstream to judge. fn rewrite_first_party_origin( headers: &mut hyper::HeaderMap, outcome: &super::rewrite::RewriteOutcome, @@ -762,19 +781,26 @@ fn rewrite_first_party_origin( let Some(upstream_origin) = &outcome.upstream_origin else { return; }; - let is_single_first_party_origin = { - let mut origins = headers.get_all(hyper::header::ORIGIN).iter(); - origins.next().is_some_and(|origin| { + let is_first_party = single_header(headers, &hyper::header::ORIGIN) + .zip(single_header(headers, &hyper::header::HOST)) + .is_some_and(|(origin, host)| { origin .as_bytes() - .eq_ignore_ascii_case(outcome.first_party_origin.as_bytes()) - }) && origins.next().is_none() - }; - if is_single_first_party_origin { + .strip_prefix(b"https://") + .is_some_and(|authority| authority.eq_ignore_ascii_case(host.as_bytes())) + }); + if is_first_party { headers.insert(hyper::header::ORIGIN, upstream_origin.clone()); } } +/// Returns the value of `name` only when exactly one such field is present. +fn single_header<'a>(headers: &'a hyper::HeaderMap, name: &HeaderName) -> Option<&'a HeaderValue> { + let mut values = headers.get_all(name).iter(); + let value = values.next()?; + values.next().is_none().then_some(value) +} + fn status_response(status: StatusCode) -> Response> { let body = Full::new(Bytes::new()) .map_err(|never| match never {}) @@ -817,7 +843,6 @@ mod tests { host_header: HeaderValue::from_static(host), orig_host: HeaderValue::from_static("www.example-publisher.com"), scheme_is_tls: true, - first_party_origin: HeaderValue::from_static("https://www.example-publisher.com"), upstream_origin: Some( HeaderValue::from_str(&format!("https://{host}")) .expect("should build upstream origin"), @@ -825,6 +850,15 @@ mod tests { } } + fn browser_headers(host: &'static str, origins: &[&'static str]) -> hyper::HeaderMap { + let mut headers = hyper::HeaderMap::new(); + headers.insert(hyper::header::HOST, HeaderValue::from_static(host)); + for origin in origins { + headers.append(hyper::header::ORIGIN, HeaderValue::from_static(origin)); + } + headers + } + fn origins(headers: &hyper::HeaderMap) -> Vec<&str> { headers .get_all(hyper::header::ORIGIN) @@ -1064,15 +1098,14 @@ mod tests { } #[test] - fn rewrite_headers_maps_same_origin_origin_to_upstream_origin() { + fn maps_same_origin_origin_to_upstream_origin() { let outcome = rewrite_outcome("to.edgecompute.app"); - let mut headers = hyper::HeaderMap::new(); - headers.insert( - hyper::header::ORIGIN, - HeaderValue::from_static("https://WWW.example-publisher.com"), + let mut headers = browser_headers( + "www.example-publisher.com", + &["https://WWW.example-publisher.com"], ); - rewrite_headers(&mut headers, &outcome, None); + rewrite_first_party_origin(&mut headers, &outcome); assert_eq!( origins(&headers), @@ -1082,23 +1115,38 @@ mod tests { } #[test] - fn rewrite_headers_keeps_foreign_opaque_and_duplicate_origins() { + fn maps_same_origin_origin_on_a_non_default_port() { + let outcome = rewrite_outcome("to.edgecompute.app"); + let mut headers = browser_headers( + "www.example-publisher.com:8443", + &["https://www.example-publisher.com:8443"], + ); + + rewrite_first_party_origin(&mut headers, &outcome); + + assert_eq!( + origins(&headers), + ["https://to.edgecompute.app"], + "should derive the browser origin from the inbound Host, port included" + ); + } + + #[test] + fn keeps_foreign_opaque_and_duplicate_origins() { let outcome = rewrite_outcome("to.edgecompute.app"); for values in [ &["https://evil.example.com"][..], &["null"][..], &["http://www.example-publisher.com"][..], + &["https://www.example-publisher.com:8443"][..], &[ "https://www.example-publisher.com", "https://www.example-publisher.com", ][..], ] { - let mut headers = hyper::HeaderMap::new(); - for value in values { - headers.append(hyper::header::ORIGIN, HeaderValue::from_static(value)); - } + let mut headers = browser_headers("www.example-publisher.com", values); - rewrite_headers(&mut headers, &outcome, None); + rewrite_first_party_origin(&mut headers, &outcome); assert_eq!( origins(&headers), @@ -1109,19 +1157,70 @@ mod tests { } #[test] - fn rewrite_headers_keeps_origin_without_rewrite_host() { + fn keeps_origin_when_host_is_missing_or_duplicated() { + let outcome = rewrite_outcome("to.edgecompute.app"); + let mut missing = hyper::HeaderMap::new(); + missing.insert( + hyper::header::ORIGIN, + HeaderValue::from_static("https://www.example-publisher.com"), + ); + let mut duplicated = browser_headers( + "www.example-publisher.com", + &["https://www.example-publisher.com"], + ); + duplicated.append( + hyper::header::HOST, + HeaderValue::from_static("www.example-publisher.com"), + ); + + for headers in [&mut missing, &mut duplicated] { + rewrite_first_party_origin(headers, &outcome); + + assert_eq!( + origins(headers), + ["https://www.example-publisher.com"], + "should not rewrite Origin without a single inbound Host" + ); + } + } + + #[test] + fn matches_only_the_trusted_server_namespace() { + for path in ["/_ts", "/_ts/", "/_ts/trace/enable", "/_ts/api/v1/identify"] { + assert!( + is_trusted_server_path(path), + "should treat {path} as a Trusted Server path" + ); + } + for path in [ + "/", + "/_tsx", + "/_ts-foo", + "/api/_ts/trace", + "/auction", + "/integrations/lockr/api", + "/_TS/trace", + ] { + assert!( + !is_trusted_server_path(path), + "should leave {path} with the browser's Origin" + ); + } + } + + #[test] + fn keeps_origin_without_rewrite_host() { let outcome = RewriteOutcome { host_header: HeaderValue::from_static("www.example-publisher.com"), upstream_origin: None, ..rewrite_outcome("www.example-publisher.com") }; - let mut headers = hyper::HeaderMap::new(); - headers.insert( - hyper::header::ORIGIN, - HeaderValue::from_static("https://www.example-publisher.com"), + let mut headers = browser_headers( + "www.example-publisher.com", + &["https://www.example-publisher.com"], ); - rewrite_headers(&mut headers, &outcome, None); + rewrite_first_party_origin(&mut headers, &outcome); assert_eq!( origins(&headers), diff --git a/crates/trusted-server-cli/tests/proxy_e2e.rs b/crates/trusted-server-cli/tests/proxy_e2e.rs index 8f36d8c46..5baa13113 100644 --- a/crates/trusted-server-cli/tests/proxy_e2e.rs +++ b/crates/trusted-server-cli/tests/proxy_e2e.rs @@ -98,6 +98,78 @@ async fn rewrite_host_keeps_forwarded_host_on_from() { ); } +#[tokio::test] +async fn rewrite_host_presents_same_origin_origin_as_the_upstream_origin() { + let upstream = support::start_echo_upstream().await; + let cfg = support::test_config_rewrite_host(&upstream.addr); + let ca = Arc::new(support::dev_ca()); + + let response = support::drive_request_with_origin( + cfg, + ca, + "/_ts/trace/enable?source=test", + &format!("https://{}", support::FROM_HOST), + ) + .await; + + assert_eq!(response.status, 200, "response streamed back"); + assert_eq!( + response.seen_origin, + format!("https://{}", upstream.addr), + "--rewrite-host should name the TO authority in Origin on /_ts requests" + ); +} + +#[tokio::test] +async fn rewrite_host_keeps_origin_on_publisher_paths() { + let upstream = support::start_echo_upstream().await; + let cfg = support::test_config_rewrite_host(&upstream.addr); + let ca = Arc::new(support::dev_ca()); + let origin = format!("https://{}", support::FROM_HOST); + + let response = support::drive_request_with_origin(cfg, ca, "/checkout", &origin).await; + + assert_eq!( + response.seen_origin, origin, + "publisher-bound requests should keep the browser's real Origin" + ); +} + +#[tokio::test] +async fn rewrite_host_forwards_a_cross_site_origin_unchanged() { + let upstream = support::start_echo_upstream().await; + let cfg = support::test_config_rewrite_host(&upstream.addr); + let ca = Arc::new(support::dev_ca()); + + let response = support::drive_request_with_origin( + cfg, + ca, + "/_ts/trace/enable", + "https://evil.example.com", + ) + .await; + + assert_eq!( + response.seen_origin, "https://evil.example.com", + "should leave a cross-site Origin for the upstream to judge" + ); +} + +#[tokio::test] +async fn origin_is_untouched_without_rewrite_host() { + let upstream = support::start_echo_upstream().await; + let cfg = support::test_config(&upstream.addr); + let ca = Arc::new(support::dev_ca()); + let origin = format!("https://{}", support::FROM_HOST); + + let response = support::drive_request_with_origin(cfg, ca, "/_ts/trace/enable", &origin).await; + + assert_eq!( + response.seen_origin, origin, + "Origin should stay FROM while Host stays FROM" + ); +} + #[tokio::test] async fn resolve_pins_connection_to_address() { let upstream = support::start_echo_upstream().await; diff --git a/crates/trusted-server-cli/tests/support/mod.rs b/crates/trusted-server-cli/tests/support/mod.rs index 6c464b015..f717b7ce1 100644 --- a/crates/trusted-server-cli/tests/support/mod.rs +++ b/crates/trusted-server-cli/tests/support/mod.rs @@ -28,6 +28,7 @@ pub struct ProxiedResponse { pub seen_host: String, pub seen_orig_host: String, pub seen_forwarded_host: String, + pub seen_origin: String, pub path: String, } @@ -658,6 +659,7 @@ async fn serve_upstream_connection( let host = header_value(&head, "host").unwrap_or_default(); let orig_host = header_value(&head, "x-orig-host").unwrap_or_default(); let fwd_host = header_value(&head, "x-forwarded-host").unwrap_or_default(); + let origin = header_value(&head, "origin").unwrap_or_default(); let has_auth = header_value(&head, "authorization").is_some(); if fail_second_request && request_index == 2 { @@ -667,7 +669,8 @@ async fn serve_upstream_connection( let (status_line, body) = if gated && !has_auth { ("HTTP/1.1 401 Unauthorized", String::new()) } else { - let body = format!("host={host};orig={orig_host};fwd={fwd_host};path={path}"); + let body = + format!("host={host};orig={orig_host};fwd={fwd_host};origin={origin};path={path}"); ("HTTP/1.1 200 OK", body) }; if !response_delay.is_zero() { @@ -1515,6 +1518,35 @@ pub async fn drive_request_with_host_header( read_http_response(&mut tls).await.status } +/// CONNECTs to the mapped [`FROM_HOST`], then sends a single `POST` to `path` +/// carrying `Host: FROM` and the given `Origin`, and returns what the upstream saw. +pub async fn drive_request_with_origin( + cfg: config::ResolvedConfig, + ca: Arc, + path: &str, + origin: &str, +) -> ProxiedResponse { + let proxy = spawn_proxy(cfg, ca).await; + let authority = format!("{FROM_HOST}:443"); + let tcp = proxy_connect(proxy, &authority).await; + + let connector = accept_any_connector(); + let server_name = ServerName::try_from(FROM_HOST.to_string()).expect("valid server name"); + let mut tls = connector + .connect(server_name, tcp) + .await + .expect("client TLS handshake with proxy leaf"); + + let request = format!( + "POST {path} HTTP/1.1\r\nHost: {FROM_HOST}\r\nOrigin: {origin}\r\nContent-Length: 0\r\n\r\n" + ); + tls.write_all(request.as_bytes()) + .await + .expect("should send request over tunnel"); + tls.flush().await.expect("should flush request"); + read_http_response(&mut tls).await +} + /// Reads one HTTP/1.1 response (head + Content-Length body) and parses the echo. async fn read_http_response(stream: &mut S) -> ProxiedResponse where @@ -1552,34 +1584,44 @@ where body.extend_from_slice(&chunk[..n]); } let body = String::from_utf8_lossy(&body[..content_length.min(body.len())]).to_string(); - let (seen_host, seen_orig_host, seen_forwarded_host, path) = parse_echo(&body); + let echo = parse_echo(&body); ProxiedResponse { status, - seen_host, - seen_orig_host, - seen_forwarded_host, - path, + seen_host: echo.host, + seen_orig_host: echo.orig, + seen_forwarded_host: echo.fwd, + seen_origin: echo.origin, + path: echo.path, } } -/// Parses `host=..;orig=..;fwd=..;path=..` echoed by the upstream. -fn parse_echo(body: &str) -> (String, String, String, String) { - let mut host = String::new(); - let mut orig = String::new(); - let mut fwd = String::new(); - let mut path = String::new(); +/// Header values the echo upstream reported. +#[derive(Default)] +struct Echo { + host: String, + orig: String, + fwd: String, + origin: String, + path: String, +} + +/// Parses `host=..;orig=..;fwd=..;origin=..;path=..` echoed by the upstream. +fn parse_echo(body: &str) -> Echo { + let mut echo = Echo::default(); for field in body.split(';') { if let Some(v) = field.strip_prefix("host=") { - host = v.to_string(); + echo.host = v.to_string(); } else if let Some(v) = field.strip_prefix("orig=") { - orig = v.to_string(); + echo.orig = v.to_string(); } else if let Some(v) = field.strip_prefix("fwd=") { - fwd = v.to_string(); + echo.fwd = v.to_string(); + } else if let Some(v) = field.strip_prefix("origin=") { + echo.origin = v.to_string(); } else if let Some(v) = field.strip_prefix("path=") { - path = v.to_string(); + echo.path = v.to_string(); } } - (host, orig, fwd, path) + echo } /// CONNECTs through the proxy to an UNMATCHED authority, completes the TLS diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index 0b9e6c39f..9aa168714 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -325,13 +325,26 @@ hostname (e.g. a Fastly Deliver service that rejects an unconfigured `Host`), pa `X-Forwarded-Host: `, so first-party URL rewriting stays anchored to `FROM` **as long as the upstream preserves that header**. -With `--rewrite-host`, the proxy also replaces a single same-origin -`Origin: https://` with the `TO` origin (`http://` with -`--upstream-plaintext`, `https://` otherwise, plus any non-default port), so -`Origin` names the same authority as `Host`. Upstream endpoints that verify a -same-origin `Origin` against their own origin then accept proxied same-origin -requests. Cross-site, `null`, and duplicated `Origin` values pass through -unchanged. +With `--rewrite-host`, the proxy also replaces a single same-origin `Origin` +(`https://`, plus the port the browser connected to when it isn't 443) +with the `TO` origin (`http://` with `--upstream-plaintext`, `https://` +otherwise, plus any non-default port), so `Origin` names the same authority as +`Host`. Trusted Server's state-changing endpoints compare `Origin` against their +own origin, so they then accept proxied same-origin requests. Cross-site, +`null`, plain `http://`, and duplicated `Origin` values pass through unchanged. + +The rewrite applies only to Trusted Server's `/_ts` namespace: the path `/_ts` +or anything under `/_ts/` (not `/_tsx` or `/_ts-foo`). Trusted Server forwards +the browser's request headers to the publisher origin and to integration +vendors, so every other request keeps the browser's real `Origin`, as in +production, and publisher or vendor `Origin` checks keep working. Trusted Server +routes outside `/_ts` — `/auction`, `/first-party/*`, and `/integrations/*` — +are not rewritten; none of them check `Origin` today. + +Inside `/_ts`, `/_ts/api/v1/identify` answers CORS only for an `https` `Origin` +on the publisher domain. It is GET-only, so a same-origin call carries no +`Origin` and a cross-site one is never rewritten, but a same-origin POST added +under `/_ts` later would see the rewritten `TO` origin. > **Caveat with real Trusted Server adapters.** The Fastly and Spin adapter > request paths strip inbound `X-Forwarded-Host` before routing, so with From 6323a0ec48d24cad6ff06e744011668260fe00e0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 8 Oct 2026 09:51:06 +0530 Subject: [PATCH 3/7] Rewrite Origin only for the trace Enable/End actions Match POST /_ts/trace/enable and /_ts/trace/end exactly instead of the whole /_ts namespace, so integration routes an operator mounts under /_ts (such as a Didomi proxy_path of _ts/consent) keep the browser's real Origin. Log the rewrite at debug level. --- .../src/commands/dev/proxy/server.rs | 53 ++++++++++++------- crates/trusted-server-cli/tests/proxy_e2e.rs | 18 +++++++ docs/guide/ts-dev-proxy.md | 27 ++++------ 3 files changed, 62 insertions(+), 36 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index 271e25b90..d513db2bf 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -572,7 +572,7 @@ async fn proxy_to_upstream( let metadata = super::upstream::RequestMetadata::capture(&req); // Runs before `rewrite_headers` replaces the inbound `Host` it compares against. - if is_trusted_server_path(req.uri().path()) { + if requires_upstream_origin(req.method(), req.uri().path()) { rewrite_first_party_origin(req.headers_mut(), outcome); } rewrite_headers(req.headers_mut(), outcome, basic_auth); @@ -753,16 +753,16 @@ fn rewrite_headers( trailer_metadata.regenerate(headers); } -/// Whether `path` (query excluded) is in Trusted Server's `/_ts` namespace: -/// `/_ts` itself or anything under `/_ts/`, but not `/_tsx` or `/_ts-foo`. +/// Whether the request is one Trusted Server consumes itself *and* that +/// compares `Origin` against its own origin: the trace Enable/End actions. /// -/// Only those requests get their `Origin` rewritten. Trusted Server's own -/// endpoints there compare `Origin` against the `Host` they receive; every -/// other path may be forwarded to the publisher origin or a third-party vendor, -/// which must keep seeing the browser's real `Origin`, as in production. -fn is_trusted_server_path(path: &str) -> bool { - path.strip_prefix("/_ts") - .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) +/// Only those requests get their `Origin` rewritten, matched by exact method +/// and path (query excluded). Every other path — including integration routes +/// an operator mounts under `/_ts`, such as a Didomi `proxy_path` of +/// `_ts/consent` — may be forwarded to the publisher origin or a third-party +/// vendor, which must keep seeing the browser's real `Origin`, as in production. +fn requires_upstream_origin(method: &hyper::Method, path: &str) -> bool { + method == hyper::Method::POST && matches!(path, "/_ts/trace/enable" | "/_ts/trace/end") } /// With `--rewrite-host`, replaces a single same-origin `Origin` with the `TO` @@ -790,6 +790,12 @@ fn rewrite_first_party_origin( .is_some_and(|authority| authority.eq_ignore_ascii_case(host.as_bytes())) }); if is_first_party { + log::debug!( + "rewriting same-origin Origin to {}", + upstream_origin + .to_str() + .expect("should prevalidate upstream origin") + ); headers.insert(hyper::header::ORIGIN, upstream_origin.clone()); } } @@ -1185,24 +1191,31 @@ mod tests { } #[test] - fn matches_only_the_trusted_server_namespace() { - for path in ["/_ts", "/_ts/", "/_ts/trace/enable", "/_ts/api/v1/identify"] { + fn requires_upstream_origin_only_for_trace_actions() { + for path in ["/_ts/trace/enable", "/_ts/trace/end"] { + assert!( + requires_upstream_origin(&hyper::Method::POST, path), + "should rewrite Origin for POST {path}" + ); assert!( - is_trusted_server_path(path), - "should treat {path} as a Trusted Server path" + !requires_upstream_origin(&hyper::Method::GET, path), + "should leave Origin for GET {path}" ); } for path in [ "/", - "/_tsx", - "/_ts-foo", - "/api/_ts/trace", + "/_ts", + "/_ts/trace", + "/_ts/trace/state", + "/_ts/trace/enable/", + "/_ts/trace/enabled", + "/_ts/consent/api/events", + "/_ts/api/v1/identify", "/auction", - "/integrations/lockr/api", - "/_TS/trace", + "/integrations/didomi/consent/api/events", ] { assert!( - !is_trusted_server_path(path), + !requires_upstream_origin(&hyper::Method::POST, path), "should leave {path} with the browser's Origin" ); } diff --git a/crates/trusted-server-cli/tests/proxy_e2e.rs b/crates/trusted-server-cli/tests/proxy_e2e.rs index 5baa13113..3d5eed54a 100644 --- a/crates/trusted-server-cli/tests/proxy_e2e.rs +++ b/crates/trusted-server-cli/tests/proxy_e2e.rs @@ -120,6 +120,24 @@ async fn rewrite_host_presents_same_origin_origin_as_the_upstream_origin() { ); } +#[tokio::test] +async fn rewrite_host_keeps_origin_on_integration_routes_under_ts() { + let upstream = support::start_echo_upstream().await; + let cfg = support::test_config_rewrite_host(&upstream.addr); + let ca = Arc::new(support::dev_ca()); + let origin = format!("https://{}", support::FROM_HOST); + + // An integration mounted under `/_ts` (e.g. a Didomi `proxy_path` of + // `_ts/consent`) forwards Origin to its vendor, so it must stay the browser's. + let response = + support::drive_request_with_origin(cfg, ca, "/_ts/consent/api/events", &origin).await; + + assert_eq!( + response.seen_origin, origin, + "vendor-bound routes under /_ts should keep the browser's real Origin" + ); +} + #[tokio::test] async fn rewrite_host_keeps_origin_on_publisher_paths() { let upstream = support::start_echo_upstream().await; diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index 9aa168714..f2dc04447 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -329,22 +329,17 @@ With `--rewrite-host`, the proxy also replaces a single same-origin `Origin` (`https://`, plus the port the browser connected to when it isn't 443) with the `TO` origin (`http://` with `--upstream-plaintext`, `https://` otherwise, plus any non-default port), so `Origin` names the same authority as -`Host`. Trusted Server's state-changing endpoints compare `Origin` against their -own origin, so they then accept proxied same-origin requests. Cross-site, -`null`, plain `http://`, and duplicated `Origin` values pass through unchanged. - -The rewrite applies only to Trusted Server's `/_ts` namespace: the path `/_ts` -or anything under `/_ts/` (not `/_tsx` or `/_ts-foo`). Trusted Server forwards -the browser's request headers to the publisher origin and to integration -vendors, so every other request keeps the browser's real `Origin`, as in -production, and publisher or vendor `Origin` checks keep working. Trusted Server -routes outside `/_ts` — `/auction`, `/first-party/*`, and `/integrations/*` — -are not rewritten; none of them check `Origin` today. - -Inside `/_ts`, `/_ts/api/v1/identify` answers CORS only for an `https` `Origin` -on the publisher domain. It is GET-only, so a same-origin call carries no -`Origin` and a cross-site one is never rewritten, but a same-origin POST added -under `/_ts` later would see the rewritten `TO` origin. +`Host`. Trusted Server's trace actions compare `Origin` against their own +origin, so they then accept proxied same-origin requests. Cross-site, `null`, +plain `http://`, and duplicated `Origin` values pass through unchanged. + +The rewrite applies only to the requests Trusted Server consumes itself and that +check `Origin`: `POST /_ts/trace/enable` and `POST /_ts/trace/end`, matched by +exact path with any query string ignored. Trusted Server forwards the browser's +request headers to the publisher origin and to integration vendors — including +integrations an operator mounts under `/_ts`, such as a Didomi `proxy_path` of +`_ts/consent` — so every other request keeps the browser's real `Origin`, as in +production, and publisher or vendor `Origin` checks keep working. > **Caveat with real Trusted Server adapters.** The Fastly and Spin adapter > request paths strip inbound `X-Forwarded-Host` before routing, so with From 1dd5c7b05f19b3b0f3f530ec34b155b68fb4464b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 8 Oct 2026 10:01:00 +0530 Subject: [PATCH 4/7] Describe the Origin rewrite as trace-action only --- crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs | 2 +- crates/trusted-server-cli/src/commands/dev/proxy/server.rs | 2 +- crates/trusted-server-cli/tests/proxy_e2e.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs index cf041dbf2..1f62a713c 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -253,7 +253,7 @@ pub struct RewriteOutcome { /// Whether the upstream leg is TLS (`!plaintext`). pub scheme_is_tls: bool, /// With `--rewrite-host`, the `TO` origin that replaces the browser's - /// same-origin `Origin` on Trusted Server requests, so `Origin` and `Host` + /// same-origin `Origin` on the trace Enable/End requests, so `Origin` and `Host` /// name the same authority. `None` without `--rewrite-host`, where `Host` /// stays `FROM`. pub upstream_origin: Option, diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index d513db2bf..0d37604e1 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -767,7 +767,7 @@ fn requires_upstream_origin(method: &hyper::Method, path: &str) -> bool { /// With `--rewrite-host`, replaces a single same-origin `Origin` with the `TO` /// origin, so an upstream that compares `Origin` against its own origin (as -/// Trusted Server's state-changing endpoints do) sees the same authority it +/// Trusted Server's trace Enable/End actions do) sees the same authority it /// receives in `Host`. /// /// Must run before `Host` is rewritten: the browser's origin is `https://` plus diff --git a/crates/trusted-server-cli/tests/proxy_e2e.rs b/crates/trusted-server-cli/tests/proxy_e2e.rs index 3d5eed54a..47a9a4106 100644 --- a/crates/trusted-server-cli/tests/proxy_e2e.rs +++ b/crates/trusted-server-cli/tests/proxy_e2e.rs @@ -116,7 +116,7 @@ async fn rewrite_host_presents_same_origin_origin_as_the_upstream_origin() { assert_eq!( response.seen_origin, format!("https://{}", upstream.addr), - "--rewrite-host should name the TO authority in Origin on /_ts requests" + "--rewrite-host should name the TO authority in Origin on trace actions" ); } From ee024b8db5421b0d68f3e95fd0a5ad660847d14e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 9 Oct 2026 14:40:54 +0530 Subject: [PATCH 5/7] Preserve browser Origin with authenticated proxy forwarding --- Cargo.lock | 2 + crates/trusted-server-cli/Cargo.toml | 2 + .../src/commands/dev/proxy/ca.rs | 3 + .../src/commands/dev/proxy/config.rs | 194 ++++++ .../src/commands/dev/proxy/mod.rs | 4 + .../src/commands/dev/proxy/rewrite.rs | 52 -- .../src/commands/dev/proxy/server.rs | 483 ++++++++------- .../src/commands/dev/proxy/upstream/body.rs | 79 ++- .../commands/dev/proxy/upstream/connect.rs | 1 + crates/trusted-server-cli/tests/proxy_e2e.rs | 214 ++++++- .../tests/proxy_trusted_server.rs | 567 ++++++++++++++++++ .../trusted-server-cli/tests/support/mod.rs | 157 ++++- docs/guide/ts-dev-proxy.md | 126 ++-- .../2026-10-09-authenticated-forwarder.md | 82 +++ .../specs/2026-06-22-ts-dev-proxy-design.md | 144 +++-- 15 files changed, 1690 insertions(+), 420 deletions(-) create mode 100644 crates/trusted-server-cli/tests/proxy_trusted_server.rs create mode 100644 docs/superpowers/plans/2026-10-09-authenticated-forwarder.md diff --git a/Cargo.lock b/Cargo.lock index 4cfca6a10..372271f10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6587,6 +6587,7 @@ dependencies = [ "clap", "derive_more", "directories", + "edgezero-adapter-axum", "edgezero-cli", "edgezero-core", "error-stack", @@ -6620,6 +6621,7 @@ dependencies = [ "toml", "toml_edit 0.23.10+spec-1.0.0", "tracing", + "trusted-server-adapter-axum", "trusted-server-core", "url", "uuid", diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index ce97919c8..4d6af83fd 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -112,8 +112,10 @@ x509-parser = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] assert_cmd = { workspace = true } +edgezero-adapter-axum = { workspace = true, features = ["axum"] } flate2 = { workspace = true } predicates = { workspace = true } temp-env = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["test-util"] } +trusted-server-adapter-axum = { path = "../trusted-server-adapter-axum" } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs index 7bc4851e8..8f08d0982 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs @@ -305,6 +305,7 @@ mod tests { #[test] fn leaf_cache_returns_same_arc_for_same_host() { + crate::tls::install_crypto_provider(); let dir = tempfile::tempdir().expect("should create tempdir"); let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate"); let a = ca @@ -322,6 +323,7 @@ mod tests { #[test] fn leaf_cache_normalizes_dns_case() { + crate::tls::install_crypto_provider(); let dir = tempfile::tempdir().expect("should create tempdir"); let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate"); let lower = ca @@ -335,6 +337,7 @@ mod tests { #[test] fn mints_leaf_for_ip_literal_host() { + crate::tls::install_crypto_provider(); // An IP-literal host must mint successfully (IP-type SAN, not DNS) — spec §8.3. let dir = tempfile::tempdir().expect("should create tempdir"); let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate"); diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index ef2a2d8b9..0e80f2d66 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -46,6 +46,15 @@ pub enum ConfigError { can reach the proxy. Bind a loopback address, or drop --basic-auth." )] BasicAuthNonLoopback { value: String }, + /// The forwarding token file could not be read. + #[display("cannot read --forwarder-secret-file")] + ForwarderSecretFile, + /// The forwarding token was invalid. + #[display("--forwarder-secret-file must contain at least 32 ASCII graphic bytes on one line")] + ForwarderSecret, + /// Credential injection requires a loopback listener. + #[display("--forwarder-secret-file requires a loopback --listen address")] + ForwarderSecretNonLoopback, /// An unknown or unsupported browser was passed to `--launch`. #[display("unsupported browser `{value}` (use chrome|firefox|all, plus safari on macOS)")] Browser { value: String }, @@ -91,6 +100,41 @@ impl core::fmt::Debug for BasicAuth { } } +/// Authentication header shared with Trusted Server's trusted-forwarder configuration. +pub const FORWARDER_AUTH_HEADER: &str = "x-ts-forwarder-auth"; + +/// A validated forwarding token whose debug representation is redacted. +#[derive(Clone)] +pub struct ForwarderSecret(HeaderValue); + +impl ForwarderSecret { + fn parse(raw: &[u8]) -> Result { + let token = raw + .strip_suffix(b"\r\n") + .or_else(|| raw.strip_suffix(b"\n")) + .unwrap_or(raw); + if token.len() < 32 || !token.iter().all(u8::is_ascii_graphic) { + return Err(ConfigError::ForwarderSecret); + } + let mut header = + HeaderValue::from_bytes(token).map_err(|_| ConfigError::ForwarderSecret)?; + header.set_sensitive(true); + Ok(Self(header)) + } + + /// The prevalidated sensitive authentication header value. + #[must_use] + pub fn header_value(&self) -> &HeaderValue { + &self.0 + } +} + +impl core::fmt::Debug for ForwarderSecret { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter.write_str("ForwarderSecret([REDACTED])") + } +} + /// A browser the proxy can launch and configure. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Browser { @@ -143,6 +187,8 @@ pub struct ResolvedConfig { pub launch: Vec, pub insecure: bool, pub basic_auth: Option, + /// Optional credential for authenticating browser-facing forwarding metadata. + pub forwarder_secret: Option, pub ca_dir: PathBuf, /// DNS pins from `--resolve`: lowercase hostname → connection address. When /// an upstream host is present here, the proxy dials this IP instead of @@ -309,6 +355,18 @@ pub fn resolve(args: &ProxyArgs) -> Result> value: args.listen.clone(), })); } + if !is_loopback && args.forwarder_secret_file.is_some() { + return Err(Report::new(ConfigError::ForwarderSecretNonLoopback)); + } + let forwarder_secret = args + .forwarder_secret_file + .as_ref() + .map(|path| { + let raw = std::fs::read(path).map_err(|_| ConfigError::ForwarderSecretFile)?; + ForwarderSecret::parse(&raw) + }) + .transpose() + .map_err(Report::from)?; let ca_dir = ca_dir(args); let resolve = build_resolve(args).map_err(Report::from)?; @@ -336,6 +394,7 @@ pub fn resolve(args: &ProxyArgs) -> Result> launch, insecure: args.insecure, basic_auth, + forwarder_secret, ca_dir, resolve, connect_timeout: std::time::Duration::from_secs(args.connect_timeout), @@ -384,6 +443,141 @@ mod tests { W::try_parse_from(argv).map(|w| w.a) } + #[test] + fn forwarder_secret_file_is_accepted_by_cli() { + let dir = tempfile::tempdir().expect("should create temporary directory"); + let path = dir.path().join("token"); + std::fs::write(&path, "example-forwarder-token-0123456789\n").expect("should write token"); + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.example.com", + "--forwarder-secret-file", + path.to_str().expect("should encode path"), + ]); + assert!( + resolve(&args).is_ok(), + "should load a valid forwarder secret" + ); + } + + #[test] + fn forwarder_secret_files_reject_invalid_contents_without_disclosure() { + let dir = tempfile::tempdir().expect("should create temporary directory"); + let path = dir.path().join("token"); + for raw in [ + "", + "short", + "1234567890123456789012345678901", + "example-forwarder-token-0123456789 ", + " example-forwarder-token-0123456789", + "example-forwarder-token-0123456789\n\n", + "example-forwarder-token-0123456789\r", + "example-forwarder-token-0123456789\nsecond", + "example-forwarder-token-0123456789é", + ] { + std::fs::write(&path, raw).expect("should write invalid token"); + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.example.com", + "--forwarder-secret-file", + path.to_str().expect("should encode path"), + ]); + let error = resolve(&args).expect_err("should reject invalid token"); + if !raw.is_empty() { + assert!( + !format!("{error:?}").contains(raw), + "should redact invalid token" + ); + } + } + } + + #[test] + fn non_loopback_rejects_forwarder_secret_file() { + let dir = tempfile::tempdir().expect("should create temporary directory"); + let path = dir.path().join("token"); + std::fs::write(&path, "example-forwarder-token-0123456789").expect("should write token"); + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.example.com", + "--forwarder-secret-file", + path.to_str().expect("should encode path"), + "--listen", + "0.0.0.0:18080", + "--allow-non-loopback", + ]); + let error = resolve(&args).expect_err("should reject credential injection off loopback"); + assert!( + matches!( + error.current_context(), + ConfigError::ForwarderSecretNonLoopback + ), + "should reject credential injection before reading the file" + ); + } + + #[test] + fn forwarder_credentials_are_optional_and_accept_exactly_32_bytes() { + let args = parse_args(&["ts", "--map", "a.example.com=b.example.com"]); + assert!( + resolve(&args) + .expect("should resolve default config") + .forwarder_secret + .is_none(), + "should leave forwarding authentication disabled by default" + ); + assert!( + ForwarderSecret::parse(b"12345678901234567890123456789012").is_ok(), + "should accept the minimum token length" + ); + } + + #[test] + fn forwarding_token_is_sensitive_and_debug_redacted() { + for ending in ["", "\n", "\r\n"] { + let raw = format!("example-forwarder-token-0123456789{ending}"); + let secret = ForwarderSecret::parse(raw.as_bytes()).expect("should accept token"); + assert!( + secret.header_value().is_sensitive(), + "should mark credential sensitive" + ); + assert_eq!( + secret.header_value(), + "example-forwarder-token-0123456789", + "should remove only line terminator" + ); + assert!( + !format!("{secret:?}").contains("example-forwarder"), + "should redact wrapper Debug" + ); + assert!( + !format!("{:?}", secret.header_value()).contains("example-forwarder"), + "should redact header Debug" + ); + } + } + + #[test] + fn forwarder_secret_file_read_failure_is_redacted() { + let dir = tempfile::tempdir().expect("should create temporary directory"); + let path = dir.path().join("missing"); + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.example.com", + "--forwarder-secret-file", + path.to_str().expect("should encode path"), + ]); + let error = resolve(&args).expect_err("should reject missing token file"); + assert!( + matches!(error.current_context(), ConfigError::ForwarderSecretFile), + "should report file error" + ); + } + #[test] fn clap_parses_rewrite_host_as_a_bool() { assert!( diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 997b643bd..aa1caa7bf 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -132,6 +132,10 @@ pub struct ProxyArgs { #[arg(long, value_name = "PATH")] pub basic_auth_file: Option, + /// Read the Trusted Server forwarding token from a file (loopback only). + #[arg(long, value_name = "PATH")] + pub forwarder_secret_file: Option, + /// Skip upstream certificate verification. #[arg(long)] pub insecure: bool, diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs index 1f62a713c..cf02db079 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/rewrite.rs @@ -190,21 +190,11 @@ impl Rule { VerifyMode::Secure }; let origin_key = OriginKey::new(transport, reference, to.port, verify, address_policy); - let upstream_origin = if rewrite_host { - let scheme = if plaintext { "http" } else { "https" }; - let text = format!("{scheme}://{}", to.host_with_port()); - Some(HeaderValue::from_str(&text).map_err(|_| RuleError::Header { - value: text.clone(), - })?) - } else { - None - }; let outcome = RewriteOutcome { sni, host_header, orig_host, scheme_is_tls: !plaintext, - upstream_origin, }; Ok(Self { from, @@ -252,11 +242,6 @@ pub struct RewriteOutcome { pub orig_host: HeaderValue, /// Whether the upstream leg is TLS (`!plaintext`). pub scheme_is_tls: bool, - /// With `--rewrite-host`, the `TO` origin that replaces the browser's - /// same-origin `Origin` on the trace Enable/End requests, so `Origin` and `Host` - /// name the same authority. `None` without `--rewrite-host`, where `Host` - /// stays `FROM`. - pub upstream_origin: Option, } /// Computes the rewrite outcome for a matched rule (spec §8.3). @@ -461,43 +446,6 @@ mod tests { ); } - #[test] - fn rewrite_host_derives_upstream_origin_from_to_scheme_and_port() { - let plaintext = rule("www.example-publisher.com", "127.0.0.1:7676", true, true); - let tls = rule( - "www.example-publisher.com", - "ts.example-publisher.com", - true, - false, - ); - - assert_eq!( - rewrite_for(&plaintext).upstream_origin, - Some(HeaderValue::from_static("http://127.0.0.1:7676")), - "should use the plaintext scheme and non-default port" - ); - assert_eq!( - rewrite_for(&tls).upstream_origin, - Some(HeaderValue::from_static("https://ts.example-publisher.com")), - "should omit the default TLS port" - ); - } - - #[test] - fn default_host_keeps_no_upstream_origin() { - let r = rule( - "www.example-publisher.com", - "to.edgecompute.app", - false, - false, - ); - assert_eq!( - rewrite_for(&r).upstream_origin, - None, - "should leave Origin alone when Host stays FROM" - ); - } - #[test] fn rejects_empty_or_missing_port() { let err = diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs index 0d37604e1..8903129fa 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/server.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/server.rs @@ -479,7 +479,8 @@ async fn mitm( /// rerouted through the CONNECT-authority rule — it is refused with `421` /// (Misdirected Request), so a client cannot `CONNECT mapped.example` then send /// `Host: other.example` to smuggle traffic through a rule it never matched. The -/// CONNECT authority is consulted only when the request carries no `Host` at all. +/// Without a forwarding credential, CONNECT remains a fallback for missing Host. +/// Authenticated forwarding requires a validated request authority. /// /// This is infallible at the hyper layer — upstream errors become a `502` so /// the keep-alive tunnel survives a single bad request (spec §11). @@ -493,17 +494,25 @@ async fn forward_request( return Ok(status_response(StatusCode::NOT_IMPLEMENTED)); } - // Route by the request's own Host when present (spec §8.2). A Host that - // matches no rule is refused (421) rather than rerouted through the CONNECT - // authority. Only a request with no Host falls back to the CONNECT authority. - let rule = match request_host(&req) { + let authority = match browser_authority(&req) { + Ok(Some(authority)) => Some(authority), + Ok(None) | Err(()) if state.config.forwarder_secret.is_some() => { + return Ok(status_response(StatusCode::BAD_REQUEST)); + } + Ok(None) | Err(()) => None, + }; + let inbound_host = authority + .as_ref() + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + .or_else(|| request_host(&req)); + let rule = match inbound_host { Some(host) => match state.config.rules.first_match(&host) { Some(rule) => rule, None => return Ok(status_response(StatusCode::MISDIRECTED_REQUEST)), }, None => match state.config.rules.first_match(connect_host) { Some(rule) => rule, - // Should not happen: MITM is only entered on a CONNECT-authority match. None => return Ok(status_response(StatusCode::BAD_GATEWAY)), }, }; @@ -517,6 +526,10 @@ async fn forward_request( state.config.basic_auth.as_ref(), rule, &state.upstream, + ForwardingHeaders { + authority: authority.as_ref(), + secret: state.config.forwarder_secret.as_ref(), + }, ) .await { @@ -545,12 +558,85 @@ fn request_host(req: &Request) -> Option { req.uri().host().map(str::to_string) } +/// Metadata derived from the request before its headers are sanitized. +#[derive(Clone, Copy, Default)] +struct ForwardingHeaders<'a> { + authority: Option<&'a HeaderValue>, + secret: Option<&'a super::config::ForwarderSecret>, +} + +/// Requires a single valid browser authority, with URI/Host agreement. +fn browser_authority(req: &Request) -> Result, ()> { + let host = if req.headers().contains_key(hyper::header::HOST) { + Some(single_header(req.headers(), &hyper::header::HOST).ok_or(())?) + } else { + None + }; + let host_authority = host + .map(|value| { + value + .to_str() + .map_err(|_| ()) + .and_then(parse_browser_authority) + }) + .transpose()?; + let uri_authority = req + .uri() + .authority() + .map(|authority| parse_browser_authority(authority.as_str())) + .transpose()?; + if let (Some(host), Some(uri)) = (&host_authority, &uri_authority) + && host != uri + { + return Err(()); + } + match (host, req.uri().authority()) { + (Some(host), _) => Ok(Some(host.clone())), + (None, Some(authority)) => HeaderValue::from_str(authority.as_str()) + .map(Some) + .map_err(|_| ()), + (None, None) => Ok(None), + } +} + +fn parse_browser_authority(raw: &str) -> Result<(String, u16), ()> { + let (host, port) = match raw.split_once(':') { + Some((host, port)) => { + if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(()); + } + let port = port.parse::().map_err(|_| ())?; + if port == 0 { + return Err(()); + } + (host, port) + } + None => (raw, 443), + }; + if host.is_empty() + || host.len() > 253 + || !host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return Err(()); + } + Ok((host.to_ascii_lowercase(), port)) +} + async fn proxy_to_upstream( mut req: Request, outcome: &super::rewrite::RewriteOutcome, basic_auth: Option<&super::config::BasicAuth>, rule: &super::rewrite::Rule, upstream: &super::upstream::UpstreamClient, + forwarding: ForwardingHeaders<'_>, ) -> Result>, Report> { let upstream_host = rule.to.host(); let upstream_port = rule.to.port; @@ -571,11 +657,7 @@ async fn proxy_to_upstream( ); let metadata = super::upstream::RequestMetadata::capture(&req); - // Runs before `rewrite_headers` replaces the inbound `Host` it compares against. - if requires_upstream_origin(req.method(), req.uri().path()) { - rewrite_first_party_origin(req.headers_mut(), outcome); - } - rewrite_headers(req.headers_mut(), outcome, basic_auth); + rewrite_headers(req.headers_mut(), outcome, basic_auth, forwarding); let mut response = upstream.send(req, metadata, rule, outcome).await?; @@ -678,58 +760,62 @@ impl UpstreamTrailerMetadata { } fn is_safe_trailer_field(name: &HeaderName) -> bool { - !matches!( - name.as_str(), - "authorization" - | "cache-control" - | "connection" - | "content-encoding" - | "content-length" - | "content-range" - | "content-type" - | "host" - | "keep-alive" - | "max-forwards" - | "proxy-authenticate" - | "proxy-authorization" - | "set-cookie" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - ) + name.as_str() != super::config::FORWARDER_AUTH_HEADER + && !matches!( + name.as_str(), + "authorization" + | "cache-control" + | "connection" + | "content-encoding" + | "content-length" + | "content-range" + | "content-type" + | "host" + | "keep-alive" + | "max-forwards" + | "proxy-authenticate" + | "proxy-authorization" + | "set-cookie" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) } -/// Applies the rewrite outcome: strips inbound hop-by-hop headers, then sets -/// upstream `Host`, `X-Forwarded-Host`/`X-Orig-Host` (both `FROM`, after -/// stripping any higher-priority inbound `Forwarded`), an authoritative -/// `X-Forwarded-Proto: https` (the browser leg is always TLS), and (only when -/// absent) the injected `Authorization`. The request URI is left origin-form, -/// which is what an HTTP/1.1 upstream expects. +/// Sanitizes mapped request headers, then stamps browser-facing authority, +/// scheme and optional credentials. Origin fields retain their original values. fn rewrite_headers( headers: &mut hyper::HeaderMap, outcome: &super::rewrite::RewriteOutcome, basic_auth: Option<&super::config::BasicAuth>, + forwarding: ForwardingHeaders<'_>, ) { let trailer_metadata = UpstreamTrailerMetadata::capture(headers); // Strip hop-by-hop headers first, so a client cannot flag the authoritative // headers we stamp below as connection-specific and have them dropped. + let origins: Vec<_> = headers + .get_all(hyper::header::ORIGIN) + .iter() + .cloned() + .collect(); strip_hop_by_hop(headers); + headers.remove(hyper::header::ORIGIN); + for origin in origins { + headers.append(hyper::header::ORIGIN, origin); + } + headers.remove(super::config::FORWARDER_AUTH_HEADER); + if let Some(secret) = forwarding.secret { + headers.insert( + super::config::FORWARDER_AUTH_HEADER, + secret.header_value().clone(), + ); + } headers.insert(hyper::header::HOST, outcome.host_header.clone()); - // Tell the upstream the original first-party host (always `FROM`). Trusted - // Server resolves the request host from `Forwarded` → `X-Forwarded-Host` → - // `Host`, so a client-supplied `Forwarded` would outrank the value we inject. - // Remove it first so the `X-Forwarded-Host` we stamp is the one core reads, - // aiming to keep emitted first-party URLs on the production host even when - // `--rewrite-host` sends `Host: TO` for routing/validation (spec §8.3). NOTE: - // this only holds if the upstream preserves `X-Forwarded-Host` — the real - // Fastly/Spin adapter paths strip it before routing, in which case core falls - // back to `Host` (`TO`). See the `--rewrite-host` caveat in the user guide. - // The `insert`s below already overwrite any inbound `X-Forwarded-Host`/`X-Orig-Host`. headers.remove("forwarded"); headers.insert( HeaderName::from_static(X_FORWARDED_HOST), - outcome.orig_host.clone(), + forwarding.authority.unwrap_or(&outcome.orig_host).clone(), ); headers.insert( HeaderName::from_static(X_ORIG_HOST), @@ -753,53 +839,6 @@ fn rewrite_headers( trailer_metadata.regenerate(headers); } -/// Whether the request is one Trusted Server consumes itself *and* that -/// compares `Origin` against its own origin: the trace Enable/End actions. -/// -/// Only those requests get their `Origin` rewritten, matched by exact method -/// and path (query excluded). Every other path — including integration routes -/// an operator mounts under `/_ts`, such as a Didomi `proxy_path` of -/// `_ts/consent` — may be forwarded to the publisher origin or a third-party -/// vendor, which must keep seeing the browser's real `Origin`, as in production. -fn requires_upstream_origin(method: &hyper::Method, path: &str) -> bool { - method == hyper::Method::POST && matches!(path, "/_ts/trace/enable" | "/_ts/trace/end") -} - -/// With `--rewrite-host`, replaces a single same-origin `Origin` with the `TO` -/// origin, so an upstream that compares `Origin` against its own origin (as -/// Trusted Server's trace Enable/End actions do) sees the same authority it -/// receives in `Host`. -/// -/// Must run before `Host` is rewritten: the browser's origin is `https://` plus -/// the inbound `Host`, which keeps any non-default port the browser connected -/// to. Cross-site, opaque, or duplicated `Origin` values — and requests with an -/// ambiguous `Host` — pass through unchanged for the upstream to judge. -fn rewrite_first_party_origin( - headers: &mut hyper::HeaderMap, - outcome: &super::rewrite::RewriteOutcome, -) { - let Some(upstream_origin) = &outcome.upstream_origin else { - return; - }; - let is_first_party = single_header(headers, &hyper::header::ORIGIN) - .zip(single_header(headers, &hyper::header::HOST)) - .is_some_and(|(origin, host)| { - origin - .as_bytes() - .strip_prefix(b"https://") - .is_some_and(|authority| authority.eq_ignore_ascii_case(host.as_bytes())) - }); - if is_first_party { - log::debug!( - "rewriting same-origin Origin to {}", - upstream_origin - .to_str() - .expect("should prevalidate upstream origin") - ); - headers.insert(hyper::header::ORIGIN, upstream_origin.clone()); - } -} - /// Returns the value of `name` only when exactly one such field is present. fn single_header<'a>(headers: &'a hyper::HeaderMap, name: &HeaderName) -> Option<&'a HeaderValue> { let mut values = headers.get_all(name).iter(); @@ -849,10 +888,6 @@ mod tests { host_header: HeaderValue::from_static(host), orig_host: HeaderValue::from_static("www.example-publisher.com"), scheme_is_tls: true, - upstream_origin: Some( - HeaderValue::from_str(&format!("https://{host}")) - .expect("should build upstream origin"), - ), } } @@ -883,6 +918,106 @@ mod tests { } } + #[test] + fn authority_validation_rejects_ambiguous_or_malformed_hosts() { + for host in [ + "", + "a.example.com,b.example.com", + "a.example.com/path", + "user@a.example.com", + "a.example.com:0", + "a.example.com:65536", + "a.example.com:", + "a.example.com:abc", + "a.example.com ", + "a..example.com", + "-a.example.com", + "a.example.com?x=1", + ] { + let mut req = Request::new(()); + req.headers_mut().insert( + hyper::header::HOST, + HeaderValue::from_str(host).expect("should encode header"), + ); + assert!( + browser_authority(&req).is_err(), + "should reject invalid authority {host}" + ); + } + let mut req = Request::new(()); + req.headers_mut().append( + hyper::header::HOST, + HeaderValue::from_static("a.example.com"), + ); + req.headers_mut().append( + hyper::header::HOST, + HeaderValue::from_static("a.example.com"), + ); + assert!( + browser_authority(&req).is_err(), + "should reject duplicate Host fields" + ); + } + + #[test] + fn authority_validation_preserves_ports_and_requires_uri_agreement() { + let mut req = Request::builder() + .uri("https://A.example.com:8443/path") + .header(hyper::header::HOST, "a.example.com:8443") + .body(()) + .expect("should build request"); + assert_eq!( + browser_authority(&req).expect("should validate authority"), + Some(HeaderValue::from_static("a.example.com:8443")), + "should retain browser port" + ); + req.headers_mut().insert( + hyper::header::HOST, + HeaderValue::from_static("b.example.com:8443"), + ); + assert!( + browser_authority(&req).is_err(), + "should reject URI/Host disagreement" + ); + req.headers_mut().remove(hyper::header::HOST); + assert_eq!( + browser_authority(&req).expect("should accept unambiguous URI authority"), + Some(HeaderValue::from_static("A.example.com:8443")), + "should retain URI authority" + ); + let req = Request::new(()); + assert_eq!( + browser_authority(&req), + Ok(None), + "should never guess CONNECT authority" + ); + } + + #[test] + fn header_sanitation_preserves_every_origin_field() { + let expected = [ + "https://www.example-publisher.com:8443", + "null", + "https://foreign.example.com", + ]; + let mut headers = browser_headers("www.example-publisher.com:8443", &expected); + headers.insert( + hyper::header::CONNECTION, + HeaderValue::from_static("Origin"), + ); + rewrite_headers( + &mut headers, + &rewrite_outcome("to.example.com"), + None, + ForwardingHeaders::default(), + ); + assert_eq!( + origins(&headers), + expected, + "should preserve all Origin values unchanged" + ); + } + #[test] fn local_pac_route_is_origin_form_get_only() { assert!( @@ -945,7 +1080,7 @@ mod tests { HeaderName::from_static("proxy-connection"), HeaderValue::from_static("keep-alive"), ); - rewrite_headers(&mut headers, &outcome, None); + rewrite_headers(&mut headers, &outcome, None, ForwardingHeaders::default()); assert!( !headers.contains_key("proxy-connection"), "Proxy-Connection is a hop-by-hop header and must be removed" @@ -995,6 +1130,7 @@ mod tests { request.headers_mut(), &rewrite_outcome("to.edgecompute.app"), None, + ForwardingHeaders::default(), ); assert!( @@ -1034,7 +1170,7 @@ mod tests { HeaderValue::from_static("keep-alive, TE"), ); - rewrite_headers(&mut headers, &outcome, None); + rewrite_headers(&mut headers, &outcome, None, ForwardingHeaders::default()); let declarations: Vec<_> = headers .get_all(hyper::header::TRAILER) @@ -1065,7 +1201,7 @@ mod tests { HeaderName::from_static("forwarded"), HeaderValue::from_static("host=evil.example.com"), ); - rewrite_headers(&mut headers, &outcome, None); + rewrite_headers(&mut headers, &outcome, None, ForwardingHeaders::default()); assert!( !headers.contains_key("forwarded"), "inbound Forwarded must be stripped so it cannot outrank X-Forwarded-Host" @@ -1091,7 +1227,7 @@ mod tests { HeaderName::from_static("fastly-ssl"), HeaderValue::from_static("0"), ); - rewrite_headers(&mut headers, &outcome, None); + rewrite_headers(&mut headers, &outcome, None, ForwardingHeaders::default()); assert_eq!( headers.get(X_FORWARDED_PROTO).and_then(|v| v.to_str().ok()), Some("https"), @@ -1103,145 +1239,6 @@ mod tests { ); } - #[test] - fn maps_same_origin_origin_to_upstream_origin() { - let outcome = rewrite_outcome("to.edgecompute.app"); - let mut headers = browser_headers( - "www.example-publisher.com", - &["https://WWW.example-publisher.com"], - ); - - rewrite_first_party_origin(&mut headers, &outcome); - - assert_eq!( - origins(&headers), - ["https://to.edgecompute.app"], - "should present the same authority in Origin as in Host" - ); - } - - #[test] - fn maps_same_origin_origin_on_a_non_default_port() { - let outcome = rewrite_outcome("to.edgecompute.app"); - let mut headers = browser_headers( - "www.example-publisher.com:8443", - &["https://www.example-publisher.com:8443"], - ); - - rewrite_first_party_origin(&mut headers, &outcome); - - assert_eq!( - origins(&headers), - ["https://to.edgecompute.app"], - "should derive the browser origin from the inbound Host, port included" - ); - } - - #[test] - fn keeps_foreign_opaque_and_duplicate_origins() { - let outcome = rewrite_outcome("to.edgecompute.app"); - for values in [ - &["https://evil.example.com"][..], - &["null"][..], - &["http://www.example-publisher.com"][..], - &["https://www.example-publisher.com:8443"][..], - &[ - "https://www.example-publisher.com", - "https://www.example-publisher.com", - ][..], - ] { - let mut headers = browser_headers("www.example-publisher.com", values); - - rewrite_first_party_origin(&mut headers, &outcome); - - assert_eq!( - origins(&headers), - values, - "should forward a non-first-party or ambiguous Origin unchanged" - ); - } - } - - #[test] - fn keeps_origin_when_host_is_missing_or_duplicated() { - let outcome = rewrite_outcome("to.edgecompute.app"); - let mut missing = hyper::HeaderMap::new(); - missing.insert( - hyper::header::ORIGIN, - HeaderValue::from_static("https://www.example-publisher.com"), - ); - let mut duplicated = browser_headers( - "www.example-publisher.com", - &["https://www.example-publisher.com"], - ); - duplicated.append( - hyper::header::HOST, - HeaderValue::from_static("www.example-publisher.com"), - ); - - for headers in [&mut missing, &mut duplicated] { - rewrite_first_party_origin(headers, &outcome); - - assert_eq!( - origins(headers), - ["https://www.example-publisher.com"], - "should not rewrite Origin without a single inbound Host" - ); - } - } - - #[test] - fn requires_upstream_origin_only_for_trace_actions() { - for path in ["/_ts/trace/enable", "/_ts/trace/end"] { - assert!( - requires_upstream_origin(&hyper::Method::POST, path), - "should rewrite Origin for POST {path}" - ); - assert!( - !requires_upstream_origin(&hyper::Method::GET, path), - "should leave Origin for GET {path}" - ); - } - for path in [ - "/", - "/_ts", - "/_ts/trace", - "/_ts/trace/state", - "/_ts/trace/enable/", - "/_ts/trace/enabled", - "/_ts/consent/api/events", - "/_ts/api/v1/identify", - "/auction", - "/integrations/didomi/consent/api/events", - ] { - assert!( - !requires_upstream_origin(&hyper::Method::POST, path), - "should leave {path} with the browser's Origin" - ); - } - } - - #[test] - fn keeps_origin_without_rewrite_host() { - let outcome = RewriteOutcome { - host_header: HeaderValue::from_static("www.example-publisher.com"), - upstream_origin: None, - ..rewrite_outcome("www.example-publisher.com") - }; - let mut headers = browser_headers( - "www.example-publisher.com", - &["https://www.example-publisher.com"], - ); - - rewrite_first_party_origin(&mut headers, &outcome); - - assert_eq!( - origins(&headers), - ["https://www.example-publisher.com"], - "should keep Origin aligned with an unchanged Host" - ); - } - #[test] fn rewrite_headers_strips_connection_named_headers_but_keeps_injected() { // A client naming the proxy's own headers in `Connection` must not cause @@ -1261,7 +1258,7 @@ mod tests { HeaderName::from_static("keep-alive"), HeaderValue::from_static("timeout=5"), ); - rewrite_headers(&mut headers, &outcome, None); + rewrite_headers(&mut headers, &outcome, None, ForwardingHeaders::default()); assert!( !headers.contains_key(hyper::header::CONNECTION), "inbound Connection is stripped" diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/upstream/body.rs b/crates/trusted-server-cli/src/commands/dev/proxy/upstream/body.rs index 44b85db66..eb00a729f 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/upstream/body.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/upstream/body.rs @@ -9,6 +9,7 @@ use hyper::body::{Body, Frame, Incoming, SizeHint}; use super::connect::UpstreamSender; use super::manager::{Lease, Manager}; +use crate::commands::dev::proxy::config::FORWARDER_AUTH_HEADER; use crate::commands::dev::proxy::metrics::ProxyMetrics; const STREAMING: u8 = 0; @@ -89,7 +90,12 @@ impl Body for RequestUploadBody { self.state.store(FAILED, Ordering::Release); Poll::Ready(Some(Err(error))) } - Poll::Ready(Some(Ok(frame))) => Poll::Ready(Some(Ok(frame))), + Poll::Ready(Some(Ok(mut frame))) => { + if let Some(trailers) = frame.trailers_mut() { + trailers.remove(FORWARDER_AUTH_HEADER); + } + Poll::Ready(Some(Ok(frame))) + } Poll::Pending => Poll::Pending, } } @@ -269,6 +275,77 @@ mod tests { } } + #[tokio::test] + async fn upload_removes_forwarder_authentication_from_streamed_trailers() { + let polls = Arc::new(AtomicUsize::new(0)); + let mut trailers = HeaderMap::new(); + trailers.append( + "x-ts-forwarder-auth", + hyper::header::HeaderValue::from_static("hostile"), + ); + trailers.append( + "x-ts-forwarder-auth", + hyper::header::HeaderValue::from_static("second"), + ); + trailers.insert( + "x-checksum", + hyper::header::HeaderValue::from_static("verified"), + ); + let scripted = ScriptedBody { + frames: VecDeque::from([ + Frame::data(Bytes::from_static(b"data")), + Frame::trailers(trailers), + ]), + polls: Arc::clone(&polls), + }; + let (mut body, state) = RequestUploadBody::from_boxed(scripted.boxed(), false); + assert_eq!( + polls.load(AtomicOrdering::Relaxed), + 0, + "should not pre-poll frames" + ); + let data = body + .frame() + .await + .expect("should have data frame") + .expect("should read data frame"); + assert_eq!( + data.data_ref().expect("should retain data frame"), + &Bytes::from_static(b"data"), + "should preserve streaming body data" + ); + assert_eq!( + polls.load(AtomicOrdering::Relaxed), + 1, + "should poll one frame at a time" + ); + let frame = body + .frame() + .await + .expect("should have trailer frame") + .expect("should read trailer frame"); + let trailers = frame.trailers_ref().expect("should retain benign trailers"); + assert!( + !trailers.contains_key("x-ts-forwarder-auth"), + "should remove every inbound credential trailer" + ); + assert_eq!( + trailers["x-checksum"], "verified", + "should retain benign trailer" + ); + assert_eq!( + state.load(Ordering::Acquire), + STREAMING, + "should wait for terminal EOS" + ); + assert!(body.frame().await.is_none(), "should reach terminal EOS"); + assert_eq!( + state.load(Ordering::Acquire), + COMPLETE, + "should mark complete after EOS" + ); + } + #[tokio::test] async fn upload_completes_only_after_terminal_eos_following_trailers() { let polls = Arc::new(AtomicUsize::new(0)); diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/upstream/connect.rs b/crates/trusted-server-cli/src/commands/dev/proxy/upstream/connect.rs index dd876b316..9ea1800ce 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/upstream/connect.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/upstream/connect.rs @@ -373,6 +373,7 @@ mod tests { #[test] fn client_configs_are_cached_by_verification_and_http1_only() { + crate::tls::install_crypto_provider(); let secure = client_config(VerifyMode::Secure); let secure_again = client_config(VerifyMode::Secure); let insecure = client_config(VerifyMode::Insecure); diff --git a/crates/trusted-server-cli/tests/proxy_e2e.rs b/crates/trusted-server-cli/tests/proxy_e2e.rs index 47a9a4106..1f076e0cd 100644 --- a/crates/trusted-server-cli/tests/proxy_e2e.rs +++ b/crates/trusted-server-cli/tests/proxy_e2e.rs @@ -89,8 +89,8 @@ async fn rewrite_host_keeps_forwarded_host_on_from() { upstream.addr.to_string(), "--rewrite-host sends Host: TO" ); - // The point: TS anchors URL rewriting to X-Forwarded-Host, so it stays FROM - // even though Host is TO — keeping emitted first-party URLs on the prod host. + // Authenticated forwarding lets the server use browser authority independently + // of the upstream routing Host. This fixture verifies the proxy wire values. assert_eq!( response.seen_forwarded_host, support::FROM_HOST, @@ -99,7 +99,7 @@ async fn rewrite_host_keeps_forwarded_host_on_from() { } #[tokio::test] -async fn rewrite_host_presents_same_origin_origin_as_the_upstream_origin() { +async fn rewrite_host_preserves_browser_origin_on_trace_actions() { let upstream = support::start_echo_upstream().await; let cfg = support::test_config_rewrite_host(&upstream.addr); let ca = Arc::new(support::dev_ca()); @@ -115,8 +115,8 @@ async fn rewrite_host_presents_same_origin_origin_as_the_upstream_origin() { assert_eq!(response.status, 200, "response streamed back"); assert_eq!( response.seen_origin, - format!("https://{}", upstream.addr), - "--rewrite-host should name the TO authority in Origin on trace actions" + format!("https://{}", support::FROM_HOST), + "should preserve browser Origin on trace actions" ); } @@ -773,3 +773,207 @@ async fn mitm_connect_overread_preserves_tls_client_hello() { assert_eq!(response.status, 200); assert_eq!(response.path, "/mitm-overread"); } + +#[tokio::test] +async fn mapped_requests_strip_unconfigured_forwarder_auth_and_keep_browser_port() { + let upstream = support::start_echo_upstream().await; + let cfg = support::test_config_rewrite_host(&upstream.addr); + let proxy = support::spawn_proxy(cfg, Arc::new(support::dev_ca())).await; + let authority = format!("{}:8443", support::FROM_HOST); + let responses = support::drive_raw_mapped_requests(proxy, &[format!( + "POST /_ts/trace/end HTTP/1.1\r\nHost: {authority}\r\nOrigin: https://{authority}\r\nX-Ts-Forwarder-Auth: hostile\r\nContent-Length: 0\r\n\r\n" + )]).await; + assert_eq!( + responses[0].seen_forwarded_host, authority, + "should preserve browser port" + ); + assert_eq!( + responses[0].seen_forwarder_auth, "", + "should remove inbound credential even when unset" + ); + assert_eq!( + responses[0].seen_origin, + format!("https://{authority}"), + "should preserve Origin" + ); +} + +#[tokio::test] +async fn authenticated_forwarding_preserves_browser_contract_across_transports_and_host_modes() { + for plaintext in [false, true] { + for rewrite_host in [false, true] { + let upstream = if plaintext { + support::start_plaintext_echo_upstream().await + } else { + support::start_echo_upstream().await + }; + let cfg = support::test_config_authenticated(&upstream.addr, plaintext, rewrite_host); + let proxy = support::spawn_proxy(cfg, Arc::new(support::dev_ca())).await; + let authority = format!("{}:8443", support::FROM_HOST); + let requests: Vec<_> = ["/_ts/trace/enable?source=test", "/_ts/trace/end", "/_ts/consent/api/events", + "/_ts/trace/enable/", "/checkout"].iter().map(|path| format!( + "POST {path} HTTP/1.1\r\nHost: {authority}\r\nOrigin: https://{authority}\r\nOrigin: https://foreign.example.com\r\nX-Ts-Forwarder-Auth: hostile\r\nX-Ts-Forwarder-Auth: second\r\nX-Forwarded-Host: foreign.example.com\r\nX-Forwarded-Proto: http\r\nConnection: x-ts-forwarder-auth, x-forwarded-host, x-forwarded-proto, Origin\r\nContent-Length: 0\r\n\r\n" + )).collect(); + let responses = support::drive_raw_mapped_requests(proxy, &requests).await; + for response in responses { + assert_eq!(response.status, 200, "should forward mapped request"); + assert_eq!( + response.seen_host, + if rewrite_host { + upstream.addr.to_string() + } else { + support::FROM_HOST.to_string() + }, + "should apply configured Host mode" + ); + assert_eq!( + response.seen_forwarded_host, authority, + "should authenticate actual browser authority" + ); + assert_eq!( + response.seen_forwarded_proto, "https", + "should authenticate browser TLS scheme" + ); + assert_eq!( + response.seen_forwarder_auth, + support::FORWARDER_TOKEN, + "should overwrite inbound credentials after sanitation" + ); + assert_eq!( + response.seen_origin, + format!("https://{authority}|https://foreign.example.com"), + "should preserve every browser Origin on all routes" + ); + } + assert_eq!( + upstream.snapshot().accepted_connections, + 1, + "should reuse authenticated upstream connection" + ); + } + } +} + +#[tokio::test] +async fn authenticated_forwarding_rejects_guessed_or_ambiguous_authorities() { + let upstream = support::start_echo_upstream().await; + for request in [ + "GET / HTTP/1.0\r\n\r\n".to_string(), + format!( + "GET / HTTP/1.1\r\nHost: {}\r\nHost: {}\r\n\r\n", + support::FROM_HOST, + support::FROM_HOST + ), + format!("GET / HTTP/1.1\r\nHost: {}:bad\r\n\r\n", support::FROM_HOST), + format!( + "GET https://foreign.example.com/ HTTP/1.1\r\nHost: {}\r\n\r\n", + support::FROM_HOST + ), + ] { + let cfg = support::test_config_authenticated(&upstream.addr, false, true); + let proxy = support::spawn_proxy(cfg, Arc::new(support::dev_ca())).await; + let responses = support::drive_raw_mapped_requests(proxy, &[request]).await; + assert_eq!( + responses[0].status, 400, + "should reject ambiguous authority without forwarding" + ); + } + assert_eq!( + upstream.snapshot().requests, + 0, + "should never send credentials for rejected authority" + ); +} + +#[tokio::test] +async fn authenticated_forwarding_routes_by_request_host_instead_of_connect_host() { + let upstream = support::start_echo_upstream().await; + let mut cfg = support::test_config_shared_to(&upstream.addr); + cfg.forwarder_secret = + support::test_config_authenticated(&upstream.addr, false, true).forwarder_secret; + let proxy = support::spawn_proxy(cfg, Arc::new(support::dev_ca())).await; + let authority = format!("{}:8443", support::ALT_FROM_HOST); + let responses = support::drive_raw_mapped_requests( + proxy, + &[format!("GET / HTTP/1.1\r\nHost: {authority}\r\n\r\n")], + ) + .await; + assert_eq!( + responses[0].status, 200, + "should accept the request's matched rule" + ); + assert_eq!( + responses[0].seen_forwarded_host, authority, + "should stamp per-request authority" + ); + assert_eq!( + responses[0].seen_forwarder_auth, + support::FORWARDER_TOKEN, + "should authenticate matched request" + ); +} + +#[tokio::test] +async fn configured_forwarder_token_does_not_change_blind_or_plain_http_traffic() { + let raw = support::start_raw_echo_upstream().await; + let cfg = support::test_config_authenticated(&raw.addr, false, true); + let proxy = support::spawn_proxy(cfg, Arc::new(support::dev_ca())).await; + let payload = b"X-Ts-Forwarder-Auth: original\r\nopaque bytes"; + let mut tunnel = support::open_blind_tunnel(proxy, &raw.addr.to_string(), payload).await; + let mut echoed = vec![0; payload.len()]; + tunnel + .read_exact(&mut echoed) + .await + .expect("should read unchanged blind payload"); + assert_eq!(echoed, payload, "should leave blind tunnel bytes unchanged"); + let (mut plain, expected) = support::open_plain_forward(proxy, raw.addr, payload).await; + let mut echoed = vec![0; expected.len()]; + plain + .read_exact(&mut echoed) + .await + .expect("should read unchanged plain HTTP payload"); + assert_eq!( + echoed, expected, + "should leave plain HTTP forwarding unchanged" + ); + assert!( + !String::from_utf8_lossy(&echoed).contains(support::FORWARDER_TOKEN), + "should never inject forwarding token into plain traffic" + ); +} + +async fn assert_forwarder_trailer_contract(authenticated: bool) { + let expected_auth = if authenticated { + support::FORWARDER_TOKEN + } else { + "" + }; + let upstream = support::start_forwarder_trailer_upstream(expected_auth).await; + let cfg = if authenticated { + support::test_config_authenticated(&upstream.addr, false, true) + } else { + support::test_config_rewrite_host(&upstream.addr) + }; + let proxy = support::spawn_proxy(cfg, Arc::new(support::dev_ca())).await; + let (body, trailers) = support::drive_forwarder_auth_trailers(proxy).await; + assert_eq!( + body, b"accepted", + "should strip auth declarations and frames, preserve checksum and initial configured auth (authenticated={authenticated})" + ); + assert!( + trailers + .to_ascii_lowercase() + .contains("x-response-accepted: yes"), + "should preserve benign response trailers" + ); +} + +#[tokio::test] +async fn mapped_authentication_is_removed_from_trailer_declarations_and_frames_when_unset() { + assert_forwarder_trailer_contract(false).await; +} + +#[tokio::test] +async fn mapped_authentication_is_removed_from_trailer_declarations_and_frames_when_set() { + assert_forwarder_trailer_contract(true).await; +} diff --git a/crates/trusted-server-cli/tests/proxy_trusted_server.rs b/crates/trusted-server-cli/tests/proxy_trusted_server.rs new file mode 100644 index 000000000..0e19497d2 --- /dev/null +++ b/crates/trusted-server-cli/tests/proxy_trusted_server.rs @@ -0,0 +1,567 @@ +//! Exercises the proxy against the production Axum listener and trace router. +//! +//! A child test process owns the real HTTP server. A TLS relay additionally +//! exercises the proxy's encrypted upstream transport without inventing ingress +//! metadata or replacing the server's trace authorization with an echo handler. + +#![cfg(any(target_os = "macos", target_os = "linux"))] + +use std::convert::Infallible; +use std::io::Cursor; +use std::net::SocketAddr; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use clap::Parser as _; +use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; +use http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header}; +use http_body_util::{BodyExt as _, Full}; +use hyper::client::conn::http1::SendRequest; +use hyper::service::service_fn; +use hyper_util::rt::TokioIo; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use serde_json::json; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::JoinHandle; +use tokio_rustls::{TlsAcceptor, TlsConnector}; +use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_cli::commands::dev::proxy::{ProxyArgs, ca, config}; +use trusted_server_core::settings::Settings; + +mod support; + +const PUBLIC_HOST: &str = "www.publisher.example.com"; +const PUBLIC_AUTHORITY: &str = "www.publisher.example.com:8443"; +const PUBLIC_ORIGIN: &str = "https://www.publisher.example.com:8443"; +const FIXTURE_ADDR: &str = "TS_TEST_AXUM_ADDR"; +const FIXTURE_ORIGIN: &str = "TS_TEST_AXUM_ORIGIN"; +const FIXTURE_TRUST: &str = "TS_TEST_AXUM_TRUST"; + +/// Run only as a fixture child; the parent always kills and reaps this process. +#[test] +#[ignore = "server fixture invoked only by proxy integration tests"] +fn trusted_server_fixture() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let addr: SocketAddr = std::env::var(FIXTURE_ADDR) + .expect("should supply the fixture listener address") + .parse() + .expect("should parse the fixture listener address"); + let origin = std::env::var(FIXTURE_ORIGIN).expect("should supply the publisher origin"); + let trust = std::env::var(FIXTURE_TRUST).expect("should supply the forwarder policy"); + let mut value = json!({ + "handlers": [{ + "path": "^/_ts/admin", + "username": "example-admin", + "password": "fictional-admin-password-0123456789" + }], + "publisher": { + "domain": "publisher.example.com", + "cookie_domain": ".publisher.example.com", + "origin_url": origin, + "origin_host_header_override": "origin.example.com", + "proxy_secret": "fictional-proxy-signing-secret-0123456789" + }, + "ec": {"passphrase": "fictional-ec-passphrase-0123456789"}, + "integrations": { + "gpt_diagnostics": {"enabled": true, "trace_page_enabled": true} + } + }); + if trust == "enabled" { + value["trusted_forwarder"] = json!({ + "auth_header": "x-ts-forwarder-auth", + "shared_secret": support::FORWARDER_TOKEN + }); + } + let settings = Settings::from_json_value(value).expect("should load fixture settings"); + let router = TrustedServerApp::routes_with_settings(settings) + .expect("should build the actual Trusted Server router"); + AxumDevServer::with_config( + router, + AxumDevServerConfig { + addr, + enable_ctrl_c: false, + }, + ) + .run() + .expect("should run the actual Axum HTTP listener"); +} + +struct Fixture { + addr: SocketAddr, + child: Child, + origin_headers: Arc>>, + tasks: Vec>, +} + +impl Drop for Fixture { + fn drop(&mut self) { + for task in &self.tasks { + task.abort(); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +async fn start_fixture(trust: bool) -> Fixture { + let origin = TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind the fictional publisher origin"); + let origin_url = format!( + "http://{}", + origin.local_addr().expect("should read the origin address") + ); + let headers = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&headers); + let html = + format!("next"); + let origin_task = tokio::spawn(async move { + while let Ok((stream, _)) = origin.accept().await { + let recorded = Arc::clone(&recorded); + let html = html.clone(); + tokio::spawn(async move { + let service = service_fn(move |request: Request| { + recorded + .lock() + .expect("should record publisher-bound headers") + .push(request.headers().clone()); + let html = html.clone(); + async move { + Ok::<_, Infallible>( + Response::builder() + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(Full::new(Bytes::from(html))) + .expect("should return publisher HTML"), + ) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(stream), service) + .await; + }); + } + }); + let reserved = std::net::TcpListener::bind("127.0.0.1:0") + .expect("should reserve the Axum fixture address"); + let addr = reserved.local_addr().expect("should read the Axum address"); + drop(reserved); + let child = Command::new(std::env::current_exe().expect("should locate the test executable")) + .args([ + "--ignored", + "--exact", + "trusted_server_fixture", + "--nocapture", + ]) + .env(FIXTURE_ADDR, addr.to_string()) + .env(FIXTURE_ORIGIN, origin_url) + .env(FIXTURE_TRUST, if trust { "enabled" } else { "disabled" }) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .expect("should spawn the real Axum fixture"); + let mut fixture = Fixture { + addr, + child, + origin_headers: headers, + tasks: vec![origin_task], + }; + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if TcpStream::connect(addr).await.is_ok() { + return; + } + assert!( + fixture + .child + .try_wait() + .expect("should inspect the child") + .is_none(), + "should keep the Axum fixture running until ready" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("should start the actual Axum listener promptly"); + fixture +} + +async fn add_tls_relay(fixture: &mut Fixture) -> SocketAddr { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind the upstream TLS relay"); + let addr = listener.local_addr().expect("should read the TLS address"); + let certificate = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]) + .expect("should generate the fictional relay certificate"); + let tls = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from(certificate.cert.der().to_vec())], + PrivateKeyDer::try_from(certificate.key_pair.serialize_der()) + .expect("should encode the relay key"), + ) + .expect("should configure upstream TLS"); + let acceptor = TlsAcceptor::from(Arc::new(tls)); + let target = fixture.addr; + fixture.tasks.push(tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let mut encrypted = acceptor + .accept(stream) + .await + .expect("should accept proxy TLS"); + let mut plain = TcpStream::connect(target) + .await + .expect("should connect TLS relay to the actual listener"); + let _ = tokio::io::copy_bidirectional(&mut encrypted, &mut plain).await; + }); + } + })); + addr +} + +async fn browser( + fixture: &mut Fixture, + plaintext: bool, + rewrite_host: bool, + credential: bool, +) -> SendRequest> { + let addr = if plaintext { + fixture.addr + } else { + add_tls_relay(fixture).await + }; + let token_directory = tempfile::tempdir().expect("should create the proxy token directory"); + let token_path = token_directory.path().join("forwarder-token"); + std::fs::write(&token_path, support::FORWARDER_TOKEN) + .expect("should write the fictional token"); + let mapping = format!("{PUBLIC_HOST}={addr}"); + let mut arguments = vec![ + "ts", + "--map", + &mapping, + "--listen", + "127.0.0.1:0", + "--insecure", + "--forwarder-secret-file", + token_path.to_str().expect("should encode the token path"), + ]; + if plaintext { + arguments.push("--upstream-plaintext"); + } + if rewrite_host { + arguments.push("--rewrite-host"); + } + let parsed = ProxyArguments::try_parse_from(arguments).expect("should parse proxy arguments"); + let mut configuration = + config::resolve(&parsed.args).expect("should resolve the real proxy configuration"); + if !credential { + configuration.forwarder_secret = None; + } + connect_browser(configuration).await +} + +#[derive(clap::Parser)] +struct ProxyArguments { + #[command(flatten)] + args: ProxyArgs, +} + +async fn connect_browser(configuration: config::ResolvedConfig) -> SendRequest> { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let directory = tempfile::tempdir().expect("should create the browser CA directory"); + let ca = Arc::new( + ca::CertAuthority::load_or_generate(directory.path()) + .expect("should generate the browser CA"), + ); + let pem = std::fs::read(ca::CertAuthority::cert_path(directory.path())) + .expect("should read the browser CA"); + let mut roots = rustls::RootCertStore::empty(); + for certificate in rustls_pemfile::certs(&mut Cursor::new(pem)) { + roots + .add(certificate.expect("should decode the browser CA")) + .expect("should trust the browser CA"); + } + let tls_config = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + let proxy = support::spawn_proxy(configuration, ca).await; + let mut tcp = TcpStream::connect(proxy) + .await + .expect("should connect the browser to the proxy"); + tcp.write_all( + format!("CONNECT {PUBLIC_AUTHORITY} HTTP/1.1\r\nHost: {PUBLIC_AUTHORITY}\r\n\r\n") + .as_bytes(), + ) + .await + .expect("should request the actual browser tunnel"); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + assert!( + head.len() < 8192, + "should bound the CONNECT response headers" + ); + head.push( + tcp.read_u8() + .await + .expect("should read the CONNECT response"), + ); + } + assert!( + head.starts_with(b"HTTP/1.1 200 "), + "should establish the mapped browser tunnel" + ); + let tls = TlsConnector::from(Arc::new(tls_config)) + .connect( + ServerName::try_from(PUBLIC_HOST.to_owned()) + .expect("should parse the browser TLS name"), + tcp, + ) + .await + .expect("should authenticate the proxy leaf using the generated CA"); + let (sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(tls)) + .await + .expect("should establish a real browser HTTP connection through the proxy"); + tokio::spawn(async move { + let _ = connection.await; + }); + sender +} + +async fn send( + browser: &mut SendRequest>, + request: Request>, +) -> (StatusCode, HeaderMap, Bytes) { + tokio::time::timeout(Duration::from_secs(5), async { + let response = browser + .send_request(request) + .await + .expect("should receive the actual Trusted Server response"); + let (parts, body) = response.into_parts(); + let body = body + .collect() + .await + .expect("should collect the response") + .to_bytes(); + (parts.status, parts.headers, body) + }) + .await + .expect("should complete the proxied response promptly") +} + +fn action(path: &str, origin: &str) -> Request> { + let action = if path.starts_with("/_ts/trace/end") { + "end" + } else { + "enable" + }; + Request::builder() + .method(Method::POST) + .uri(path) + .header(header::HOST, PUBLIC_AUTHORITY) + .header(header::ORIGIN, origin) + .header("sec-fetch-site", "same-origin") + .header("x-ts-trace-action", action) + .body(Full::new(Bytes::new())) + .expect("should build a deliberate trace action") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn authenticated_proxy_reaches_real_trace_actions_for_every_upstream_mode() { + for plaintext in [false, true] { + for rewrite_host in [false, true] { + let mut fixture = start_fixture(true).await; + let mut browser = browser(&mut fixture, plaintext, rewrite_host, true).await; + for (action_name, lifetime) in [("enable", "Max-Age=1800"), ("end", "Max-Age=0")] { + let request = action(&format!("/_ts/trace/{action_name}"), PUBLIC_ORIGIN); + let (status, headers, body) = send(&mut browser, request).await; + assert_eq!( + status, + StatusCode::OK, + "should accept {action_name} with plaintext={plaintext}, rewrite_host={rewrite_host}" + ); + let cookie = headers + .get(header::SET_COOKIE) + .expect("should mutate the diagnostics cookie") + .to_str() + .expect("should encode the diagnostics cookie"); + assert!( + cookie.contains(lifetime), + "should apply {action_name} lifetime: {cookie}" + ); + assert!( + cookie.contains("Secure") && cookie.contains("SameSite=Lax"), + "should preserve the browser cookie policy: {cookie}" + ); + assert!( + !cookie.contains("Domain="), + "should keep diagnostics cookies host-only" + ); + assert_eq!( + serde_json::from_slice::(&body) + .expect("should decode the real action response"), + json!({"mutation_requested": true}), + "should execute the actual action handler" + ); + } + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn actual_trace_authorization_rejects_foreign_origin_queries_and_missing_trust() { + let mut fixture = start_fixture(true).await; + let mut authenticated = browser(&mut fixture, true, true, true).await; + for (path, origin) in [ + ("/_ts/trace/enable", "https://foreign.example.com"), + ("/_ts/trace/enable?source=test", PUBLIC_ORIGIN), + ("/_ts/trace/end?", PUBLIC_ORIGIN), + ] { + let (status, headers, _) = send(&mut authenticated, action(path, origin)).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "should reject browser controls for {path} from {origin}" + ); + assert!( + !headers.contains_key(header::SET_COOKIE), + "should never mutate rejected cookies" + ); + } + let mut duplicated = action("/_ts/trace/enable", PUBLIC_ORIGIN); + duplicated + .headers_mut() + .append(header::ORIGIN, HeaderValue::from_static(PUBLIC_ORIGIN)); + let (status, headers, _) = send(&mut authenticated, duplicated).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "should preserve duplicate browser Origin fields so the server rejects ambiguity" + ); + assert!( + !headers.contains_key(header::SET_COOKIE), + "should never mutate cookies for ambiguous Origin" + ); + let mut unauthenticated = browser(&mut fixture, true, true, false).await; + let (status, headers, _) = send( + &mut unauthenticated, + action("/_ts/trace/enable", PUBLIC_ORIGIN), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "should require proxy credentials for the browser origin" + ); + assert!( + !headers.contains_key(header::SET_COOKIE), + "should reject unauthenticated mutations" + ); + let mut default_fixture = start_fixture(false).await; + let mut untrusted = browser(&mut default_fixture, true, true, true).await; + let (status, headers, _) = + send(&mut untrusted, action("/_ts/trace/enable", PUBLIC_ORIGIN)).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "should require explicit server opt-in even with a valid proxy token" + ); + assert!( + !headers.contains_key(header::SET_COOKIE), + "should keep default policy fail-closed" + ); + let transport_origin = format!("http://{}", default_fixture.addr); + let (status, _, _) = send( + &mut untrusted, + action("/_ts/trace/enable", &transport_origin), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "should retain the default transport-origin authorization when forwarding is disabled" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn real_publisher_rewrites_public_port_and_strips_forwarding_credentials() { + let mut fixture = start_fixture(true).await; + let mut browser = browser(&mut fixture, true, true, true).await; + let request = Request::builder() + .uri("/article") + .header(header::HOST, PUBLIC_AUTHORITY) + .header(header::ORIGIN, PUBLIC_ORIGIN) + .header(header::ACCEPT, "text/html") + .header("sec-fetch-dest", "document") + .body(Full::new(Bytes::new())) + .expect("should build a publisher navigation"); + let (status, _, body) = send(&mut browser, request).await; + assert_eq!( + status, + StatusCode::OK, + "should fetch the configured publisher origin" + ); + let html = std::str::from_utf8(&body).expect("should decode publisher HTML"); + assert!( + html.contains(&format!("{PUBLIC_ORIGIN}/next")), + "should preserve the real browser authority and port: {html}" + ); + { + let requests = fixture + .origin_headers + .lock() + .expect("should inspect publisher requests"); + assert_eq!( + requests.len(), + 1, + "should send exactly one publisher request" + ); + assert_eq!( + requests[0].get(header::HOST).and_then(|v| v.to_str().ok()), + Some("origin.example.com"), + "should retain the configured publisher backend Host" + ); + assert_eq!( + requests[0] + .get(header::ORIGIN) + .and_then(|v| v.to_str().ok()), + Some(PUBLIC_ORIGIN), + "should preserve the browser Origin on publisher-bound requests" + ); + for name in [ + "x-ts-forwarder-auth", + "x-forwarded-host", + "x-forwarded-proto", + ] { + assert!( + !requests[0].contains_key(name), + "should remove {name} before ordinary outbound forwarding" + ); + } + } + let body = json!({"url": "//cdn.example.com/asset.js"}).to_string(); + let request = Request::builder() + .method(Method::POST) + .uri("/first-party/sign") + .header(header::HOST, PUBLIC_AUTHORITY) + .header(header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body))) + .expect("should build a protocol-relative signing request"); + let (status, _, body) = send(&mut browser, request).await; + assert_eq!( + status, + StatusCode::OK, + "should sign a first-party proxy URL" + ); + let value: serde_json::Value = + serde_json::from_slice(&body).expect("should decode signed URL metadata"); + assert_eq!( + value["base"], "https://cdn.example.com/asset.js", + "should use public HTTPS for protocol-relative signing over plaintext ingress" + ); +} diff --git a/crates/trusted-server-cli/tests/support/mod.rs b/crates/trusted-server-cli/tests/support/mod.rs index f717b7ce1..68b25f7fb 100644 --- a/crates/trusted-server-cli/tests/support/mod.rs +++ b/crates/trusted-server-cli/tests/support/mod.rs @@ -29,6 +29,8 @@ pub struct ProxiedResponse { pub seen_orig_host: String, pub seen_forwarded_host: String, pub seen_origin: String, + pub seen_forwarded_proto: String, + pub seen_forwarder_auth: String, pub path: String, } @@ -238,6 +240,7 @@ fn upstream_identity() -> (Vec>, PrivateKeyDer<'static>) } fn upstream_tls_acceptor() -> TlsAcceptor { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let (chain, key) = upstream_identity(); let mut config = rustls::ServerConfig::builder() .with_no_client_auth() @@ -293,6 +296,68 @@ pub async fn start_echo_upstream() -> Upstream { start_upstream(false, Duration::ZERO, false, false).await } +/// Starts an echo fixture over plaintext HTTP. +pub async fn start_plaintext_echo_upstream() -> Upstream { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind plaintext upstream"); + let addr = listener.local_addr().expect("should read upstream address"); + let counters = Arc::new(UpstreamCounters::default()); + let task_counters = Arc::clone(&counters); + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + task_counters + .accepted_connections + .fetch_add(1, Ordering::Relaxed); + let counters = Arc::clone(&task_counters); + tokio::spawn(async move { + serve_upstream_connection( + &mut stream, + false, + Duration::ZERO, + false, + false, + &counters, + ) + .await; + }); + } + }); + Upstream { addr, counters } +} + +/// Fictional token shared by authenticated forwarding fixtures. +pub const FORWARDER_TOKEN: &str = "example-forwarder-token-0123456789"; + +/// Resolves a file-backed credential using the real CLI configuration path. +pub fn test_config_authenticated( + addr: &SocketAddr, + plaintext: bool, + rewrite_host: bool, +) -> config::ResolvedConfig { + let directory = tempfile::tempdir().expect("should create credential directory"); + let path = directory.path().join("token"); + std::fs::write(&path, FORWARDER_TOKEN).expect("should write test token"); + let mapping = format!("{FROM_HOST}={addr}"); + let mut args = vec![ + "ts", + "--map", + &mapping, + "--listen", + "127.0.0.1:0", + "--insecure", + "--forwarder-secret-file", + path.to_str().expect("should encode credential path"), + ]; + if plaintext { + args.push("--upstream-plaintext"); + } + if rewrite_host { + args.push("--rewrite-host"); + } + resolve(&args) +} + /// Starts the echo upstream with a fixed delay before every response. pub async fn start_delayed_echo_upstream(response_delay: Duration) -> Upstream { start_upstream(false, response_delay, false, false).await @@ -391,6 +456,16 @@ pub async fn start_trailer_upstream() -> Upstream { /// Starts an origin that accepts a declared request trailer and returns a /// response trailer only when the proxy also forwards `TE: trailers`. pub async fn start_request_trailer_upstream() -> Upstream { + start_request_trailer_contract_upstream(None).await +} + +/// Requires authentication removal from trailers while retaining the initial +/// proxy credential and the benign checksum trailer. +pub async fn start_forwarder_trailer_upstream(expected_auth: &'static str) -> Upstream { + start_request_trailer_contract_upstream(Some(expected_auth)).await +} + +async fn start_request_trailer_contract_upstream(expected_auth: Option<&'static str>) -> Upstream { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("should bind request-trailer upstream"); @@ -447,7 +522,23 @@ pub async fn start_request_trailer_upstream() -> Upstream { let request_trailer_arrived = trailers .to_ascii_lowercase() .contains("x-request-checksum: verified"); - let accepted = body == b"data" + let authentication_valid = expected_auth.is_none_or(|expected| { + let declaration_has_auth = head + .lines() + .filter_map(|line| line.split_once(':')) + .filter(|(name, _)| name.eq_ignore_ascii_case("trailer")) + .flat_map(|(_, value)| value.split(',')) + .any(|name| name.trim().eq_ignore_ascii_case("x-ts-forwarder-auth")); + let trailer_has_auth = trailers + .lines() + .filter_map(|line| line.split_once(':')) + .any(|(name, _)| name.eq_ignore_ascii_case("x-ts-forwarder-auth")); + !declaration_has_auth + && !trailer_has_auth + && header_value(&head, "x-ts-forwarder-auth").unwrap_or_default() == expected + }); + let accepted = authentication_valid + && body == b"data" && trailer_declared && accepts_trailers && connection_declares_te @@ -659,7 +750,18 @@ async fn serve_upstream_connection( let host = header_value(&head, "host").unwrap_or_default(); let orig_host = header_value(&head, "x-orig-host").unwrap_or_default(); let fwd_host = header_value(&head, "x-forwarded-host").unwrap_or_default(); - let origin = header_value(&head, "origin").unwrap_or_default(); + let origin = head + .lines() + .skip(1) + .filter_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("origin") + .then(|| value.trim().to_string()) + }) + .collect::>() + .join("|"); + let proto = header_value(&head, "x-forwarded-proto").unwrap_or_default(); + let auth = header_value(&head, "x-ts-forwarder-auth").unwrap_or_default(); let has_auth = header_value(&head, "authorization").is_some(); if fail_second_request && request_index == 2 { @@ -669,8 +771,9 @@ async fn serve_upstream_connection( let (status_line, body) = if gated && !has_auth { ("HTTP/1.1 401 Unauthorized", String::new()) } else { - let body = - format!("host={host};orig={orig_host};fwd={fwd_host};origin={origin};path={path}"); + let body = format!( + "host={host};orig={orig_host};fwd={fwd_host};origin={origin};proto={proto};auth={auth};path={path}" + ); ("HTTP/1.1 200 OK", body) }; if !response_delay.is_zero() { @@ -1006,6 +1109,7 @@ impl ServerCertVerifier for AcceptAny { } fn accept_any_connector() -> TlsConnector { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut config = rustls::ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(AcceptAny)) @@ -1444,6 +1548,20 @@ pub async fn drive_request_trailer(proxy: SocketAddr) -> (Vec, String) { .expect("origin should return a chunked response with trailers") } +/// Sends a benign trailer alongside duplicate hostile credential trailers. +pub async fn drive_forwarder_auth_trailers(proxy: SocketAddr) -> (Vec, String) { + let mut tls = open_mitm_client(proxy).await; + let request = format!( + "POST /request-trailer HTTP/1.1\r\nHost: {FROM_HOST}\r\nX-Ts-Forwarder-Auth: hostile-header\r\nTransfer-Encoding: chunked\r\nTrailer: X-Ts-Forwarder-Auth, x-request-checksum\r\nTE: trailers\r\nConnection: TE\r\n\r\n4\r\ndata\r\n0\r\nX-Ts-Forwarder-Auth: hostile-trailer\r\nx-ts-forwarder-auth: second-hostile-trailer\r\nx-request-checksum: verified\r\n\r\n" + ); + tls.write_all(request.as_bytes()) + .await + .expect("should send credential trailer request"); + read_chunked_response(&mut tls) + .await + .expect("should read trailer contract response") +} + /// Streams `body` through the proxy with chunked request framing and returns /// the decoded chunked response body. pub async fn drive_chunked_body( @@ -1547,6 +1665,29 @@ pub async fn drive_request_with_origin( read_http_response(&mut tls).await } +/// Sends raw decrypted requests sequentially over one mapped CONNECT tunnel. +pub async fn drive_raw_mapped_requests( + proxy: SocketAddr, + requests: &[String], +) -> Vec { + let tcp = proxy_connect(proxy, &format!("{FROM_HOST}:443")).await; + let server_name = + ServerName::try_from(FROM_HOST.to_string()).expect("should parse server name"); + let mut tls = accept_any_connector() + .connect(server_name, tcp) + .await + .expect("should establish TLS tunnel"); + let mut responses = Vec::new(); + for request in requests { + tls.write_all(request.as_bytes()) + .await + .expect("should send raw request"); + tls.flush().await.expect("should flush raw request"); + responses.push(read_http_response(&mut tls).await); + } + responses +} + /// Reads one HTTP/1.1 response (head + Content-Length body) and parses the echo. async fn read_http_response(stream: &mut S) -> ProxiedResponse where @@ -1591,6 +1732,8 @@ where seen_orig_host: echo.orig, seen_forwarded_host: echo.fwd, seen_origin: echo.origin, + seen_forwarded_proto: echo.proto, + seen_forwarder_auth: echo.auth, path: echo.path, } } @@ -1602,6 +1745,8 @@ struct Echo { orig: String, fwd: String, origin: String, + proto: String, + auth: String, path: String, } @@ -1617,6 +1762,10 @@ fn parse_echo(body: &str) -> Echo { echo.fwd = v.to_string(); } else if let Some(v) = field.strip_prefix("origin=") { echo.origin = v.to_string(); + } else if let Some(v) = field.strip_prefix("proto=") { + echo.proto = v.to_string(); + } else if let Some(v) = field.strip_prefix("auth=") { + echo.auth = v.to_string(); } else if let Some(v) = field.strip_prefix("path=") { echo.path = v.to_string(); } diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index f2dc04447..115d90d26 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -62,11 +62,10 @@ ts dev proxy \ `--rewrite-host` sends the upstream `Host: ` so upstreams that reject the default `Host: ` — most dev and staging services, which aren't configured -for the production hostname — still serve a page. Trade-off: against a real -Trusted Server adapter, first-party URLs then render on the upstream host, not the -production domain. To keep them on the production domain, point at an upstream that -accepts `Host: ` and omit `--rewrite-host` — see -[Host header behavior](#host-header-behavior). +for the production hostname — still serve a page. To keep first-party URLs and +trace authorization on the browser's production origin, configure authenticated +forwarding on the Trusted Server upstream and supply `--forwarder-secret-file`. +See [authenticated forwarding](#authenticated-forwarding). Run `ts dev proxy --help` to list every flag. @@ -85,7 +84,7 @@ invocation with explicit options but no complete rewrite rule fails with a `no rewrite rule` error before touching system proxy state. Connection options — `--rewrite-host`, `--basic-auth`/`--basic-auth-file`, -`--insecure`, and `--upstream-plaintext` — apply to every mapping, not per-rule. +`--forwarder-secret-file`, `--insecure`, and `--upstream-plaintext` — apply to every mapping, not per-rule. ### Explicit rule and browser launch @@ -286,13 +285,11 @@ certificate is imported until the subject check succeeds. ## Host header behavior -The proxy always sends `X-Forwarded-Host: ` (the production hostname) — the -standard "original host" header for a forward proxy. Trusted Server core anchors -all HTML/URL rewriting to it (it prefers `X-Forwarded-Host`, then `Host`), so -**first-party URLs stay on the production domain regardless of the `Host` header — -as long as the upstream preserves `X-Forwarded-Host`**. That decouples routing -(`Host`) from the first-party host. (Real Trusted Server adapters strip inbound -`X-Forwarded-Host`; the caveat below covers what that means with `--rewrite-host`.) +The proxy sends the validated inbound browser authority as `X-Forwarded-Host`, +including any explicit browser port, and `X-Forwarded-Proto: https` because the +browser leg uses TLS. With authenticated forwarding enabled on both proxy and +server, Trusted Server uses those values for public URLs and trace Origin checks. +The upstream routing `Host` remains a separate choice. By default `Host: ` too. Fastly routes by SNI (`= TO`) and passes `Host` through unchanged, so `Host: ` reaches the upstream — but the upstream still @@ -312,8 +309,8 @@ ts dev proxy \ ``` The proxy dials `192.0.2.10` while the SNI stays `ts.example-publisher.com` and -`X-Forwarded-Host` stays `www.example-publisher.com` — so TS rewrites first-party -URLs onto the production domain. This keeps the tool self-contained — no +`X-Forwarded-Host` stays `www.example-publisher.com`. With authenticated +forwarding configured, TS rewrites first-party URLs onto the production domain. This keeps the tool self-contained — no `/etc/hosts` edit. (Pointing `--to` at a bare IP instead would make the SNI an IP, which sends no SNI extension at all, so a host-routed endpoint serves its default vhost.) Add `--insecure` if the endpoint serves a certificate that doesn't match @@ -321,47 +318,77 @@ the hostname. **Sending `Host: TO`.** If your upstream routes or validates on its _own_ hostname (e.g. a Fastly Deliver service that rejects an unconfigured `Host`), pass -`--rewrite-host` to send `Host: `. The proxy still stamps -`X-Forwarded-Host: `, so first-party URL rewriting stays anchored to `FROM` -**as long as the upstream preserves that header**. - -With `--rewrite-host`, the proxy also replaces a single same-origin `Origin` -(`https://`, plus the port the browser connected to when it isn't 443) -with the `TO` origin (`http://` with `--upstream-plaintext`, `https://` -otherwise, plus any non-default port), so `Origin` names the same authority as -`Host`. Trusted Server's trace actions compare `Origin` against their own -origin, so they then accept proxied same-origin requests. Cross-site, `null`, -plain `http://`, and duplicated `Origin` values pass through unchanged. - -The rewrite applies only to the requests Trusted Server consumes itself and that -check `Origin`: `POST /_ts/trace/enable` and `POST /_ts/trace/end`, matched by -exact path with any query string ignored. Trusted Server forwards the browser's -request headers to the publisher origin and to integration vendors — including -integrations an operator mounts under `/_ts`, such as a Didomi `proxy_path` of -`_ts/consent` — so every other request keeps the browser's real `Origin`, as in -production, and publisher or vendor `Origin` checks keep working. - -> **Caveat with real Trusted Server adapters.** The Fastly and Spin adapter -> request paths strip inbound `X-Forwarded-Host` before routing, so with -> `--rewrite-host` a real Trusted Server upstream falls back to `Host` (`TO`) and -> emits first-party URLs on `TO`, not `FROM`. Even so, `--rewrite-host` is the -> right choice for most upstreams — dev and staging services rarely have the -> production hostname configured and would reject the plain `Host: `. Drop -> it only when the upstream is configured to accept `Host: ` and you -> specifically need first-party URLs anchored to `FROM` — the `--resolve` example -> above is exactly that case. +`--rewrite-host` to send `Host: `. The proxy preserves every browser `Origin` +value on all routes, including trace Enable/End, publisher requests, and vendor +integration requests. Trusted Server validates the browser Origin against the +authenticated public origin when forwarding is configured. The TLS SNI is always the `TO` host either way: -| Form | `Host` header | `X-Forwarded-Host` | TLS SNI | -| ---------------- | ------------- | ------------------ | --------- | -| _(omitted)_ | `FROM` | `FROM` | `TO` host | -| `--rewrite-host` | `TO` host | `FROM` | `TO` host | +| Form | `Host` header | `X-Forwarded-Host` | TLS SNI | +| ---------------- | ------------- | -------------------------------- | --------- | +| _(omitted)_ | `FROM` | Browser authority, port included | `TO` host | +| `--rewrite-host` | `TO` host | Browser authority, port included | `TO` host | **Port handling:** with `--rewrite-host` and a non-default `TO` port (e.g. `localhost:3000`), the port is included in the `Host` header but never in the SNI (a bare hostname; a port in SNI is invalid). +## Authenticated forwarding + +This feature requires a Trusted Server upstream with authenticated forwarder +support enabled. Provision the same secret in the server's trusted-forwarder +configuration and in a local file, using the authentication header +`x-ts-forwarder-auth`. The publisher hostname must be allowed by the server's +publisher domain configuration. The server consumes the credential before +routing or forwarding to publishers and vendors. + +Configure the upstream application with this optional section: + +```toml +[trusted_forwarder] +auth_header = "x-ts-forwarder-auth" +shared_secret = "trusted_forwarder_shared_secret" +``` + +`shared_secret` names a server secret-store key; provision that key with the +actual token bytes from the local file, excluding its line terminator. The publisher domain or its subdomains must +cover every mapped browser hostname. Omitting the section keeps the server's +default transport-origin behavior. + +Generate a local token file, provision its token in the server secret store, +and pass only the file path to the proxy: + +```bash +umask 077 +openssl rand -hex 32 > ./forwarder-token.txt + +ts dev proxy \ + --map www.example-publisher.com=trusted-server-example.edgecompute.app \ + --rewrite-host \ + --forwarder-secret-file ./forwarder-token.txt \ + --launch chrome +``` + +The file must contain at least 32 ASCII graphic bytes on one line, with an +optional single LF or CRLF terminator. Spaces, blank or extra lines, and non-ASCII +bytes are rejected. It is read once at startup. The token is redacted in debug +output and marked sensitive in the HTTP header value. + +The proxy removes incoming `x-ts-forwarder-auth` fields on mapped MITM requests, +including trailer declarations and streamed trailer fields, even when no +credential is configured. With the option enabled it stamps the +validated token after hop-by-hop sanitation, alongside the actual browser +`X-Forwarded-Host` and `X-Forwarded-Proto: https`. A missing, duplicate, malformed, +or conflicting Host/absolute-URI authority receives `400`; the proxy never +signs a guessed CONNECT host. Each request must match its own mapping. +Unmatched blind tunnels and plain HTTP forwarding keep their existing behavior. + +Without the server opt-in, forwarded metadata does not establish a trusted +public origin. In particular, rewriting Host can change the origin Trusted Server +uses for public URLs and can make browser trace actions fail Origin validation. +Keep the credential file outside version control. + ## Non-loopback listen The proxy binds `127.0.0.1:18080` by default. A non-loopback `--listen` is @@ -376,7 +403,8 @@ ts dev proxy \ Even with `--allow-non-loopback`, unmatched `CONNECT` authorities are refused (`403`) rather than blind-tunneled, so the proxy cannot act as an open CONNECT -proxy on the LAN. +proxy on the LAN. Injected forwarding credentials and Basic auth require a +loopback listener, even with `--allow-non-loopback`. ## All options diff --git a/docs/superpowers/plans/2026-10-09-authenticated-forwarder.md b/docs/superpowers/plans/2026-10-09-authenticated-forwarder.md new file mode 100644 index 000000000..04601c730 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-authenticated-forwarder.md @@ -0,0 +1,82 @@ +# Authenticated forwarder implementation plan + +> For agentic workers: use `superpowers:subagent-driven-development` for independent implementation and review tasks. Review each completed task for requirements and code quality before moving on. + +**Goal:** Let the development proxy preserve browser Origin and authenticate the browser-facing host and scheme, with the server changes in #1107 and CLI changes in #1251. + +**Architecture:** Preserve immutable runtime `RequestIngress` transport facts. Capture a separate validated public origin before trace pre-dispatch and forwarded-header sanitation. Share this origin between trace authorization and request information used to construct public URLs. Production behavior remains unchanged when the optional configuration is absent. + +**Tech stack:** Rust, HTTP request extensions, existing secret-store envelopes, constant-time digest comparison, Fastly/Axum/Cloudflare/Spin, native CLI proxy and browser integration fixtures. + +**Wire contract:** The CLI uses `x-ts-forwarder-auth`; configure the server's optional authentication header to that name when using this CLI. The forwarded sources are `x-forwarded-host` and `x-forwarded-proto`. Secrets contain at least 32 ASCII graphic bytes; a file may end in a single line terminator. Only one field of each kind is accepted, with no lists, whitespace, userinfo or URL suffixes. Schemes are HTTP/HTTPS and origins canonicalize hostname case and default ports. Invalid forwarding falls back to transport facts. Authentication headers cannot overlap forwarded sources, trace controls or configured client-IP trust headers. + +**Adapter seam:** Preparation is the first operation of the existing trace pre-dispatch hook, which is registered by every adapter. Fastly captures a typed preparation outcome before its existing native sanitation and inserts that outcome after conversion. Preparation is idempotent and contains no secret material. Startup-error routers remain local-only. + +**Authority and evidence:** Credential stamping requires a single valid inbound Host, or an unambiguous absolute URI authority, with agreement when both exist. Never authenticate a guessed CONNECT fallback. Preserve browser ports. Forwarding must not upgrade cookie health, request-target provenance or header fidelity. + +## Task 1: Server configuration and origin contract (#1107) + +Files: core `settings.rs`, `config.rs`, `config_payload.rs`, `http_util.rs`, a focused `forwarder.rs` module, and `lib.rs`. + +- [x] Add failing tests for optional/omitted configuration, redacted secrets, secret-store resolution, placeholder/length validation and unsafe/colliding authentication names. +- [x] Implement optional `TrustedForwarderConfig` using the existing trusted-client-IP secret conventions. +- [x] Add failing tests for authenticated public origin: single auth/host/proto fields; valid publisher domain/subdomains and explicit ports; duplicate/list/malformed/foreign values; default fallback; removal of authentication fields regardless of acceptance. +- [x] Implement a typed authenticated public-origin extension and strict canonical parsing. Compare fixed-size secret digests in constant time. Do not mutate `RequestIngress` or use public forwarding to manufacture target/header fidelity. +- [x] Make `RequestInfo` read the authenticated public-origin extension first. +- [x] Run native core tests and independent requirements/code-quality review; fix findings. + +## Task 2: Adapter entry and trace authorization (#1107) + +Files: four adapters' entry points/hooks/middleware, core `trace/actions.rs` and `trace/dispatch.rs`, adapter/parity fixtures. + +- [x] Write regression tests for real trace enable/end over rewritten Host and plaintext transport with authenticated forwarding. Cover invalid/missing/duplicate auth and Origin, foreign origin, query strings and empty-body/action/Fetch Metadata controls. +- [x] Resolve forwarding before trace pre-dispatch and before any sanitizer removes inputs in every adapter; remove the configured secret before routing/forwarding, including rejected requests. +- [x] Check received URI/Host consistency against transport ingress independently of browser Origin, which must match the effective public origin. +- [x] Keep trace transport-cookie/header-fidelity rules intact. +- [x] Run target-matched tests and independent review; fix findings. + +## Task 3: Public-origin consumers and documentation (#1107) + +Files determined by complete consumer audit: core publisher/proxy/integrations/HTML paths, configuration guide/example, trace design specification. + +- [x] Classify each URI/Host/scheme consumer as public-origin or transport-sensitive. Add behavioral regression tests before changing public-origin consumers (including protocol-relative signing and redirects). +- [x] Use the shared effective origin for generated public URLs and public-scheme decisions. Keep actual upstream routing and ingress evidence tied to transport. +- [x] Document opt-in trust, host bounds, secret-store provisioning, unchanged defaults and rejection/fallback rules. Amend trace specification's forwarded-header prohibition to permit only authenticated forwarding. +- [x] Run relevant core/integration tests and independent review; fix findings. + +## Task 4: Proxy credentials and unchanged Origin (#1251) + +Files: CLI proxy `mod.rs`, `config.rs`, `rewrite.rs`, `server.rs`, E2E support/tests, proxy guide and design specification. + +- [x] Write failing tests for `--forwarder-secret-file`, valid/invalid file contents, redacted/sensitive header values and non-loopback credential rejection. +- [x] Add file-only, prevalidated forwarder credentials. Remove incoming forwarder authentication on mapped traffic and stamp the configured token after hop-by-hop sanitation. +- [x] Preserve and validate the inbound browser authority, including its port, when stamping authenticated forwarding; reject ambiguity rather than authenticating guessed metadata. +- [x] Remove route-specific Origin rewriting and `RewriteOutcome::upstream_origin`. Preserve all browser Origin fields unchanged. +- [x] Replace echo tests with meaningful forwarding contract coverage, including auth overwrite/absence, plaintext/TLS, Host rewriting, ports, near-miss routes and connection reuse. +- [x] Update guide/header tables/specification; remove obsolete caveats about Origin rewriting and document server dependency. +- [x] Run CLI tests/lint and independent requirements/code-quality review; fix findings. + +## Task 5: Joint verification and PR updates + +- [x] Run real proxy-to-server trace and public-URL tests using both branches: TLS/plaintext, rewritten/preserved Host, public non-default ports, valid auth and hostile/ambiguous headers. Verify enable/end cookies, identify CORS, vendor Origin preservation, auth stripping and signing scheme. +- [x] Run the repository's complete CI gate list on the server branch and the CLI branch, plus production adapter builds. Record failures with evidence and distinguish environmental issues from regressions. +- [x] Obtain independent final reviews of both branch diffs and their combined behavior, resolving actionable findings. +- Final handoff: commit tested changes using repository conventions and push each PR branch without force. Refresh PR descriptions to explain the final contract and dependency. +- Final handoff: recheck remote heads and CI, and report actual validation results and any remaining limitations. + +## Reviewed corrections + +- Independent configuration review found a collision with the DataDome bypass header; runtime and deployment validation now reject it, including mixed-case names and unresolved key references. +- Origin review caught paired ingress facts being mixed with mutable Host; the resolver now keeps immutable authority and scheme together. +- CLI wire review found authentication leaking through streaming trailers; both declarations and actual frames are filtered while benign trailers remain intact. +- Full core verification found a cache test fixture relying on unauthenticated forwarded HTTPS; the fixture now supplies explicit runtime ingress. +- Cross-surface review found Spin losing HTTPS when its original URI is path-only. A separate validated runtime-origin extension supplies public URL fallback and survives snapshots without granting trace or fidelity trust. +- Operator trace guidance now documents the opt-in offload arrangement, and an actual Fastly router regression keeps configured identify CORS independent of the forwarded public authority. + +## Final local verification + +Both branch trees passed all required CI gates, target-matched adapter builds, Fastly/Spin release builds and core documentation. Native core: 3,057 passed; cross-adapter parity: 27 passed; JS: 1,757 passed. Final stacked CLI: 731 unit tests, 41 proxy wire tests and three real-server regressions passed, plus its remaining integration and documentation suites. CLI lint and formatting passed again after explicit TLS provider setup was added to test fixtures. A serial proxy unit run passed 111 tests, and an isolated TLS wire regression passed in a fresh process. + +The real operator `ts config validate` command accepted the optional forwarder section with a secret-store key reference. A shared-target artifact interruption in the server CLI documentation run cleared on a sequential full-suite rerun. Independent reviews approved every implementation and corrective pass, including the runtime-origin fallback, streaming trailer removal, identify CORS and joint listener fixture. + +GitHub CI and the existing deployed/mobile release acceptance are tracked in the PR descriptions; local wire tests do not replace physical mobile, browser session restoration or operator staging acceptance. diff --git a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md index 13785c98b..448484808 100644 --- a/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md +++ b/docs/superpowers/specs/2026-06-22-ts-dev-proxy-design.md @@ -93,17 +93,17 @@ satisfies **HSTS**, which an "ignored" cert does not. Resolved during brainstorming and design review (2026-06-22): -| Decision | Choice | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | -| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | -| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | -| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | -| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | -| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | -| Crate wiring | **Excluded** from the workspace (like `integration-tests`), _not_ a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | -| Host / first-party | First-party host is anchored to `FROM` via an always-sent `X-Forwarded-Host: FROM` (TS prefers it over `Host` for URL rewriting). `Host = FROM` by default; `--rewrite-host` sends `Host = TO` for host-validating upstreams without moving first-party URLs off `FROM`. SNI is always the `TO` host; reach a server by IP with `--resolve` (§8.3). | -| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | +| Decision | Choice | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Browser scope | **All three** (Chrome, Firefox, Safari) in v1 via a CA. | +| CA provenance | **Generated per-machine on first run**, stored in the user data dir (`--ca-dir`), key `0600`; **never committed**. Trust once per machine. | +| Browser launch | `--launch` takes a **list** and has **no default** — if unset, just run the proxy (no browser). Each listed browser is launched and configured against the proxy. | +| Safari proxy | Best-effort system PAC via `networksetup`, **restored on exit**; falls back to printed instructions. | +| Transport | HTTP/1.1 both legs in v1 (h2 deferred). | +| Bind | Loopback only by default; non-loopback requires `--allow-non-loopback` and disables blind tunnel/forward, so it can't become an open proxy (§11). | +| Crate wiring | **Excluded** from the workspace (like `integration-tests`), _not_ a non-default member — the repo pins the build target to `wasm32-wasip1` and this binary is native (§6). | +| Host / first-party | Authenticated forwarding anchors the public origin to the validated browser authority, including ports. `Host = FROM` by default; `--rewrite-host` sends `Host = TO`. Preserving the public origin with rewritten Host requires the matching server opt-in. SNI is always the `TO` host; reach a server by IP with `--resolve` (§8.3). | +| Unmatched hosts | **Blind-tunnel**, decided from the CONNECT authority before terminating TLS; only matched hosts are MITM'd (§5, §11). | --- @@ -115,21 +115,22 @@ ts dev proxy [OPTIONS] ### 4.1 Options -| Flag | Value | Default | Description | -| ---------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | -| `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Pairs with `--to`. | -| `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Pairs with `--from`. Keep it a hostname so the SNI/cert stay valid; pin a connection address with `--resolve`. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | -| `--listen` | `ADDR` | `127.0.0.1:18080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | -| `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | -| `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | -| `--rewrite-host` | flag | false (Host = `FROM`) | Send `Host = TO` instead of the default `Host = FROM`. The TLS SNI is always the `TO` host (see §8.3). | -| `--resolve` | `HOST:IP` (repeatable) | — | Pin `HOST`'s upstream connection to `IP` (curl-style), so `TO` stays a hostname (valid SNI/cert) while the socket dials a chosen server. Keeps the tool self-contained — no `/etc/hosts` (see §8.3). | -| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | -| `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | -| `--insecure` | flag | false | Skip **upstream** certificate verification. | -| `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | -| `--ca-dir` | `PATH` | `$XDG_DATA_HOME/trusted-server/dev-proxy` (macOS: `~/Library/Application Support/trusted-server/dev-proxy`) | Where the per-machine CA cert/key are stored (generated on first run). | +| Flag | Value | Default | Description | +| ------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--map` | `FROM=TO` (repeatable) | — | Rewrite rule: requests to `FROM` are served from `TO`. | +| `-f, --from` | `HOST` | — | Shorthand for a single rule's `FROM`. Pairs with `--to`. | +| `-t, --to` | `HOST[:PORT]` | — | Shorthand for a single rule's `TO`. Pairs with `--from`. Keep it a hostname so the SNI/cert stay valid; pin a connection address with `--resolve`. A non-default port is kept in the upstream `Host` but never in the SNI (§8.3). | +| `--listen` | `ADDR` | `127.0.0.1:18080` | Proxy listen address. A non-loopback address is **rejected** unless `--allow-non-loopback` is also set. | +| `--allow-non-loopback` | flag | false | Permit binding a non-loopback `--listen`. Even then, blind tunnel/forward of **unmatched** hosts is disabled (only configured rules are served), so the proxy can't act as a generic open proxy (§11). | +| `--launch` | `chrome,firefox,safari` \| `all` | _unset_ | Comma list of browsers to launch + configure (`all` = `chrome,firefox,safari`); **if omitted, just run the proxy** (no browser). | +| `--rewrite-host` | flag | false (Host = `FROM`) | Send `Host = TO` instead of the default `Host = FROM`. The TLS SNI is always the `TO` host (see §8.3). | +| `--resolve` | `HOST:IP` (repeatable) | — | Pin `HOST`'s upstream connection to `IP` (curl-style), so `TO` stays a hostname (valid SNI/cert) while the socket dials a chosen server. Keeps the tool self-contained — no `/etc/hosts` (see §8.3). | +| `--basic-auth` | `USER:PASS` | — | Inject `Authorization: Basic …` toward gated upstreams. **Convenience only** — visible via `ps`/shell history; prefer `--basic-auth-file`. | +| `--basic-auth-file` | `PATH` | — | Read `USER:PASS` from a file (preferred over `--basic-auth`). | +| `--forwarder-secret-file` | `PATH` | — | Read a single-line token of at least 32 ASCII graphic bytes for `x-ts-forwarder-auth`. Requires loopback and matching server trusted-forwarder configuration. | +| `--insecure` | flag | false | Skip **upstream** certificate verification. | +| `--upstream-plaintext` | flag | false | Connect to upstream over HTTP (e.g. `localhost:3000`). | +| `--ca-dir` | `PATH` | `$XDG_DATA_HOME/trusted-server/dev-proxy` (macOS: `~/Library/Application Support/trusted-server/dev-proxy`) | Where the per-machine CA cert/key are stored (generated on first run). | ### 4.2 Companion subcommands @@ -196,7 +197,8 @@ sequenceDiagram 2. On the MITM path, read decrypted HTTP/1.1 requests **in a loop** — one keep-alive tunnel carries many sequential requests. 3. For each request: rewrite upstream target + SNI to `TO`, set `Host` (§8.3), - strip any inbound `Forwarded`, add `X-Forwarded-Host: ` (and an + strip any inbound `Forwarded`, add the validated browser authority as + `X-Forwarded-Host` (and an informational `X-Orig-Host: `), inject auth if configured. An `Upgrade:` (WebSocket) request is out of scope in v1 (§16): log a clear note and close rather than corrupting the stream. @@ -360,36 +362,43 @@ are instead refused with `403` (§11), never blind-tunneled. ### 8.3 Header rewriting on match -| Header | Action | Rationale | -| ----------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| upstream **connection** | the `--resolve` pin for `rule.to` host if present, else `rule.to` host via DNS; + `rule.to` port | lets `TO` stay a hostname (valid SNI/cert) while the socket targets a chosen IP | -| **SNI** | `rule.to` host; **port stripped** | always the `TO` hostname; a `:port` or bare IP in SNI is invalid/unroutable, so keep `TO` a hostname | -| `Host` | `rule.from` (default); `rule.to` with `--rewrite-host` | the upstream's routing/validation host | -| `X-Forwarded-Host` | `rule.from` (always) | the original first-party host; TS core prefers it over `Host` for `request_host`, so first-party URLs stay on `FROM` even with `--rewrite-host` | -| `X-Orig-Host` | `rule.from` | informational duplicate of the original first-party host | -| `Authorization` | set if `--basic-auth` and not already present | clear `401` gates on staging upstreams | -| `Proxy-Connection` | removed | hop-by-hop hygiene | - -**First-party host is anchored to `FROM` via `X-Forwarded-Host`.** The §1 goal — -validate cookies, `Host`-sensitive logic, CMP/consent, and first-party context at -the _real_ domain — requires TS to treat `FROM` as the first-party host. Trusted -Server core derives `request_host` from `Forwarded` → `X-Forwarded-Host` → `Host` -(`extract_request_host` in `http_util.rs`) and anchors all HTML/RSC URL rewriting -to it (`request_url = "{scheme}://{request_host}"` and -`rewrite_bare_host_at_boundaries` in `publisher.rs` / `rsc_flight.rs`). The proxy -therefore **always sends `X-Forwarded-Host: FROM`** — standard forward-proxy -behavior — so `request_host = FROM` regardless of the routing `Host`. (Note: -`sanitize_forwarded_headers` would strip this at a hardened edge, but it is not -wired into the current request flow, so TS honors the inbound value.) - -This decouples routing from the first-party host: `Host` is free to be whatever -the upstream needs. The default `Host = FROM` works against a TS **Compute** -upstream (Fastly routes by SNI `= TO` and passes `Host` through). A Fastly -**Deliver** / host-validating upstream may reject an unconfigured `Host` -("unknown domain"); and because a domain can be active on only one service -(§2 ¶3), you cannot add the live production domain to a separate dev service. For -those, pass `--rewrite-host` (sends `Host = TO`) — first-party URLs still stay on -`FROM` because `X-Forwarded-Host` anchors them. +| Header | Action | Rationale | +| -------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------- | +| upstream **connection** | `--resolve` pin for `rule.to`, otherwise DNS; `rule.to` port | Keep the TLS identity separate from the connection address | +| **SNI** | `rule.to` host without port | Upstream certificate and routing identity | +| `Host` | `rule.from` by default; `rule.to` with `--rewrite-host` | Upstream routing authority | +| `X-Forwarded-Host` | Validated inbound browser authority, including port | Browser-facing public host | +| `X-Forwarded-Proto` | `https` | Browser leg always uses TLS, including plaintext upstreams | +| `X-Orig-Host` | `rule.from` | Informational mapping host | +| `x-ts-forwarder-auth` | Remove inbound headers and trailers; stamp file token when configured | Authenticate public-origin metadata | +| `Origin` | Preserve every field value unchanged on every route | Browser security and vendor consent contracts | +| `Authorization` | Inject Basic auth only if absent | Clear upstream gates | +| `Forwarded`, `Fastly-SSL`, hop-by-hop fields | Remove before stamping authoritative fields | Prevent spoofed or connection-specific metadata | + +Credential stamping requires one valid inbound Host, or one unambiguous absolute +URI authority. When both are present they must agree. Preserve the browser port; +reject duplicate, malformed, missing, or conflicting authorities with `400` when +the token is configured. Never authenticate a guessed CONNECT fallback. Route +each decrypted request against its own matched FROM host, which may differ from +the CONNECT hostname. + +Trusted Server must opt into authenticated forwarding, use the header +`x-ts-forwarder-auth`, and resolve the matching secret from its secret store: + +```toml +[trusted_forwarder] +auth_header = "x-ts-forwarder-auth" +shared_secret = "trusted_forwarder_shared_secret" +``` + +The key `trusted_forwarder_shared_secret` holds the actual local-file token. +Only bounded publisher hosts are accepted. The server captures validated public +origin before trace dispatch and forwarded-header sanitation, and removes the +credential before publisher/vendor forwarding. This allows browser Origin to +remain unchanged while trace checks and generated public URLs use the same +public origin. Actual upstream transport and ingress fidelity remain independent. +Without this server dependency, forwarded headers do not establish a trusted +public origin and rewritten Host may cause trace Origin validation to fail. **Targeting a specific server by IP.** Keep `TO` a hostname (so the SNI and certificate stay valid) and pin its connection address with `--resolve HOST:IP` @@ -399,10 +408,9 @@ so a host-routed endpoint would serve its default vhost). Cert verification stil applies; add `--insecure` if the endpoint serves a cert that doesn't match. This keeps the tool self-contained — no `/etc/hosts` edit. -`X-Orig-Host: FROM` is also sent as an informational duplicate (TS core does not -read it today). The functional header is `X-Forwarded-Host`. **Validation:** an -integration test asserts that with `--rewrite-host` the upstream sees `Host = TO` -while `X-Forwarded-Host = FROM` (`rewrite_host_keeps_forwarded_host_on_from`). +`X-Orig-Host: FROM` is informational. Forwarding contract tests cover browser +ports, both Host modes, TLS/plaintext upstreams, authentication overwrite and +absence, near-miss routes, vendor Origin preservation, and connection reuse. **Port handling.** With `--rewrite-host` (`Host = TO`) and a non-default `TO` port (e.g. `localhost:3000`, `staging.example.com:8443`), the port **is** included @@ -521,8 +529,8 @@ Rewrite rules are **never** inferred from `trusted-server.toml` (or any other file) — the upstream must always be passed on the command line via `--map` or `-f`/`-t`. This keeps what the proxy does fully explicit and visible in the invocation, with no hidden dependence on the working directory or on a config -key. The only file input the proxy reads is `--basic-auth-file` (and the -per-machine CA under `--ca-dir`). +key. Credential files are read through `--basic-auth-file` and +`--forwarder-secret-file`, alongside the per-machine CA under `--ca-dir`. The tool is **flags-only** — there are no `TS_DEV_PROXY_*` environment-variable overrides either. Every setting is a CLI flag (§4). @@ -555,7 +563,11 @@ overrides either. Every setting is a CLI flag (§4). path, and chosen upstream only. - **Credential input.** `--basic-auth USER:PASS` is **convenience only** — argv is visible via `ps` and shell history. Prefer `--basic-auth-file`; the file is - read once at startup and never logged. + read once at startup and never logged. Forwarding authentication is file-only: + `--forwarder-secret-file` accepts at least 32 ASCII graphic bytes and an optional + single LF/CRLF terminator; whitespace, multiline, short and non-ASCII values + fail without revealing the token. The wrapper is debug-redacted and its header + value is sensitive. Injected credentials require a loopback listener. - **Only matched hosts are decrypted.** Launched browsers proxy **HTTPS only** (§9) and unmatched CONNECT authorities are blind-tunneled (§5), so unrelated browsing is never MITM'd. @@ -615,8 +627,8 @@ request. **Unit (`rewrite.rs`):** host matching (case-insensitivity, port stripping, first-match-wins, no-match pass-through); header outcomes (default `Host=FROM` + -`X-Forwarded-Host=FROM`; `--rewrite-host` sends `Host=TO` while `X-Forwarded-Host` -stays `FROM`; inbound `Forwarded` stripped; non-default `TO` port in `Host` but +`X-Forwarded-Host` preserving the validated browser authority and port; `--rewrite-host` sends `Host=TO` while `X-Forwarded-Host` +stays the browser authority; inbound authentication and `Forwarded` stripped; non-default `TO` port in `Host` but not SNI; auth injected only when absent); URI normalization. **Unit (`ca.rs`):** CA is generated on first run and reloaded from `--ca-dir` on From b01b2e2e64f041a58a1da0e2f4fb75bcc7f7c528 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 9 Oct 2026 14:48:48 +0530 Subject: [PATCH 6/7] Clarify random proxy token provisioning and security review --- docs/guide/ts-dev-proxy.md | 4 ++-- docs/superpowers/plans/2026-10-09-authenticated-forwarder.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index 115d90d26..02377aabb 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -356,7 +356,7 @@ actual token bytes from the local file, excluding its line terminator. The publi cover every mapped browser hostname. Omitting the section keeps the server's default transport-origin behavior. -Generate a local token file, provision its token in the server secret store, +Generate a cryptographically random local token file, provision its token in the server secret store, and pass only the file path to the proxy: ```bash @@ -364,7 +364,7 @@ umask 077 openssl rand -hex 32 > ./forwarder-token.txt ts dev proxy \ - --map www.example-publisher.com=trusted-server-example.edgecompute.app \ + --map www.publisher.example.com=trusted-server.example.com \ --rewrite-host \ --forwarder-secret-file ./forwarder-token.txt \ --launch chrome diff --git a/docs/superpowers/plans/2026-10-09-authenticated-forwarder.md b/docs/superpowers/plans/2026-10-09-authenticated-forwarder.md index 04601c730..33014b215 100644 --- a/docs/superpowers/plans/2026-10-09-authenticated-forwarder.md +++ b/docs/superpowers/plans/2026-10-09-authenticated-forwarder.md @@ -80,3 +80,5 @@ Both branch trees passed all required CI gates, target-matched adapter builds, F The real operator `ts config validate` command accepted the optional forwarder section with a secret-store key reference. A shared-target artifact interruption in the server CLI documentation run cleared on a sequential full-suite rerun. Independent reviews approved every implementation and corrective pass, including the runtime-origin fallback, streaming trailer removal, identify CORS and joint listener fixture. GitHub CI and the existing deployed/mobile release acceptance are tracked in the PR descriptions; local wire tests do not replace physical mobile, browser session restoration or operator staging acceptance. + +The new CodeQL password-hashing alert #204 was independently reviewed and classified as a false positive: SHA-256 normalizes temporary bearer-token digests for comparison; no verifier digest is stored or exposed. The operator guide explicitly requires random-token generation and distinguishes length from entropy. From ba227cba5ac27730d5dcc8b015cf3456bf35eda3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 9 Oct 2026 15:34:37 +0530 Subject: [PATCH 7/7] Initialize proxy TLS before startup --- .../src/commands/dev/proxy/config.rs | 2 +- .../src/commands/dev/proxy/mod.rs | 2 + crates/trusted-server-cli/tests/proxy_cli.rs | 76 ++++++++++++++++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 0e80f2d66..d0a74fa4b 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -467,7 +467,7 @@ mod tests { let path = dir.path().join("token"); for raw in [ "", - "short", + "example-short-token-0123456789", "1234567890123456789012345678901", "example-forwarder-token-0123456789 ", " example-forwarder-token-0123456789", diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index aa1caa7bf..3ddafae88 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -255,6 +255,8 @@ pub fn run(args: &ProxyArgs) -> core::result::Result<(), error_stack::Report