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..d0a74fa4b 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 [ + "", + "example-short-token-0123456789", + "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..3ddafae88 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, @@ -251,6 +255,8 @@ pub fn run(args: &ProxyArgs) -> core::result::Result<(), error_stack::Report 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,7 +657,7 @@ async fn proxy_to_upstream( ); let metadata = super::upstream::RequestMetadata::capture(&req); - 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?; @@ -674,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), @@ -749,6 +839,13 @@ fn rewrite_headers( trailer_metadata.regenerate(headers); } +/// 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 {}) @@ -794,6 +891,23 @@ 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) + .iter() + .map(|value| value.to_str().expect("should encode origin")) + .collect() + } + fn head(method: &str, target: &str) -> RequestHead { RequestHead { method: method.to_string(), @@ -804,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!( @@ -866,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" @@ -916,6 +1130,7 @@ mod tests { request.headers_mut(), &rewrite_outcome("to.edgecompute.app"), None, + ForwardingHeaders::default(), ); assert!( @@ -955,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) @@ -986,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" @@ -1012,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"), @@ -1043,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_cli.rs b/crates/trusted-server-cli/tests/proxy_cli.rs index 38866a9a5..ba6b432db 100644 --- a/crates/trusted-server-cli/tests/proxy_cli.rs +++ b/crates/trusted-server-cli/tests/proxy_cli.rs @@ -1,7 +1,13 @@ //! Public command availability regression. -#![cfg(target_os = "linux")] +#![cfg(any(target_os = "macos", target_os = "linux"))] + +use std::io::{Read as _, Write as _}; +use std::net::{TcpListener, TcpStream}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; #[test] +#[cfg(target_os = "linux")] fn linux_dev_proxy_help_is_available() { let output = std::process::Command::new(env!("CARGO_BIN_EXE_ts")) .args(["dev", "proxy", "--help"]) @@ -14,3 +20,71 @@ fn linux_dev_proxy_help_is_available() { ); assert!(String::from_utf8_lossy(&output.stdout).contains("--map")); } + +#[test] +fn dev_proxy_binary_starts_with_unified_tls_features() { + // Run a fresh process: a provider installed by another test must not hide + // missing initialization in the operator command. + let directory = tempfile::tempdir().expect("should create an isolated CA directory"); + let reserved = TcpListener::bind("127.0.0.1:0").expect("should reserve a proxy address"); + let address = reserved + .local_addr() + .expect("should read the proxy address"); + drop(reserved); + let mut child = Command::new(env!("CARGO_BIN_EXE_ts")) + .args([ + "dev", + "proxy", + "--map", + "www.example.com=127.0.0.1:9", + "--listen", + &address.to_string(), + "--ca-dir", + ]) + .arg(directory.path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("should run the standalone proxy command"); + let deadline = Instant::now() + Duration::from_secs(10); + let mut listening = false; + while Instant::now() < deadline { + if child + .try_wait() + .expect("should inspect proxy startup") + .is_some() + { + break; + } + if let Ok(mut stream) = TcpStream::connect_timeout(&address, Duration::from_millis(100)) { + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .expect("should bound the readiness response"); + if stream + .write_all( + b"GET /proxy.pac HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ) + .is_ok() + { + let mut response = String::new(); + if stream.read_to_string(&mut response).is_ok() + && response.starts_with("HTTP/1.1 200 ") + && response.contains("FindProxyForURL") + { + listening = true; + break; + } + } + } + std::thread::sleep(Duration::from_millis(20)); + } + let _ = child.kill(); + let output = child + .wait_with_output() + .expect("should reap the proxy process"); + assert!( + listening, + "should initialize TLS and listen with unified dependencies: {}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/trusted-server-cli/tests/proxy_e2e.rs b/crates/trusted-server-cli/tests/proxy_e2e.rs index 8f36d8c46..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, @@ -98,6 +98,96 @@ async fn rewrite_host_keeps_forwarded_host_on_from() { ); } +#[tokio::test] +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()); + + 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://{}", support::FROM_HOST), + "should preserve browser Origin on trace actions" + ); +} + +#[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; + 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; @@ -683,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 6c464b015..68b25f7fb 100644 --- a/crates/trusted-server-cli/tests/support/mod.rs +++ b/crates/trusted-server-cli/tests/support/mod.rs @@ -28,6 +28,9 @@ pub struct ProxiedResponse { pub seen_host: String, 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, } @@ -237,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() @@ -292,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 @@ -390,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"); @@ -446,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 @@ -658,6 +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 = 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 { @@ -667,7 +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};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() { @@ -1003,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)) @@ -1441,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( @@ -1515,6 +1636,58 @@ 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 +} + +/// 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 @@ -1552,34 +1725,52 @@ 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, + seen_forwarded_proto: echo.proto, + seen_forwarder_auth: echo.auth, + 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, + proto: String, + auth: 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("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=") { - 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 ebd449bb9..02377aabb 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,31 +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**. - -> **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 cryptographically random 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.publisher.example.com=trusted-server.example.com \ + --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 @@ -360,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..33014b215 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-authenticated-forwarder.md @@ -0,0 +1,84 @@ +# 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. + +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. 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