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
4 changes: 4 additions & 0 deletions .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=http://localhost:9090
# [synthetic]
TRUSTED_SERVER__SYNTHETIC__COUNTER_STORE=counter_store
TRUSTED_SERVER__SYNTHETIC__OPID_STORE=opid_store

# [proxy]
# Disable TLS certificate verification for local dev with self-signed certs
# TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false
166 changes: 138 additions & 28 deletions crates/common/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,50 +6,94 @@ use url::Url;

use crate::error::TrustedServerError;

/// Returns the default port for the given scheme (443 for HTTPS, 80 for HTTP).
#[inline]
fn default_port_for_scheme(scheme: &str) -> u16 {
if scheme.eq_ignore_ascii_case("https") {
443
} else {
80
}
}

/// Compute the Host header value for a backend request.
///
/// For standard ports (443 for HTTPS, 80 for HTTP), returns just the hostname.
/// For non-standard ports, returns "hostname:port" to ensure backends that
/// generate URLs based on the Host header include the port.
///
/// This fixes the issue where backends behind reverse proxies (like Caddy)
/// would generate URLs without the port when the Host header didn't include it.
#[inline]
fn compute_host_header(scheme: &str, host: &str, port: u16) -> String {
if port != default_port_for_scheme(scheme) {
format!("{}:{}", host, port)
} else {
host.to_string()
}
}

