diff --git a/.env.dev b/.env.dev index 4272f2f..a637f6a 100644 --- a/.env.dev +++ b/.env.dev @@ -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 diff --git a/crates/common/src/auction/provider.rs b/crates/common/src/auction/provider.rs index 827b33f..d6068b8 100644 --- a/crates/common/src/auction/provider.rs +++ b/crates/common/src/auction/provider.rs @@ -62,7 +62,7 @@ pub trait AuctionProvider: Send + Sync { /// /// This is used by the orchestrator to correlate responses with providers /// when using `select()` to wait for multiple concurrent requests. - /// The backend name should match what `ensure_backend_from_url()` returns + /// The backend name should match what `BackendConfig::from_url()` returns /// for this provider's endpoint. fn backend_name(&self) -> Option { None diff --git a/crates/common/src/backend.rs b/crates/common/src/backend.rs index 6d33088..25f6168 100644 --- a/crates/common/src/backend.rs +++ b/crates/common/src/backend.rs @@ -6,142 +6,298 @@ use url::Url; use crate::error::TrustedServerError; -/// Ensure a dynamic backend exists for the given origin and return its name. +/// 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. /// -/// 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. +/// 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. /// -/// # Errors +/// 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() + } +} + +/// Configuration for creating a dynamic Fastly 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, +/// Uses the builder pattern so that new options can be added without changing +/// existing call sites — fields carry sensible defaults. +pub struct BackendConfig<'a> { + scheme: &'a str, + host: &'a str, port: Option, -) -> Result> { - 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 host_with_port = format!("{}:{}", host, target_port); - - // Name: iframe___ (sanitize '.' and ':') - let name_base = format!("{}_{}_{}", scheme, host, target_port); - let backend_name = format!("backend_{}", name_base.replace(['.', ':'], "_")); - - // 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) - .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); - log::info!("enable ssl for backend: {}", backend_name); - } - - match builder.finish() { - Ok(_) => { - log::info!( - "created dynamic backend: {} -> {}", - backend_name, - host_with_port - ); - Ok(backend_name) + certificate_check: bool, +} + +impl<'a> BackendConfig<'a> { + /// Create a new configuration with required fields and safe defaults. + /// + /// `certificate_check` defaults to `true`. + #[must_use] + pub fn new(scheme: &'a str, host: &'a str) -> Self { + Self { + scheme, + host, + port: None, + certificate_check: true, } - Err(e) => { - let msg = e.to_string(); - if msg.contains("NameInUse") || msg.contains("already in use") { - log::info!("reusing existing dynamic backend: {}", backend_name); - Ok(backend_name) + } + + /// Set the port for the backend. When `None`, the default port for the + /// scheme is used (443 for HTTPS, 80 for HTTP). + #[must_use] + pub fn port(mut self, port: Option) -> Self { + self.port = port; + self + } + + /// Control TLS certificate verification. Defaults to `true`. + #[must_use] + pub fn certificate_check(mut self, check: bool) -> Self { + self.certificate_check = check; + self + } + + /// Ensure a dynamic backend exists for this configuration and return its name. + /// + /// The backend name is derived from the scheme, host, port, and certificate + /// setting to avoid collisions. If a backend with the derived name already + /// exists, this function logs and reuses it. + /// + /// # Errors + /// + /// Returns an error if the host is empty or if backend creation fails + /// (except for `NameInUse` which reuses the existing backend). + pub fn ensure(self) -> Result> { + if self.host.is_empty() { + return Err(Report::new(TrustedServerError::Proxy { + message: "missing host".to_string(), + })); + } + + let target_port = self + .port + .unwrap_or_else(|| default_port_for_scheme(self.scheme)); + + let host_with_port = format!("{}:{}", self.host, target_port); + + // Include cert setting in name to avoid reusing a backend with different cert settings + let name_base = format!("{}_{}_{}", self.scheme, self.host, target_port); + let cert_suffix = if self.certificate_check { + "" + } else { + "_nocert" + }; + let backend_name = format!( + "backend_{}{}", + name_base.replace(['.', ':'], "_"), + cert_suffix + ); + + let host_header = compute_host_header(self.scheme, self.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_header) + .connect_timeout(Duration::from_secs(1)) + .first_byte_timeout(Duration::from_secs(15)) + .between_bytes_timeout(Duration::from_secs(10)); + if self.scheme.eq_ignore_ascii_case("https") { + builder = builder.enable_ssl().sni_hostname(self.host); + if self.certificate_check { + builder = builder + .enable_ssl() + .sni_hostname(self.host) + .check_certificate(self.host); } else { - Err(Report::new(TrustedServerError::Proxy { - message: format!( - "dynamic backend creation failed ({} -> {}): {}", - backend_name, host_with_port, msg - ), - })) + log::warn!( + "INSECURE: certificate check disabled for backend: {}", + backend_name + ); + } + log::info!("enable ssl for backend: {}", backend_name); + } + + match builder.finish() { + Ok(_) => { + log::info!( + "created dynamic backend: {} -> {}", + backend_name, + host_with_port + ); + Ok(backend_name) + } + Err(e) => { + let msg = e.to_string(); + if msg.contains("NameInUse") || msg.contains("already in use") { + log::info!("reusing existing dynamic backend: {}", backend_name); + Ok(backend_name) + } else { + Err(Report::new(TrustedServerError::Proxy { + message: format!( + "dynamic backend creation failed ({} -> {}): {}", + backend_name, host_with_port, msg + ), + })) + } } } } -} -/// 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. -/// -/// # 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> { - let parsed_url = Url::parse(origin_url).change_context(TrustedServerError::Proxy { - message: format!("Invalid origin_url: {}", origin_url), - })?; - - let scheme = parsed_url.scheme(); - let host = parsed_url.host_str().ok_or_else(|| { - Report::new(TrustedServerError::Proxy { - message: "Missing host in origin_url".to_string(), - }) - })?; - let port = parsed_url.port(); - - ensure_origin_backend(scheme, host, port) + /// Parse an origin URL and ensure a dynamic backend exists for it. + /// + /// This is a convenience constructor that parses the URL, extracts scheme, + /// host, and port, then calls [`ensure`](Self::ensure). + /// + /// # Errors + /// + /// Returns an error if the URL cannot be parsed or lacks a host, or if + /// backend creation fails. + pub fn from_url( + origin_url: &str, + certificate_check: bool, + ) -> Result> { + let parsed_url = Url::parse(origin_url).change_context(TrustedServerError::Proxy { + message: format!("Invalid origin_url: {}", origin_url), + })?; + + let scheme = parsed_url.scheme(); + let host = parsed_url.host_str().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "Missing host in origin_url".to_string(), + }) + })?; + let port = parsed_url.port(); + + BackendConfig::new(scheme, host) + .port(port) + .certificate_check(certificate_check) + .ensure() + } } #[cfg(test)] mod tests { - use super::ensure_origin_backend; + use super::{compute_host_header, BackendConfig}; + + // Tests for compute_host_header - the fix for port preservation in Host header + #[test] + 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 returns_name_for_https_no_port() { - let name = ensure_origin_backend("https", "origin.example.com", None) - .expect("should create backend for https without port"); + 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 = BackendConfig::new("https", "origin.example.com") + .ensure() + .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 = BackendConfig::new("https", "origin.example.com") + .certificate_check(false) + .ensure() + .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)) - .expect("should create backend for http with custom port"); + let name = BackendConfig::new("http", "api.test-site.org") + .port(Some(8080)) + .ensure() + .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) - .expect("should create backend for http defaulting to port 80"); + let name = BackendConfig::new("http", "example.org") + .ensure() + .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).expect_err("should error on empty host"); + let err = BackendConfig::new("https", "") + .ensure() + .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) - .expect("should create backend first time"); - let second = ensure_origin_backend("https", "reuse.example.com", None) - .expect("should reuse existing backend"); - assert_eq!(first, second); + let first = BackendConfig::new("https", "reuse.example.com") + .ensure() + .expect("should create backend on first call"); + let second = BackendConfig::new("https", "reuse.example.com") + .ensure() + .expect("should reuse backend on second call"); + assert_eq!( + first, second, + "should return same backend name on repeat call" + ); } } diff --git a/crates/common/src/creative.rs b/crates/common/src/creative.rs index 0265ad7..3a6d01d 100644 --- a/crates/common/src/creative.rs +++ b/crates/common/src/creative.rs @@ -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() @@ -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#" + + + + + + + +"#; + 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(); diff --git a/crates/common/src/fastly_storage.rs b/crates/common/src/fastly_storage.rs index a2466ba..bedd249 100644 --- a/crates/common/src/fastly_storage.rs +++ b/crates/common/src/fastly_storage.rs @@ -4,7 +4,7 @@ use error_stack::{Report, ResultExt}; use fastly::{ConfigStore, Request, Response, SecretStore}; use http::StatusCode; -use crate::backend::ensure_backend_from_url; +use crate::backend::BackendConfig; use crate::error::TrustedServerError; const FASTLY_API_HOST: &str = "https://api.fastly.com"; @@ -120,7 +120,7 @@ impl FastlyApiClient { store_name: &str, key_name: &str, ) -> Result> { - let backend_name = ensure_backend_from_url("https://api.fastly.com")?; + let backend_name = BackendConfig::from_url("https://api.fastly.com", true)?; let secret_store = FastlySecretStore::new(store_name); let api_key = secret_store.get(key_name)?; diff --git a/crates/common/src/integrations/adserver_mock.rs b/crates/common/src/integrations/adserver_mock.rs index b84625e..b6d1e65 100644 --- a/crates/common/src/integrations/adserver_mock.rs +++ b/crates/common/src/integrations/adserver_mock.rs @@ -17,7 +17,7 @@ use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, }; -use crate::backend::ensure_backend_from_url; +use crate::backend::BackendConfig; use crate::error::TrustedServerError; use crate::settings::{IntegrationConfig, Settings}; @@ -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 = BackendConfig::from_url(&self.config.endpoint, true).change_context( TrustedServerError::Auction { message: format!( "Failed to resolve backend for mediation endpoint: {}", @@ -340,7 +340,7 @@ impl AuctionProvider for AdServerMockProvider { } fn backend_name(&self) -> Option { - ensure_backend_from_url(&self.config.endpoint).ok() + BackendConfig::from_url(&self.config.endpoint, true).ok() } } diff --git a/crates/common/src/integrations/aps.rs b/crates/common/src/integrations/aps.rs index 02d946f..bdd9c25 100644 --- a/crates/common/src/integrations/aps.rs +++ b/crates/common/src/integrations/aps.rs @@ -12,7 +12,7 @@ use validator::Validate; use crate::auction::provider::AuctionProvider; use crate::auction::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, MediaType}; -use crate::backend::ensure_backend_from_url; +use crate::backend::BackendConfig; use crate::error::TrustedServerError; use crate::settings::IntegrationConfig; @@ -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 = BackendConfig::from_url(&self.config.endpoint, true).change_context( TrustedServerError::Auction { message: format!( "Failed to resolve backend for APS endpoint: {}", @@ -518,7 +518,7 @@ impl AuctionProvider for ApsAuctionProvider { } fn backend_name(&self) -> Option { - ensure_backend_from_url(&self.config.endpoint).ok() + BackendConfig::from_url(&self.config.endpoint, true).ok() } } diff --git a/crates/common/src/integrations/didomi.rs b/crates/common/src/integrations/didomi.rs index b279f7e..b6f7056 100644 --- a/crates/common/src/integrations/didomi.rs +++ b/crates/common/src/integrations/didomi.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use url::Url; use validator::Validate; -use crate::backend::ensure_backend_from_url; +use crate::backend::BackendConfig; use crate::error::TrustedServerError; use crate::integrations::{IntegrationEndpoint, IntegrationProxy, IntegrationRegistration}; use crate::settings::{IntegrationConfig, Settings}; @@ -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 = BackendConfig::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); diff --git a/crates/common/src/integrations/lockr.rs b/crates/common/src/integrations/lockr.rs index 6a5dd30..1147e84 100644 --- a/crates/common/src/integrations/lockr.rs +++ b/crates/common/src/integrations/lockr.rs @@ -23,7 +23,7 @@ use regex::Regex; use serde::Deserialize; use validator::Validate; -use crate::backend::ensure_backend_from_url; +use crate::backend::BackendConfig; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, @@ -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 = BackendConfig::from_url(sdk_url, true) .change_context(Self::error("Failed to determine backend for SDK fetch"))?; let mut lockr_response = @@ -242,7 +242,7 @@ impl LockrIntegration { } // Get backend and forward - let backend_name = ensure_backend_from_url(&self.config.api_endpoint) + let backend_name = BackendConfig::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) { diff --git a/crates/common/src/integrations/permutive.rs b/crates/common/src/integrations/permutive.rs index 96e7630..dccb11d 100644 --- a/crates/common/src/integrations/permutive.rs +++ b/crates/common/src/integrations/permutive.rs @@ -12,7 +12,7 @@ use fastly::{Request, Response}; use serde::Deserialize; use validator::Validate; -use crate::backend::ensure_backend_from_url; +use crate::backend::BackendConfig; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, @@ -118,7 +118,7 @@ impl PermutiveIntegration { permutive_req.set_header(header::USER_AGENT, "TrustedServer/1.0"); permutive_req.set_header(header::ACCEPT, "application/javascript, */*"); - let backend_name = ensure_backend_from_url(&sdk_url) + let backend_name = BackendConfig::from_url(&sdk_url, true) .change_context(Self::error("Failed to determine backend for SDK fetch"))?; let mut permutive_response = @@ -208,7 +208,7 @@ impl PermutiveIntegration { } // Get backend and forward - let backend_name = ensure_backend_from_url(&self.config.api_endpoint) + let backend_name = BackendConfig::from_url(&self.config.api_endpoint, true) .change_context(Self::error("Failed to determine backend for API proxy"))?; let response = target_req @@ -277,7 +277,7 @@ impl PermutiveIntegration { } // Get backend and forward - let backend_name = ensure_backend_from_url(&self.config.secure_signals_endpoint) + let backend_name = BackendConfig::from_url(&self.config.secure_signals_endpoint, true) .change_context(Self::error( "Failed to determine backend for Secure Signals proxy", ))?; @@ -342,7 +342,7 @@ impl PermutiveIntegration { } // Get backend and forward - let backend_name = ensure_backend_from_url("https://events.permutive.app") + let backend_name = BackendConfig::from_url("https://events.permutive.app", true) .change_context(Self::error("Failed to determine backend for Events proxy"))?; let response = target_req @@ -405,7 +405,7 @@ impl PermutiveIntegration { } // Get backend and forward - let backend_name = ensure_backend_from_url("https://sync.permutive.com") + let backend_name = BackendConfig::from_url("https://sync.permutive.com", true) .change_context(Self::error("Failed to determine backend for Sync proxy"))?; let response = target_req @@ -460,7 +460,7 @@ impl PermutiveIntegration { self.copy_request_headers(&req, &mut target_req); // Get backend and forward - let backend_name = ensure_backend_from_url("https://cdn.permutive.com") + let backend_name = BackendConfig::from_url("https://cdn.permutive.com", true) .change_context(Self::error("Failed to determine backend for CDN proxy"))?; let response = target_req diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 2a5ddba..6313281 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -14,7 +14,7 @@ use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, }; -use crate::backend::ensure_backend_from_url; +use crate::backend::BackendConfig; use crate::error::TrustedServerError; use crate::http_util::RequestInfo; use crate::integrations::{ @@ -647,7 +647,7 @@ impl AuctionProvider for PrebidAuctionProvider { })?; // Send request asynchronously - let backend_name = ensure_backend_from_url(&self.config.server_url)?; + let backend_name = BackendConfig::from_url(&self.config.server_url, true)?; let pending = pbs_req .send_async(backend_name) @@ -724,7 +724,7 @@ impl AuctionProvider for PrebidAuctionProvider { } fn backend_name(&self) -> Option { - ensure_backend_from_url(&self.config.server_url).ok() + BackendConfig::from_url(&self.config.server_url, true).ok() } } diff --git a/crates/common/src/proxy.rs b/crates/common/src/proxy.rs index baa9169..5c2f0b9 100644 --- a/crates/common/src/proxy.rs +++ b/crates/common/src/proxy.rs @@ -479,7 +479,10 @@ async fn proxy_with_redirects( })); } - let backend_name = crate::backend::ensure_origin_backend(&scheme, host, parsed_url.port())?; + let backend_name = crate::backend::BackendConfig::new(&scheme, host) + .port(parsed_url.port()) + .certificate_check(settings.proxy.certificate_check) + .ensure()?; let mut proxy_req = Request::new(current_method.clone(), ¤t_url); copy_proxy_forward_headers(req, &mut proxy_req); @@ -1175,6 +1178,31 @@ mod tests { assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); } + #[tokio::test] + async fn proxy_sign_preserves_non_standard_port() { + let settings = create_test_settings(); + let body = serde_json::json!({ + "url": "https://cdn.example.com:9443/img/300x250.svg", + }); + let mut req = Request::new(Method::POST, "https://edge.example/first-party/sign"); + req.set_body(body.to_string()); + let mut resp = handle_first_party_proxy_sign(&settings, req) + .await + .expect("should sign URL with non-standard port"); + assert_eq!( + resp.get_status(), + StatusCode::OK, + "should return 200 for valid sign request" + ); + let json = resp.take_body_str(); + // Port 9443 should be preserved (URL-encoded as %3A9443) + assert!( + json.contains("%3A9443"), + "Port should be preserved in signed URL: {}", + json + ); + } + #[test] fn proxy_request_config_supports_streaming_and_headers() { let cfg = ProxyRequestConfig::new("https://example.com/asset") @@ -1562,6 +1590,45 @@ mod tests { assert_eq!(ct, "text/css; charset=utf-8"); } + #[test] + fn html_response_rewrite_preserves_non_standard_port() { + // Verify that HTML rewriting preserves non-standard ports in sub-resource URLs. + // This is the core test for the port preservation fix. + let settings = create_test_settings(); + + let html = r#" + + + + + + + +"#; + + let beresp = Response::from_status(StatusCode::OK) + .with_header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .with_body(html); + + let req = Request::new(Method::GET, "https://edge.example/first-party/proxy"); + let mut out = finalize( + &settings, + &req, + "https://cdn.example.com:9443/creatives/300x250.html", + beresp, + ) + .expect("should finalize HTML response with non-standard port URL"); + + let body = out.take_body_str(); + + // Port 9443 should be preserved (URL-encoded as %3A9443) + assert!( + body.contains("cdn.example.com%3A9443"), + "Port 9443 should be preserved in rewritten URLs. Body:\n{}", + body + ); + } + #[test] fn image_accept_sets_generic_content_type_when_missing() { let settings = create_test_settings(); diff --git a/crates/common/src/publisher.rs b/crates/common/src/publisher.rs index 7d8f92d..fb160f3 100644 --- a/crates/common/src/publisher.rs +++ b/crates/common/src/publisher.rs @@ -2,7 +2,7 @@ use error_stack::{Report, ResultExt}; use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; -use crate::backend::ensure_backend_from_url; +use crate::backend::BackendConfig; use crate::http_util::{serve_static_with_etag, RequestInfo}; use crate::constants::{COOKIE_SYNTHETIC_ID, HEADER_X_COMPRESS_HINT, HEADER_X_SYNTHETIC_ID}; @@ -216,7 +216,10 @@ pub fn handle_publisher_request( has_synthetic_cookie ); - let backend_name = ensure_backend_from_url(&settings.publisher.origin_url)?; + let backend_name = BackendConfig::from_url( + &settings.publisher.origin_url, + settings.proxy.certificate_check, + )?; let origin_host = settings.publisher.origin_host(); log::debug!( diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index a8510db..6b2d4ca 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -277,6 +277,27 @@ fn default_request_signing_enabled() -> bool { false } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Proxy { + /// Enable TLS certificate verification when proxying to HTTPS origins. + /// Defaults to true for secure production use. + /// Set to false for local development with self-signed certificates. + #[serde(default = "default_certificate_check")] + pub certificate_check: bool, +} + +fn default_certificate_check() -> bool { + true +} + +impl Default for Proxy { + fn default() -> Self { + Self { + certificate_check: default_certificate_check(), + } + } +} + #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] pub struct Settings { #[validate(nested)] @@ -297,6 +318,8 @@ pub struct Settings { pub rewrite: Rewrite, #[serde(default)] pub auction: AuctionConfig, + #[serde(default)] + pub proxy: Proxy, } #[allow(unused)] @@ -325,6 +348,10 @@ impl Settings { return Err(Report::new(TrustedServerError::InsecureSecretKey)); } + if !settings.proxy.certificate_check { + log::warn!("INSECURE: proxy.certificate_check is disabled — TLS certificates will NOT be verified"); + } + Ok(settings) } diff --git a/trusted-server.toml b/trusted-server.toml index 2e22c06..ee4f84b 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -84,6 +84,12 @@ rewrite_sdk = true # ] +# Proxy configuration +# [proxy] +# Enable TLS certificate verification when proxying to HTTPS origins. +# Defaults to true. Set to false only for local development with self-signed certificates. +# certificate_check = true + [auction] enabled = true providers = ["prebid"]