Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<meta>` 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.
Expand Down
118 changes: 110 additions & 8 deletions crates/trusted-server-core/src/http_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pub fn copy_custom_headers(from: &Request<EdgeBody>, to: &mut Request<EdgeBody>)
/// 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",
Expand Down Expand Up @@ -123,23 +123,25 @@ pub fn is_navigation_request(req: &Request<EdgeBody>) -> 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
Expand All @@ -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<EdgeBody>, client_info: &ClientInfo) -> Self {
let host = extract_request_host(req);
let scheme = detect_request_scheme(
Expand Down Expand Up @@ -178,6 +181,17 @@ fn extract_request_host(req: &Request<EdgeBody>) -> String {
req.headers()
.get(header::HOST)
.and_then(|h| h.to_str().ok())
Comment thread
prk-Jr marked this conversation as resolved.
.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()
Expand Down Expand Up @@ -572,6 +586,94 @@ 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",
),
Comment on lines +609 to +612

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏ nitpick: Pin rsplit_once with a two-@ authority.

The http crate accepts an authority like a@b@publisher.example.com, and Authority::host() returns publisher.example.com for it. If line 192 used split_once('@') instead of rsplit_once('@'), every current test would still pass, but the result would be b@publisher.example.com. This row catches that.

Suggested change
(
"https://user:pass@[2001:db8::1]:8443/page",
"[2001:db8::1]:8443",
),
(
"https://user:pass@[2001:db8::1]:8443/page",
"[2001:db8::1]:8443",
),
(
"https://a@b@publisher.example.com/page",
"publisher.example.com",
),

Verified on this head:

  • The suggestion passes rustfmt, all six clippy aliases, test-fastly, test-axum, test-cloudflare, test-spin and parity.
  • With split_once swapped in, the new row fails (left: "b@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");
Expand Down
84 changes: 82 additions & 2 deletions crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4449,17 +4449,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"),
);

Expand Down Expand Up @@ -19732,6 +19737,81 @@ 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"<html><head></head><body><a href=\"https://origin.example.com/article\">Article</a></body></html>".to_vec(),
vec![("content-type", "text/html; charset=utf-8")],
);
let services = build_services_with_http_client(
Arc::clone(&stub) as Arc<dyn crate::platform::PlatformHttpClient>
);
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,
&registry,
&AuctionOrchestrator::new(settings.auction.clone()),
&services,
)
.await
.expect("should buffer processed publisher response");
let html = response_body_string(response);

assert!(
html.contains("href=\"https://publisher.example.com:8443/article\""),
"should rewrite origin links using URI authority: {html}"
);
assert!(
!html.contains("user:pass@"),
"should omit URI userinfo from rewritten HTML: {html}"
);
assert!(
html.contains("id=\"trustedserver-js\""),
"should inject the Trusted Server script: {html}"
);
Comment on lines +19797 to +19810

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏ nitpick: Name the failing case in the loop's assertions.

The loop covers six cases: two HTTP versions, each with a plain authority, userinfo and an empty Host. A failure prints only the HTML, so you can't tell which case broke. With this change a failure reads, for example: should rewrite origin links using URI authority for HTTP/1.1 https://publisher.example.com:8443/page (empty Host: true): ...

Suggested change
let html = response_body_string(response);
assert!(
html.contains("href=\"https://publisher.example.com:8443/article\""),
"should rewrite origin links using URI authority: {html}"
);
assert!(
!html.contains("user:pass@"),
"should omit URI userinfo from rewritten HTML: {html}"
);
assert!(
html.contains("id=\"trustedserver-js\""),
"should inject the Trusted Server script: {html}"
);
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}"
);

Verified on this head:

  • The suggestion passes rustfmt, all six clippy aliases, test-fastly, test-axum, test-cloudflare, test-spin and parity.
  • With the empty-Host filter removed, the failure message names the case, as shown above.

}
}
}

#[tokio::test]
async fn publisher_request_sends_configured_host_header_override() {
let mut settings = create_test_settings();
Expand Down
Loading