/// Ensure a dynamic backend exists for the given origin and return its name.
///
/// The backend name is derived from the scheme and `host[:port]` to avoid collisions across
/// http/https or different ports. If a backend with the derived name already exists,
/// this function logs and reuses it.
///
/// # Arguments
///
/// * `scheme` - The URL scheme ("http" or "https")
/// * `host` - The hostname
/// * `port` - Optional port number
/// * `certificate_check` - If true, enables TLS certificate verification (default for production)
///
/// # Errors
///
/// Returns an error if the host is empty or if backend creation fails (except for `NameInUse` which reuses the existing backend).
/// Returns an error if the host is empty or if backend creation fails
/// (except for `NameInUse` which reuses the existing backend).
pub fn ensure_origin_backend(
scheme: &str,
host: &str,
port: Option<u16>,
certificate_check: bool,
) -> Result<String, Report<TrustedServerError>> {
if host.is_empty() {
return Err(Report::new(TrustedServerError::Proxy {
message: "missing host".to_string(),
}));
}

let is_https = scheme.eq_ignore_ascii_case("https");
let target_port = match (port, is_https) {
(Some(p), _) => p,
(None, true) => 443,
(None, false) => 80,
};
let target_port = port.unwrap_or_else(|| default_port_for_scheme(scheme));

let host_with_port = format!("{}:{}", host, target_port);

// Name: iframe_<scheme>_<host>_<port> (sanitize '.' and ':')
// Include cert setting in name to avoid reusing a backend with different cert settings
let name_base = format!("{}_{}_{}", scheme, host, target_port);
let backend_name = format!("backend_{}", name_base.replace(['.', ':'], "_"));
let cert_suffix = if certificate_check { "" } else { "_nocert" };
let backend_name = format!(
"backend_{}{}",
name_base.replace(['.', ':'], "_"),
cert_suffix
);

let host_header = compute_host_header(scheme, host, target_port);

// Target base is host[:port]; SSL is enabled only for https scheme
let mut builder = Backend::builder(&backend_name, &host_with_port)
.override_host(host)
.override_host(&host_header)
.connect_timeout(Duration::from_secs(1))
.first_byte_timeout(Duration::from_secs(15))
.between_bytes_timeout(Duration::from_secs(10));
if scheme.eq_ignore_ascii_case("https") {
builder = builder
.enable_ssl()
.sni_hostname(host)
.check_certificate(host);
builder = builder.enable_ssl().sni_hostname(host);
if certificate_check {
builder = builder.check_certificate(host);
} else {
log::warn!(
"INSECURE: certificate check disabled for backend: {}",
backend_name
);
}
log::info!("enable ssl for backend: {}", backend_name);
}

Expand Down Expand Up @@ -81,12 +125,15 @@ pub fn ensure_origin_backend(

/// Ensures a dynamic backend exists for the given origin URL.
///
/// Parses the URL and delegates to `ensure_origin_backend` to create or reuse a backend.
/// Parses the URL and delegates to [`ensure_origin_backend`] to create or reuse a backend.
///
/// # Errors
///
/// Returns an error if the URL cannot be parsed or lacks a host, or if backend creation fails.
pub fn ensure_backend_from_url(origin_url: &str) -> Result<String, Report<TrustedServerError>> {
pub fn ensure_backend_from_url(
origin_url: &str,
certificate_check: bool,
) -> Result<String, Report<TrustedServerError>> {
let parsed_url = Url::parse(origin_url).change_context(TrustedServerError::Proxy {
message: format!("Invalid origin_url: {}", origin_url),
})?;
Expand All @@ -99,44 +146,107 @@ pub fn ensure_backend_from_url(origin_url: &str) -> Result<String, Report<Truste
})?;
let port = parsed_url.port();

ensure_origin_backend(scheme, host, port)
ensure_origin_backend(scheme, host, port, certificate_check)
}

#[cfg(test)]
mod tests {
use super::ensure_origin_backend;
use super::{compute_host_header, ensure_origin_backend};

// Tests for compute_host_header - the fix for port preservation in Host header
#[test]
fn returns_name_for_https_no_port() {
let name = ensure_origin_backend("https", "origin.example.com", None).unwrap();
fn host_header_includes_port_for_non_standard_https() {
assert_eq!(
compute_host_header("https", "cdn.example.com", 9443),
"cdn.example.com:9443",
"should include non-standard HTTPS port 9443 in Host header"
);
assert_eq!(
compute_host_header("https", "cdn.example.com", 8443),
"cdn.example.com:8443",
"should include non-standard HTTPS port 8443 in Host header"
);
}

#[test]
fn host_header_excludes_port_for_standard_https() {
assert_eq!(
compute_host_header("https", "cdn.example.com", 443),
"cdn.example.com",
"should omit standard HTTPS port 443 from Host header"
);
}

#[test]
fn host_header_includes_port_for_non_standard_http() {
assert_eq!(
compute_host_header("http", "cdn.example.com", 8080),
"cdn.example.com:8080",
"should include non-standard HTTP port 8080 in Host header"
);
}

#[test]
fn host_header_excludes_port_for_standard_http() {
assert_eq!(
compute_host_header("http", "cdn.example.com", 80),
"cdn.example.com",
"should omit standard HTTP port 80 from Host header"
);
}

#[test]
fn returns_name_for_https_with_cert_check() {
let name = ensure_origin_backend("https", "origin.example.com", None, true)
.expect("should create backend for valid HTTPS origin");
assert_eq!(name, "backend_https_origin_example_com_443");
}

#[test]
fn returns_name_for_https_without_cert_check() {
let name = ensure_origin_backend("https", "origin.example.com", None, false)
.expect("should create backend with cert check disabled");
assert_eq!(name, "backend_https_origin_example_com_443_nocert");
}

#[test]
fn returns_name_for_http_with_port_and_sanitizes() {
let name = ensure_origin_backend("http", "api.test-site.org", Some(8080)).unwrap();
let name = ensure_origin_backend("http", "api.test-site.org", Some(8080), true)
.expect("should create backend for HTTP origin with explicit port");
assert_eq!(name, "backend_http_api_test-site_org_8080");
// Explicitly check that ':' was replaced with '_'
assert!(name.ends_with("_8080"));
assert!(
name.ends_with("_8080"),
"should sanitize ':' to '_' in backend name"
);
}

#[test]
fn returns_name_for_http_without_port_defaults_to_80() {
let name = ensure_origin_backend("http", "example.org", None).unwrap();
let name = ensure_origin_backend("http", "example.org", None, true)
.expect("should create backend defaulting to port 80 for HTTP");
assert_eq!(name, "backend_http_example_org_80");
}

#[test]
fn error_on_missing_host() {
let err = ensure_origin_backend("https", "", None).err().unwrap();
let err =
ensure_origin_backend("https", "", None, true).expect_err("should reject empty host");
let msg = err.to_string();
assert!(msg.contains("missing host"));
assert!(
msg.contains("missing host"),
"should report missing host in error message"
);
}

#[test]
fn second_call_reuses_existing_backend() {
let first = ensure_origin_backend("https", "reuse.example.com", None).unwrap();
let second = ensure_origin_backend("https", "reuse.example.com", None).unwrap();
assert_eq!(first, second);
let first = ensure_origin_backend("https", "reuse.example.com", None, true)
.expect("should create backend on first call");
let second = ensure_origin_backend("https", "reuse.example.com", None, true)
.expect("should reuse backend on second call");
assert_eq!(
first, second,
"should return same backend name on repeat call"
);
}
}
41 changes: 40 additions & 1 deletion crates/common/src/creative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,10 @@ fn build_signed_url_for(
pairs.extend(extra.iter().cloned());
}

// Build tsurl from parsed URL without query/fragment (preserves port)
u.set_query(None);
u.set_fragment(None);
let tsurl = u.as_str().to_string();
let tsurl = u.to_string();

let full_for_token = if pairs.is_empty() {
tsurl.clone()
Expand Down Expand Up @@ -639,6 +640,44 @@ mod tests {
assert_eq!(to_abs(&settings, "mailto:test@example.com"), None);
}

#[test]
fn to_abs_preserves_port_in_protocol_relative() {
let settings = crate::test_support::tests::create_test_settings();
assert_eq!(
to_abs(&settings, "//cdn.example.com:8080/asset.js"),
Some("https://cdn.example.com:8080/asset.js".to_string()),
"should preserve port 8080 in protocol-relative URL"
);
assert_eq!(
to_abs(&settings, "//cdn.example.com:9443/img.png"),
Some("https://cdn.example.com:9443/img.png".to_string()),
"should preserve port 9443 in protocol-relative URL"
);
}

#[test]
fn rewrite_creative_preserves_non_standard_port() {
// Verify creative rewriting preserves non-standard ports in URLs
let settings = crate::test_support::tests::create_test_settings();
let html = r#"<!DOCTYPE html>
<html>
<body>
<a href="//cdn.example.com:9443/click">
<img src="//cdn.example.com:9443/img/300x250.svg" />
</a>
<img src="//cdn.example.com:9443/pixel?pid=test" width="1" height="1" />
</body>
</html>"#;
let out = rewrite_creative_html(&settings, html);

// Port 9443 should be preserved (URL-encoded as %3A9443)
assert!(
out.contains("cdn.example.com%3A9443"),
"Port 9443 should be preserved in rewritten URLs: {}",
out
);
}

#[test]
fn rewrite_style_urls_handles_absolute_and_relative() {
let settings = crate::test_support::tests::create_test_settings();
Expand Down
2 changes: 1 addition & 1 deletion crates/common/src/fastly_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ impl FastlyApiClient {
store_name: &str,
key_name: &str,
) -> Result<Self, Report<TrustedServerError>> {
let backend_name = ensure_backend_from_url("https://api.fastly.com")?;
let backend_name = ensure_backend_from_url("https://api.fastly.com", true)?;

let secret_store = FastlySecretStore::new(store_name);
let api_key = secret_store.get(key_name)?;
Expand Down
4 changes: 2 additions & 2 deletions crates/common/src/integrations/adserver_mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ impl AuctionProvider for AdServerMockProvider {
})?;

// Send async
let backend_name = ensure_backend_from_url(&self.config.endpoint).change_context(
let backend_name = ensure_backend_from_url(&self.config.endpoint, true).change_context(
TrustedServerError::Auction {
message: format!(
"Failed to resolve backend for mediation endpoint: {}",
Expand Down Expand Up @@ -340,7 +340,7 @@ impl AuctionProvider for AdServerMockProvider {
}

fn backend_name(&self) -> Option<String> {
ensure_backend_from_url(&self.config.endpoint).ok()
ensure_backend_from_url(&self.config.endpoint, true).ok()
}
}

Expand Down
4 changes: 2 additions & 2 deletions crates/common/src/integrations/aps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ impl AuctionProvider for ApsAuctionProvider {
})?;

// Send request asynchronously
let backend_name = ensure_backend_from_url(&self.config.endpoint).change_context(
let backend_name = ensure_backend_from_url(&self.config.endpoint, true).change_context(
TrustedServerError::Auction {
message: format!(
"Failed to resolve backend for APS endpoint: {}",
Expand Down Expand Up @@ -518,7 +518,7 @@ impl AuctionProvider for ApsAuctionProvider {
}

fn backend_name(&self) -> Option<String> {
ensure_backend_from_url(&self.config.endpoint).ok()
ensure_backend_from_url(&self.config.endpoint, true).ok()
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/common/src/integrations/didomi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ impl IntegrationProxy for DidomiIntegration {
let target_url = self
.build_target_url(base_origin, consent_path, req.get_query_str())
.change_context(Self::error("Failed to build Didomi target URL"))?;
let backend_name = ensure_backend_from_url(base_origin)
let backend_name = ensure_backend_from_url(base_origin, true)
.change_context(Self::error("Failed to configure Didomi backend"))?;

let mut proxy_req = Request::new(req.get_method().clone(), &target_url);
Expand Down
4 changes: 2 additions & 2 deletions crates/common/src/integrations/lockr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ impl LockrIntegration {
lockr_req.set_header(header::USER_AGENT, "TrustedServer/1.0");
lockr_req.set_header(header::ACCEPT, "application/javascript, */*");

let backend_name = ensure_backend_from_url(sdk_url)
let backend_name = ensure_backend_from_url(sdk_url, true)
.change_context(Self::error("Failed to determine backend for SDK fetch"))?;

let mut lockr_response =
Expand Down Expand Up @@ -242,7 +242,7 @@ impl LockrIntegration {
}

// Get backend and forward
let backend_name = ensure_backend_from_url(&self.config.api_endpoint)
let backend_name = ensure_backend_from_url(&self.config.api_endpoint, true)
.change_context(Self::error("Failed to determine backend for API proxy"))?;

let response = match target_req.send(backend_name) {
Expand Down
Loading