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();