diff --git a/CHANGELOG.md b/CHANGELOG.md index f691c6a00..0141aa811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Publisher HTML is now processed when the request carries its host only in the URI (absolute-form HTTP/1.1, or HTTP/2 without a usable `Host`), instead of being returned unmodified with an "Empty request host" warning (#1118). URI userinfo is excluded from rewritten URLs, while ports and IPv6 brackets are preserved. - Publisher responses now map origin URLs in `Location`, `Content-Location`, `Refresh`, and `Link` headers to the serving host, and each CSP source naming the origin gains a serving-host source beside it (#1128). A redirect to the current URL under a different scheme, or a `GET`/`HEAD` redirect to the current URL that sets no cookie (or any such redirect under a `Host` override), is left unchanged to avoid a loop. `` refresh and CSP tags in the page body are not rewritten. See "Origin URLs in proxied response headers" in the configuration guide. - Image Optimizer configuration now rejects profile and profile-set names within the same map that collide after trimming, even when their values match, instead of silently overwriting an entry. Empty names continue to be discarded. - Browser integration bundles now share context providers and the configured log level with TSJS core (#1196). With Permutive enabled and cohorts available, `tsjs.requestAds()` now sends `config.permutive_segments` to `/auction`; the server retains that key only when `auction.allowed_context_keys` includes `"permutive_segments"`. This restores a previously inactive data flow, not a consent gate: review publisher consent handling before relying on it. Integration logs now follow `tsjs.setConfig` and `tsjs.log.setLevel`, and creative no longer raises the default `warn` level to `info`. The `?tsdebug=1` query parameter or `localStorage['tsdebug'] = '1'` intentionally enables `debug` logging across all bundles when creative installs, overriding any log level set earlier through `tsjs.setConfig` or `tsjs.log.setLevel`. Later calls to either API can change the level again. diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index e12eefdbb..17c9463d3 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -40,7 +40,7 @@ pub fn copy_custom_headers(from: &Request, to: &mut Request) /// Fastly adapter may first consume an authenticated `fastly-client-ip`, but /// removes it before routing along with every other listed header. Stripping /// them forces [`RequestInfo::from_request`] to fall back to the trustworthy -/// `Host` header and [`ClientInfo`] TLS detection. +/// `Host` header or URI authority and [`ClientInfo`] TLS detection. pub const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ "forwarded", "x-forwarded-host", @@ -123,23 +123,25 @@ pub fn is_navigation_request(req: &Request) -> bool { /// The parser checks forwarded headers (`Forwarded`, `X-Forwarded-Host`, /// `X-Forwarded-Proto`) as fallbacks, but on the Fastly edge /// [`sanitize_forwarded_headers`] strips those headers before this method is -/// called, so the `Host` header and Fastly SDK TLS detection are the effective -/// sources in production. +/// called. The `Host` header and Fastly SDK TLS detection are the normal sources +/// in production. URI authority fills in only when no usable `Host` arrives +/// (absolute-form HTTP/1.1, or HTTP/2 without `Host`). #[derive(Debug, Clone)] pub struct RequestInfo { - /// The effective host for URL rewriting (typically the `Host` header after edge sanitization). + /// The effective host for URL rewriting (`Host` or URI authority after edge sanitization). pub host: String, /// The effective scheme (typically from Fastly SDK TLS detection after edge sanitization). pub scheme: String, } impl RequestInfo { - /// Extract request info from a Fastly request. + /// Extract host and scheme information from a request. /// - /// Host fallback order (first present wins): + /// Host fallback order (first nonempty, valid string wins): /// 1. `Forwarded` header (`host=...`) /// 2. `X-Forwarded-Host` /// 3. `Host` header + /// 4. Request URI authority (host and port only, excluding userinfo) /// /// Scheme fallback order: /// 1. Fastly SDK TLS detection @@ -149,8 +151,9 @@ impl RequestInfo { /// 5. Default `http` /// /// In production the forwarded headers are stripped by - /// [`sanitize_forwarded_headers`] at the edge, so `Host` and - /// [`ClientInfo`] TLS detection are the only sources that fire. + /// [`sanitize_forwarded_headers`] at the edge, so `Host` and [`ClientInfo`] + /// TLS detection are the normal sources. URI authority is a fallback for + /// absolute-form HTTP/1.1 or HTTP/2 requests without a usable `Host`. pub fn from_request(req: &Request, client_info: &ClientInfo) -> Self { let host = extract_request_host(req); let scheme = detect_request_scheme( @@ -178,6 +181,17 @@ fn extract_request_host(req: &Request) -> String { req.headers() .get(header::HOST) .and_then(|h| h.to_str().ok()) + .filter(|host| !host.is_empty()) + }) + .or_else(|| { + // Absolute-form HTTP/1.1 and HTTP/2 may carry the host only in the URI. + // Drop userinfo so credentials never reach rewritten URLs. + req.uri().authority().map(|authority| { + let authority = authority.as_str(); + authority + .rsplit_once('@') + .map_or(authority, |(_, host_and_port)| host_and_port) + }) }) .unwrap_or_default() .to_owned() @@ -572,6 +586,98 @@ mod tests { // RequestInfo tests + #[test] + fn request_info_uses_uri_authority_without_host_headers() { + for (uri, expected_host) in [ + ( + "https://publisher.example.com/page", + "publisher.example.com", + ), + ( + "https://publisher.example.com:8443/page", + "publisher.example.com:8443", + ), + ("https://[2001:db8::1]:8443/page", "[2001:db8::1]:8443"), + ( + "https://user:secret@publisher.example.com/page", + "publisher.example.com", + ), + ( + "https://user:pass@publisher.example.com:8443/page", + "publisher.example.com:8443", + ), + ( + "https://user:pass@[2001:db8::1]:8443/page", + "[2001:db8::1]:8443", + ), + ( + "https://a@b@publisher.example.com/page", + "publisher.example.com", + ), + ] { + let req = Request::builder() + .version(http::Version::HTTP_2) + .uri(uri) + .body(EdgeBody::empty()) + .expect("should build authority-only HTTP/2 request"); + assert!(req.headers().is_empty(), "should have no host headers"); + + let info = RequestInfo::from_request(&req, &default_client_info()); + + assert_eq!( + info.host, expected_host, + "should use only the host and port, preserving IPv6 brackets" + ); + } + } + + #[test] + fn request_info_preserves_host_header_precedence_over_uri_authority() { + for (headers, expected_host) in [ + (vec![("host", "header.example.com")], "header.example.com"), + (vec![("host", "")], "authority.example.com"), + ( + vec![ + ("host", "header.example.com"), + ("x-forwarded-host", "proxy.example.com"), + ], + "proxy.example.com", + ), + ( + vec![ + ("host", "header.example.com"), + ("x-forwarded-host", "proxy.example.com"), + ("forwarded", "host=public.example.com"), + ], + "public.example.com", + ), + ] { + let mut req = build_request(Method::GET, "https://authority.example.com/page"); + for (name, value) in headers { + set_header(&mut req, name, value); + } + + let info = RequestInfo::from_request(&req, &default_client_info()); + + assert_eq!( + info.host, expected_host, + "should preserve forwarded and explicit host precedence over URI authority" + ); + } + } + + #[test] + fn request_info_keeps_empty_host_without_headers_or_uri_authority() { + let req = build_request(Method::GET, "/page"); + + let info = RequestInfo::from_request(&req, &default_client_info()); + + assert!( + info.host.is_empty(), + "should keep the host empty when no host source is available" + ); + } + #[test] fn test_request_info_from_host_header() { let mut req = build_request(Method::GET, "https://test.example.com/page"); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 3e3537a6f..84b74d51d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -4818,17 +4818,22 @@ pub async fn handle_publisher_request( // Prebid.js requests are not intercepted here anymore. The HTML processor removes // publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled. - // Extract request host and scheme (uses Host header and TLS detection after edge sanitization) + // Extract request host and scheme using Host or URI authority and TLS + // detection after edge sanitization. let request_info = RequestInfo::from_request(&req, services.client_info()); let request_host = &request_info.host; let request_scheme = &request_info.scheme; log::debug!( - "Request info: host={}, scheme={} (X-Forwarded-Host: {:?}, Host: {:?}, X-Forwarded-Proto: {:?})", + "Request info: host={}, scheme={} (X-Forwarded-Host: {:?}, Host: {:?}, URI host: {:?}, URI port: {:?}, X-Forwarded-Proto: {:?})", request_host, request_scheme, req.headers().get("x-forwarded-host"), req.headers().get(header::HOST), + req.uri().authority().map(http::uri::Authority::host), + req.uri() + .authority() + .and_then(http::uri::Authority::port_u16), req.headers().get("x-forwarded-proto"), ); @@ -20665,6 +20670,82 @@ mod tests { ); } + #[tokio::test] + async fn publisher_request_rewrites_html_using_uri_authority() { + for version in [http::Version::HTTP_11, http::Version::HTTP_2] { + for (uri, empty_host) in [ + ("https://publisher.example.com:8443/page", false), + ("https://user:pass@publisher.example.com:8443/page", false), + ("https://publisher.example.com:8443/page", true), + ] { + let mut settings = create_test_settings(); + settings.publisher.origin_url = "https://origin.example.com".to_owned(); + let registry = IntegrationRegistry::new(&settings) + .expect("should create integration registry"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"Article".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let mut req = HttpRequest::builder() + .version(version) + .uri(uri) + .header("x-forwarded-proto", "https") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build authority-only publisher request"); + if empty_host { + req.headers_mut() + .insert(header::HOST, HeaderValue::from_static("")); + } + for name in ["forwarded", "x-forwarded-host"] { + assert!( + !req.headers().contains_key(name), + "should have no {name} header" + ); + } + + assert!( + req.headers() + .get(header::HOST) + .is_none_or(HeaderValue::is_empty), + "should have no nonempty Host header" + ); + + let publisher_response = run_publisher_proxy(&settings, &services, req).await; + let response = buffer_publisher_response_async( + publisher_response, + &Method::GET, + &settings, + ®istry, + &AuctionOrchestrator::new(settings.auction.clone()), + &services, + ) + .await + .expect("should buffer processed publisher response"); + let html = response_body_string(response); + let case = format!("{version:?} {uri} (empty Host: {empty_host})"); + + assert!( + html.contains("href=\"https://publisher.example.com:8443/article\""), + "should rewrite origin links using URI authority for {case}: {html}" + ); + assert!( + !html.contains("user:pass@"), + "should omit URI userinfo from rewritten HTML for {case}: {html}" + ); + assert!( + html.contains("id=\"trustedserver-js\""), + "should inject the Trusted Server script for {case}: {html}" + ); + } + } + } + #[tokio::test] async fn publisher_request_sends_configured_host_header_override() { let mut settings = create_test_settings();