From 8f34db740137ca2ae7b33dd24b759f7fa248a01d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 16 Mar 2026 19:25:37 +0530 Subject: [PATCH 1/7] Add domain allowlist to block SSRF via first-party proxy redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-party proxy followed up to 4 redirects with no restriction on redirect destinations. A signed URL pointing to an attacker-controlled origin could redirect to an internal service, enabling SSRF. Add `proxy.allowed_domains` — an opt-in list of permitted redirect target hostnames. Supports exact match and `*.`-wildcard prefix with dot-boundary enforcement. When the list is empty (default) behavior is unchanged for backward compatibility. Closes #414 --- .env.dev | 8 ++ crates/common/src/proxy.rs | 164 +++++++++++++++++++++++++++++++++- crates/common/src/settings.rs | 12 +++ trusted-server.toml | 17 +++- 4 files changed, 199 insertions(+), 2 deletions(-) diff --git a/.env.dev b/.env.dev index a637f6ab..cdd6af51 100644 --- a/.env.dev +++ b/.env.dev @@ -8,3 +8,11 @@ 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 +# +# Restrict first-party proxy redirect targets to an allowlist (JSON array or indexed form). +# Leave unset in local dev; configure in production to prevent SSRF via redirect chains +# initiated by signed first-party proxy URLs. +# TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS='["*.doubleclick.net","*.googlesyndication.com"]' +# Or using indexed form: +# TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS__0='*.doubleclick.net' +# TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS__1='*.googlesyndication.com' diff --git a/crates/common/src/proxy.rs b/crates/common/src/proxy.rs index 99db6328..d008d864 100644 --- a/crates/common/src/proxy.rs +++ b/crates/common/src/proxy.rs @@ -460,6 +460,27 @@ fn append_synthetic_id(req: &Request, target_url_parsed: &mut url::Url) { } } +/// Returns `true` if `host` is permitted by `pattern`. +/// +/// - `"example.com"` matches exactly `example.com`. +/// - `"*.example.com"` matches `example.com` and any subdomain at any depth. +/// +/// Comparison is case-insensitive. The wildcard check requires a dot boundary, +/// so `"*.example.com"` does **not** match `"evil-example.com"`. +fn is_host_allowed(host: &str, pattern: &str) -> bool { + let host = host.to_ascii_lowercase(); + let pattern = pattern.to_ascii_lowercase(); + + if let Some(suffix) = pattern.strip_prefix("*.") { + host == suffix + || host + .strip_suffix(suffix) + .is_some_and(|rest| rest.ends_with('.')) + } else { + host == pattern + } +} + async fn proxy_with_redirects( settings: &Settings, req: &Request, @@ -563,6 +584,25 @@ async fn proxy_with_redirects( return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); } + if !settings.proxy.allowed_domains.is_empty() { + let next_host = next_url.host_str().unwrap_or(""); + let allowed = settings + .proxy + .allowed_domains + .iter() + .any(|p| is_host_allowed(next_host, p)); + + if !allowed { + log::warn!( + "redirect to `{}` blocked: host not in proxy allowed_domains", + next_host + ); + return Err(Report::new(TrustedServerError::Proxy { + message: format!("redirect to `{next_host}` is not permitted"), + })); + } + } + log::info!( "following redirect {} => {} (status {})", current_url, @@ -1086,7 +1126,7 @@ fn reconstruct_and_validate_signed_target( mod tests { use super::{ copy_proxy_forward_headers, handle_first_party_click, handle_first_party_proxy, - handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, + handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed, reconstruct_and_validate_signed_target, ProxyRequestConfig, SUPPORTED_ENCODINGS, }; use crate::error::{IntoHttpResponse, TrustedServerError}; @@ -1797,4 +1837,126 @@ mod tests { body ); } + + // --- is_host_allowed --- + + #[test] + fn exact_match() { + assert!( + is_host_allowed("example.com", "example.com"), + "should match exact domain" + ); + } + + #[test] + fn exact_no_match() { + assert!( + !is_host_allowed("other.com", "example.com"), + "should not match different domain" + ); + } + + #[test] + fn wildcard_subdomain() { + assert!( + is_host_allowed("ad.example.com", "*.example.com"), + "should match direct subdomain" + ); + } + + #[test] + fn wildcard_deep_subdomain() { + assert!( + is_host_allowed("a.b.example.com", "*.example.com"), + "should match deep subdomain" + ); + } + + #[test] + fn wildcard_apex_match() { + assert!( + is_host_allowed("example.com", "*.example.com"), + "wildcard should also match apex domain" + ); + } + + #[test] + fn wildcard_no_boundary_bypass() { + assert!( + !is_host_allowed("evil-example.com", "*.example.com"), + "should not match host that lacks dot boundary" + ); + } + + #[test] + fn case_insensitive_host() { + assert!( + is_host_allowed("AD.EXAMPLE.COM", "*.example.com"), + "should match uppercase host" + ); + } + + #[test] + fn case_insensitive_pattern() { + assert!( + is_host_allowed("ad.example.com", "*.EXAMPLE.COM"), + "should match uppercase pattern" + ); + } + + // --- redirect allowlist enforcement (logic tests via is_host_allowed) --- + + #[test] + fn redirect_allowed_exact() { + let allowed = ["ad.example.com".to_string()]; + assert!( + allowed.iter().any(|p| is_host_allowed("ad.example.com", p)), + "should permit exact-match host" + ); + } + + #[test] + fn redirect_allowed_wildcard() { + let allowed = ["*.example.com".to_string()]; + assert!( + allowed + .iter() + .any(|p| is_host_allowed("sub.example.com", p)), + "should permit wildcard-matched host" + ); + } + + #[test] + fn redirect_blocked() { + let allowed = ["*.example.com".to_string()]; + assert!( + !allowed.iter().any(|p| is_host_allowed("evil.com", p)), + "should block host not in allowlist" + ); + } + + #[test] + fn redirect_empty_allowlist_permits_any() { + // The guard at proxy_with_redirects checks `!allowed_domains.is_empty()` + // before calling is_host_allowed, so no host is ever blocked when the + // list is empty. Verify the combined condition is false for any host. + let allowed: [String; 0] = []; + let would_block = + !allowed.is_empty() && !allowed.iter().any(|p| is_host_allowed("evil.com", p)); + assert!( + !would_block, + "empty allowlist should not block any redirect host" + ); + } + + #[test] + fn redirect_bypass_attempt() { + let allowed = ["*.example.com".to_string()]; + assert!( + !allowed + .iter() + .any(|p| is_host_allowed("evil-example.com", p)), + "should block dot-boundary bypass attempt" + ); + } } diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index eff23004..517633d3 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -280,6 +280,17 @@ pub struct Proxy { /// Set to false for local development with self-signed certificates. #[serde(default = "default_certificate_check")] pub certificate_check: bool, + /// Permitted redirect target domains for the first-party proxy. + /// + /// Supports exact hostname match (`"example.com"`) and subdomain wildcard + /// prefix (`"*.example.com"`, which also matches the apex `example.com`). + /// Matching is case-insensitive. + /// + /// When empty (the default), redirect destinations are not restricted. + /// Configure this in production to prevent SSRF via redirect chains + /// initiated by signed first-party proxy URLs. + #[serde(default, deserialize_with = "vec_from_seq_or_map")] + pub allowed_domains: Vec, } fn default_certificate_check() -> bool { @@ -290,6 +301,7 @@ impl Default for Proxy { fn default() -> Self { Self { certificate_check: default_certificate_check(), + allowed_domains: Vec::new(), } } } diff --git a/trusted-server.toml b/trusted-server.toml index 0c0a6f7e..b60ad959 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -112,11 +112,26 @@ rewrite_script = true # Proxy configuration -# [proxy] +[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 +# Restrict redirect destinations for the first-party proxy to an explicit domain allowlist. +# Supports exact match ("example.com") and subdomain wildcard prefix ("*.example.com"). +# Wildcard prefix also matches the apex domain ("*.example.com" matches "example.com"). +# Matching is case-insensitive. A dot-boundary check prevents "*.example.com" from +# matching "evil-example.com". +# When omitted or empty, redirect destinations are unrestricted — configure this in +# production to prevent SSRF via signed URLs that redirect to internal services. +# Note: this list governs only the first-party proxy redirect chain, not integration +# endpoints defined under [integrations.*]. +# allowed_domains = [ + # "ad.example.com", + # "*.doubleclick.net", + # "*.googlesyndication.com", +# ] + [auction] enabled = true providers = ["prebid"] From bd88f8d56ec3248563c6cc1788be113117165a5a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 17 Mar 2026 13:52:30 +0530 Subject: [PATCH 2/7] Normalize proxy allowed_domains and harden redirect allowlist enforcement --- crates/common/src/proxy.rs | 88 +++++++++++++++++++----- crates/common/src/settings.rs | 122 +++++++++++++++++++++++++++++++++- 2 files changed, 191 insertions(+), 19 deletions(-) diff --git a/crates/common/src/proxy.rs b/crates/common/src/proxy.rs index d008d864..a8710fe0 100644 --- a/crates/common/src/proxy.rs +++ b/crates/common/src/proxy.rs @@ -460,6 +460,14 @@ fn append_synthetic_id(req: &Request, target_url_parsed: &mut url::Url) { } } +/// Returns `true` when a redirect to `host` should be followed. +/// +/// When `allowed_domains` is empty every host is permitted (open mode). +/// When non-empty the host must match at least one pattern via [`is_host_allowed`]. +fn redirect_is_permitted(allowed_domains: &[String], host: &str) -> bool { + allowed_domains.is_empty() || allowed_domains.iter().any(|p| is_host_allowed(host, p)) +} + /// Returns `true` if `host` is permitted by `pattern`. /// /// - `"example.com"` matches exactly `example.com`. @@ -584,23 +592,15 @@ async fn proxy_with_redirects( return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); } - if !settings.proxy.allowed_domains.is_empty() { - let next_host = next_url.host_str().unwrap_or(""); - let allowed = settings - .proxy - .allowed_domains - .iter() - .any(|p| is_host_allowed(next_host, p)); - - if !allowed { - log::warn!( - "redirect to `{}` blocked: host not in proxy allowed_domains", - next_host - ); - return Err(Report::new(TrustedServerError::Proxy { - message: format!("redirect to `{next_host}` is not permitted"), - })); - } + let next_host = next_url.host_str().unwrap_or(""); + if !redirect_is_permitted(&settings.proxy.allowed_domains, next_host) { + log::warn!( + "redirect to `{}` blocked: host not in proxy allowed_domains", + next_host + ); + return Err(Report::new(TrustedServerError::Proxy { + message: format!("redirect to `{next_host}` is not permitted"), + })); } log::info!( @@ -1127,7 +1127,8 @@ mod tests { use super::{ copy_proxy_forward_headers, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed, - reconstruct_and_validate_signed_target, ProxyRequestConfig, SUPPORTED_ENCODINGS, + reconstruct_and_validate_signed_target, redirect_is_permitted, ProxyRequestConfig, + SUPPORTED_ENCODINGS, }; use crate::error::{IntoHttpResponse, TrustedServerError}; use crate::test_support::tests::create_test_settings; @@ -1959,4 +1960,55 @@ mod tests { "should block dot-boundary bypass attempt" ); } + + // --- redirect_is_permitted (full guard: empty-list bypass + is_host_allowed) --- + + #[test] + fn redirect_chain_allowed_when_host_matches_allowlist() { + let allowed = vec!["ad.example.com".to_string(), "cdn.example.com".to_string()]; + assert!( + redirect_is_permitted(&allowed, "ad.example.com"), + "should permit redirect to exact-match host" + ); + assert!( + redirect_is_permitted(&allowed, "cdn.example.com"), + "should permit redirect to second allowed host" + ); + } + + #[test] + fn redirect_chain_allowed_when_host_matches_wildcard() { + let allowed = vec!["*.example.com".to_string()]; + assert!( + redirect_is_permitted(&allowed, "sub.example.com"), + "should permit redirect to wildcard-matched subdomain" + ); + } + + #[test] + fn redirect_chain_blocked_when_host_not_in_allowlist() { + let allowed = vec!["ad.example.com".to_string()]; + assert!( + !redirect_is_permitted(&allowed, "evil.com"), + "should block redirect to host not in allowlist" + ); + } + + #[test] + fn redirect_chain_allowed_when_allowlist_is_empty() { + let allowed: Vec = vec![]; + assert!( + redirect_is_permitted(&allowed, "any-host.com"), + "should allow any redirect when allowlist is empty (open mode)" + ); + } + + #[test] + fn redirect_chain_blocked_when_host_is_empty() { + let allowed = vec!["example.com".to_string()]; + assert!( + !redirect_is_permitted(&allowed, ""), + "should block redirect with empty host when allowlist is non-empty" + ); + } } diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index 517633d3..83b2b7b6 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -306,6 +306,23 @@ impl Default for Proxy { } } +impl Proxy { + /// Normalizes [`allowed_domains`] in place. + /// + /// Each entry is trimmed of surrounding whitespace and lowercased. + /// Empty entries (including those that were only whitespace) are removed. + /// This prevents blank patterns from making the list non-empty while + /// still never matching any host, which would silently block all redirects. + fn normalize(&mut self) { + self.allowed_domains = self + .allowed_domains + .iter() + .map(|s| s.trim().to_ascii_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); + } +} + #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] pub struct Settings { #[validate(nested)] @@ -341,11 +358,13 @@ impl Settings { /// /// - [`TrustedServerError::Configuration`] if the TOML is invalid or missing required fields pub fn from_toml(toml_str: &str) -> Result> { - let settings: Self = + let mut settings: Self = toml::from_str(toml_str).change_context(TrustedServerError::Configuration { message: "Failed to deserialize TOML configuration".to_string(), })?; + settings.proxy.normalize(); + Ok(settings) } @@ -379,6 +398,7 @@ impl Settings { })?; settings.integrations.normalize(); + settings.proxy.normalize(); settings.validate().map_err(|err| { Report::new(TrustedServerError::Configuration { @@ -1181,4 +1201,104 @@ mod tests { "Empty allowed_context_keys should be respected (blocks all keys)" ); } + + // --- Proxy::normalize --- + + #[test] + fn proxy_normalize_trims_and_lowercases() { + let mut proxy = Proxy { + certificate_check: true, + allowed_domains: vec![ + " AD.EXAMPLE.COM ".to_string(), + "*.Example.Org".to_string(), + ], + }; + proxy.normalize(); + assert_eq!( + proxy.allowed_domains, + vec!["ad.example.com".to_string(), "*.example.org".to_string()], + "should trim and lowercase each entry" + ); + } + + #[test] + fn proxy_normalize_drops_empty_and_whitespace_entries() { + let mut proxy = Proxy { + certificate_check: true, + allowed_domains: vec![ + "example.com".to_string(), + " ".to_string(), + "".to_string(), + "cdn.example.com".to_string(), + ], + }; + proxy.normalize(); + assert_eq!( + proxy.allowed_domains, + vec!["example.com".to_string(), "cdn.example.com".to_string()], + "should drop blank and whitespace-only entries" + ); + } + + #[test] + fn proxy_normalize_all_blank_yields_empty_list() { + let mut proxy = Proxy { + certificate_check: true, + allowed_domains: vec![" ".to_string(), "\t".to_string()], + }; + proxy.normalize(); + assert!( + proxy.allowed_domains.is_empty(), + "all-blank list should normalize to empty (open mode)" + ); + } + + #[test] + fn proxy_normalize_applied_by_from_toml() { + let toml_str = crate_test_settings_str() + + r#" + [proxy] + allowed_domains = [" AD.EXAMPLE.COM ", " ", "*.CDN.Example.Com"] + "#; + let settings = Settings::from_toml(&toml_str).expect("should parse TOML"); + assert_eq!( + settings.proxy.allowed_domains, + vec![ + "ad.example.com".to_string(), + "*.cdn.example.com".to_string() + ], + "from_toml should normalize allowed_domains" + ); + } + + #[test] + fn proxy_normalize_applied_by_from_toml_and_env() { + let toml_str = crate_test_settings_str() + + r#" + [proxy] + allowed_domains = [" AD.EXAMPLE.COM ", " ", "*.CDN.Example.Com"] + "#; + let origin_key = format!( + "{}{}PUBLISHER{}ORIGIN_URL", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + temp_env::with_var( + origin_key, + Some("https://origin.test-publisher.com"), + || { + let settings = + Settings::from_toml_and_env(&toml_str).expect("should parse TOML with env"); + assert_eq!( + settings.proxy.allowed_domains, + vec![ + "ad.example.com".to_string(), + "*.cdn.example.com".to_string() + ], + "from_toml_and_env should normalize allowed_domains" + ); + }, + ); + } } From d5227dec51d512766e64d47c38ba811d73c7a64a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 18 Mar 2026 11:45:00 +0530 Subject: [PATCH 3/7] Document proxy.allowed_domains in proxy and configuration guides --- docs/guide/configuration.md | 74 +++++++++++++++++++++++++++++++++ docs/guide/first-party-proxy.md | 41 ++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 7fa3cfdf..4c3ab5e2 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -64,6 +64,7 @@ openssl rand -base64 32 | ------------------- | -------------------------------------------- | | `[publisher]` | Domain, origin, proxy settings | | `[synthetic]` | Synthetic ID generation | +| `[proxy]` | Proxy SSRF allowlist | | `[request_signing]` | Ed25519 request signing | | `[auction]` | Auction orchestration | | `[integrations.*]` | Partner integrations (Prebid, Next.js, etc.) | @@ -718,6 +719,79 @@ exclude_domains = ["*.publisher.com"] # Skip unnecessary proxying See [Creative Processing](/guide/creative-processing#exclude-domains) for details. +## Proxy Configuration + +Controls first-party proxy security settings. + +### `[proxy]` + +| Field | Type | Required | Description | +| ----------------- | ------------- | ------------------ | ------------------------------------------------------ | +| `allowed_domains` | Array[String] | No (default: `[]`) | Redirect destinations the proxy is permitted to follow | + +**Example**: + +```toml +[proxy] +allowed_domains = [ + "tracker.com", # Exact match + "*.adserver.com", # Wildcard: adserver.com and all subdomains + "*.trusted-cdn.net", +] +``` + +**Environment Override**: + +```bash +# JSON array +TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS='["tracker.com","*.adserver.com"]' + +# Indexed +TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS__0="tracker.com" +TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS__1="*.adserver.com" + +# Comma-separated +TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS="tracker.com,*.adserver.com" +``` + +### Field Details + +#### `allowed_domains` + +**Purpose**: Allowlist of redirect destinations the proxy is permitted to follow. + +**Behavior**: When the proxy receives an HTTP redirect (301/302/303/307/308) during a request to `/first-party/proxy` or `/first-party/click`, the redirect target host is checked against this list. A redirect whose host is not matched is blocked with a 403 error. + +**Default — open mode**: When `allowed_domains` is absent or empty, every redirect destination is allowed. This default is intentional for zero-config development but should not be used in production. + +**Pattern Matching**: + +| Pattern | Matches | Does not match | +| --------------- | --------------------------------------------------- | ------------------ | +| `tracker.com` | `tracker.com` | `sub.tracker.com` | +| `*.tracker.com` | `tracker.com`, `sub.tracker.com`, `a.b.tracker.com` | `evil-tracker.com` | + +- `"example.com"` — exact match only. +- `"*.example.com"` — matches the base domain and any subdomain at any depth. +- Matching is case-insensitive; entries are normalized to lowercase at startup. +- Blank entries are ignored. +- The `*` wildcard requires a dot boundary: `*.example.com` does **not** match `evil-example.com`. + +::: danger Production Recommendation +Always configure `allowed_domains` in production. Without an explicit allowlist, a signed proxy URL can be used to follow redirects to arbitrary hosts, creating an SSRF risk. + +```toml +[proxy] +allowed_domains = [ + "*.your-ad-network.com", + "tracker.your-partner.com", +] +``` + +::: + +See [First-Party Proxy](/guide/first-party-proxy#proxy-allowlist) for usage details. + ## Integration Configurations Settings for built-in integrations (Prebid, Next.js, Permutive, Testlight). For other diff --git a/docs/guide/first-party-proxy.md b/docs/guide/first-party-proxy.md index c3e04fc2..1b13f677 100644 --- a/docs/guide/first-party-proxy.md +++ b/docs/guide/first-party-proxy.md @@ -425,6 +425,47 @@ proxy_secret = "your-secure-random-secret" cookie_domain = ".publisher.com" # For synthetic_id cookies ``` +### Proxy Allowlist + +Restrict which domains the proxy may redirect to via the `[proxy]` section: + +```toml +[proxy] +allowed_domains = [ + "tracker.com", # Exact match + "*.adserver.com", # Wildcard: adserver.com and all subdomains + "*.trusted-cdn.net", +] +``` + +**Semantics**: When a proxied request receives an HTTP redirect (301/302/303/307/308), the redirect target host is checked against `allowed_domains`. If the host does not match any pattern the redirect is blocked and a 403 error is returned. + +**Wildcard matching**: + +| Pattern | Matches | Does not match | +| --------------- | --------------------------------------------------- | ------------------ | +| `tracker.com` | `tracker.com` | `sub.tracker.com` | +| `*.tracker.com` | `tracker.com`, `sub.tracker.com`, `a.b.tracker.com` | `evil-tracker.com` | + +- The `*` prefix matches the base domain and any subdomain at any depth. +- Matching is case-insensitive; entries are normalized to lowercase on startup. +- The wildcard requires a dot boundary — `*.example.com` will **not** match `evil-example.com`. + +**Default behavior**: When `allowed_domains` is omitted (or set to an empty list) every redirect destination is permitted. This default is intentional for zero-config development but should not be used in production. + +::: danger Production Recommendation +Always set `allowed_domains` explicitly in production deployments. Without an allowlist, a signed proxy URL that follows redirects could be used to reach internal or unintended hosts (SSRF). + +```toml +[proxy] +allowed_domains = [ + "*.your-ad-network.com", + "tracker.your-partner.com", +] +``` + +::: + ### URL Rewrite Exclusions Exclude specific domains from rewriting: From a04b0e91f196ce6f929e3226b4592d812d9618e1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 21 Mar 2026 09:31:08 +0530 Subject: [PATCH 4/7] Enforce proxy allowlist on initial target and redirect hops --- crates/trusted-server-core/src/error.rs | 5 +++ crates/trusted-server-core/src/proxy.rs | 49 +++++++++++++++++++++- crates/trusted-server-core/src/settings.rs | 5 +++ docs/guide/configuration.md | 2 +- docs/guide/first-party-proxy.md | 6 +-- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/error.rs b/crates/trusted-server-core/src/error.rs index 0e4a7456..f80282d0 100644 --- a/crates/trusted-server-core/src/error.rs +++ b/crates/trusted-server-core/src/error.rs @@ -66,6 +66,10 @@ pub enum TrustedServerError { #[display("Proxy error: {message}")] Proxy { message: String }, + /// A redirect destination was blocked by the proxy allowlist. + #[display("Redirect to `{host}` blocked: host not in proxy allowed_domains")] + AllowlistViolation { host: String }, + /// Settings parsing or validation failed. #[display("Settings error: {message}")] Settings { message: String }, @@ -106,6 +110,7 @@ impl IntoHttpResponse for TrustedServerError { Self::Prebid { .. } => StatusCode::BAD_GATEWAY, Self::Integration { .. } => StatusCode::BAD_GATEWAY, Self::Proxy { .. } => StatusCode::BAD_GATEWAY, + Self::AllowlistViolation { .. } => StatusCode::FORBIDDEN, Self::SyntheticId { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::Template { .. } => StatusCode::INTERNAL_SERVER_ERROR, } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 6814fcd9..1afd0b0f 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -543,6 +543,16 @@ async fn proxy_with_redirects( })); } + if !redirect_is_permitted(&settings.proxy.allowed_domains, host) { + log::warn!( + "request to `{}` blocked: host not in proxy allowed_domains", + host + ); + return Err(Report::new(TrustedServerError::AllowlistViolation { + host: host.to_string(), + })); + } + let backend_name = crate::backend::BackendConfig::new(&scheme, host) .port(parsed_url.port()) .certificate_check(settings.proxy.certificate_check) @@ -619,8 +629,8 @@ async fn proxy_with_redirects( "redirect to `{}` blocked: host not in proxy allowed_domains", next_host ); - return Err(Report::new(TrustedServerError::Proxy { - message: format!("redirect to `{next_host}` is not permitted"), + return Err(Report::new(TrustedServerError::AllowlistViolation { + host: next_host.to_string(), })); } @@ -2048,4 +2058,39 @@ mod tests { "should block redirect with empty host when allowlist is non-empty" ); } + + // --- initial target allowlist enforcement (integration-level) --- + + #[tokio::test] + async fn proxy_initial_target_blocked_by_allowlist() { + use crate::http_util::compute_encrypted_sha256_token; + + let mut settings = create_test_settings(); + settings.proxy.allowed_domains = vec!["allowed.com".to_string()]; + + let target = "https://blocked.com/pixel.gif"; + let token = compute_encrypted_sha256_token(&settings, target); + let url = format!( + "https://edge.example/first-party/proxy?tsurl={}&tstoken={}", + urlencoding::encode(target), + token, + ); + let req = Request::new(Method::GET, url); + let err = handle_first_party_proxy(&settings, req) + .await + .expect_err("should block initial target not in allowlist"); + assert_eq!( + err.current_context().status_code(), + StatusCode::FORBIDDEN, + "should return 403 for allowlist violation" + ); + assert!( + matches!( + err.current_context(), + TrustedServerError::AllowlistViolation { .. } + ), + "should be AllowlistViolation error" + ); + } + } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 33e93858..7fb14729 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -354,6 +354,11 @@ impl Proxy { .map(|s| s.trim().to_ascii_lowercase()) .filter(|s| !s.is_empty()) .collect(); + if self.allowed_domains.is_empty() { + log::warn!( + "proxy.allowed_domains is empty: all redirect destinations are permitted (open mode)" + ); + } } } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 50c9bde1..4ee4e87e 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -761,7 +761,7 @@ TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS="tracker.com,*.adserver.com" **Purpose**: Allowlist of redirect destinations the proxy is permitted to follow. -**Behavior**: When the proxy receives an HTTP redirect (301/302/303/307/308) during a request to `/first-party/proxy` or `/first-party/click`, the redirect target host is checked against this list. A redirect whose host is not matched is blocked with a 403 error. +**Behavior**: When the proxy receives an HTTP redirect (301/302/303/307/308) during a request to `/first-party/proxy`, the redirect target host is checked against this list. A redirect whose host is not matched is blocked with a 403 error. **Default — open mode**: When `allowed_domains` is absent or empty, every redirect destination is allowed. This default is intentional for zero-config development but should not be used in production. diff --git a/docs/guide/first-party-proxy.md b/docs/guide/first-party-proxy.md index 1b13f677..6373a77b 100644 --- a/docs/guide/first-party-proxy.md +++ b/docs/guide/first-party-proxy.md @@ -562,7 +562,7 @@ Only essential headers are forwarded to reduce overhead: **Invalid Signature**: ``` -HTTP 403 Forbidden +HTTP 502 Bad Gateway tstoken validation failed: signature mismatch ``` @@ -575,7 +575,7 @@ tstoken validation failed: signature mismatch **Expired URL**: ``` -HTTP 403 Forbidden +HTTP 502 Bad Gateway tstoken expired ``` @@ -640,7 +640,7 @@ Attacker tries: Trusted Server: 1. Computes expected token for https://evil.com 2. Compares with provided token - 3. Rejects if mismatch (403 Forbidden) + 3. Rejects if mismatch (502 Bad Gateway) ``` ### Content Security From fbb3b6df4b41d6cb9231d61b61606092f049febd Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 21 Mar 2026 09:32:34 +0530 Subject: [PATCH 5/7] Fix format ci failure --- crates/trusted-server-core/src/proxy.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 1afd0b0f..bf04dffc 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -2092,5 +2092,4 @@ mod tests { "should be AllowlistViolation error" ); } - } From ef745016a461314a33610f790a8b31befa09fa6d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 25 Mar 2026 10:52:41 +0530 Subject: [PATCH 6/7] Addressed pr findings --- crates/trusted-server-core/src/proxy.rs | 81 ++++++++++++++++++++-- crates/trusted-server-core/src/settings.rs | 42 ++++++++++- docs/guide/first-party-proxy.md | 7 +- 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index bf04dffc..745a8414 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -46,10 +46,19 @@ pub struct ProxyRequestConfig<'a> { pub copy_request_headers: bool, /// When true, stream the origin response without HTML/CSS rewrites. pub stream_passthrough: bool, + /// Domain allowlist enforced on the initial target and every redirect hop. + /// + /// An empty slice disables allowlist enforcement (open mode). + /// Integration proxies should pass `&[]`; first-party proxy passes + /// `&settings.proxy.allowed_domains`. + pub allowed_domains: &'a [String], } impl<'a> ProxyRequestConfig<'a> { /// Build a proxy configuration that follows redirects and forwards the synthetic ID. + /// + /// `allowed_domains` defaults to `&[]` (open mode). Override it for the + /// first-party proxy by setting [`allowed_domains`] directly. #[must_use] pub fn new(target_url: &'a str) -> Self { Self { @@ -60,6 +69,7 @@ impl<'a> ProxyRequestConfig<'a> { headers: Vec::new(), copy_request_headers: true, stream_passthrough: false, + allowed_domains: &[], } } @@ -390,6 +400,7 @@ fn finalize_response( struct ProxyRequestHeaders<'a> { additional_headers: &'a [(header::HeaderName, HeaderValue)], copy_request_headers: bool, + allowed_domains: &'a [String], } /// Proxy a request to a clear target URL while reusing creative rewrite logic. @@ -415,6 +426,7 @@ pub async fn proxy_request( headers, copy_request_headers, stream_passthrough, + allowed_domains, } = config; let mut target_url_parsed = url::Url::parse(target_url).map_err(|_| { @@ -436,6 +448,7 @@ pub async fn proxy_request( ProxyRequestHeaders { additional_headers: &headers, copy_request_headers, + allowed_domains, }, stream_passthrough, ) @@ -483,8 +496,11 @@ fn append_synthetic_id(req: &Request, target_url_parsed: &mut url::Url) { /// /// When `allowed_domains` is empty every host is permitted (open mode). /// When non-empty the host must match at least one pattern via [`is_host_allowed`]. -fn redirect_is_permitted(allowed_domains: &[String], host: &str) -> bool { - allowed_domains.is_empty() || allowed_domains.iter().any(|p| is_host_allowed(host, p)) +fn redirect_is_permitted>(allowed_domains: &[S], host: &str) -> bool { + allowed_domains.is_empty() + || allowed_domains + .iter() + .any(|p| is_host_allowed(host, p.as_ref())) } /// Returns `true` if `host` is permitted by `pattern`. @@ -543,7 +559,7 @@ async fn proxy_with_redirects( })); } - if !redirect_is_permitted(&settings.proxy.allowed_domains, host) { + if !redirect_is_permitted(request_headers.allowed_domains, host) { log::warn!( "request to `{}` blocked: host not in proxy allowed_domains", host @@ -623,8 +639,15 @@ async fn proxy_with_redirects( return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); } - let next_host = next_url.host_str().unwrap_or(""); - if !redirect_is_permitted(&settings.proxy.allowed_domains, next_host) { + let next_host = match next_url.host_str() { + Some(h) if !h.is_empty() => h, + _ => { + return Err(Report::new(TrustedServerError::Proxy { + message: "missing host in redirect location".to_string(), + })); + } + }; + if !redirect_is_permitted(request_headers.allowed_domains, next_host) { log::warn!( "redirect to `{}` blocked: host not in proxy allowed_domains", next_host @@ -687,6 +710,7 @@ pub async fn handle_first_party_proxy( headers: Vec::new(), copy_request_headers: true, stream_passthrough: false, + allowed_domains: &settings.proxy.allowed_domains, }, ) .await @@ -2059,7 +2083,54 @@ mod tests { ); } + #[test] + fn redirect_is_permitted_accepts_str_slices() { + // Verifies the &[impl AsRef] bound works with &str literals, + // not just Vec. + let allowed: &[&str] = &["example.com", "*.cdn.example.com"]; + assert!( + redirect_is_permitted(allowed, "example.com"), + "should permit exact match via &str slice" + ); + assert!( + redirect_is_permitted(allowed, "static.cdn.example.com"), + "should permit wildcard match via &str slice" + ); + assert!( + !redirect_is_permitted(allowed, "evil.com"), + "should block host not in &str slice allowlist" + ); + } + + #[test] + fn ip_literal_blocked_by_domain_allowlist() { + let allowed = vec!["*.example.com".to_string()]; + assert!( + !redirect_is_permitted(&allowed, "169.254.169.254"), + "should block cloud metadata IP" + ); + assert!( + !redirect_is_permitted(&allowed, "127.0.0.1"), + "should block loopback IPv4" + ); + assert!( + !redirect_is_permitted(&allowed, "[::1]"), + "should block loopback IPv6" + ); + assert!( + !redirect_is_permitted(&allowed, "::1"), + "should block bare loopback IPv6" + ); + } + // --- initial target allowlist enforcement (integration-level) --- + // + // NOTE: A test for Nth-hop redirect blocking (i.e. exercising the + // `redirect_is_permitted` check that fires *after* receiving a 302 + // response) requires a Viceroy backend fixture that returns a redirect. + // That infrastructure is not available here. The unit tests above for + // `redirect_is_permitted` and `ip_literal_blocked_by_domain_allowlist` + // cover the blocking logic used at every hop. #[tokio::test] async fn proxy_initial_target_blocked_by_allowlist() { diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 7fb14729..597c8c0e 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -345,8 +345,9 @@ impl Proxy { /// /// Each entry is trimmed of surrounding whitespace and lowercased. /// Empty entries (including those that were only whitespace) are removed. - /// This prevents blank patterns from making the list non-empty while - /// still never matching any host, which would silently block all redirects. + /// A bare `"*"` entry is removed with a warning: it is not a valid pattern + /// (it never matches any real host) and is likely a mistake. Users who want + /// open mode should omit `allowed_domains` entirely or leave it empty. fn normalize(&mut self) { self.allowed_domains = self .allowed_domains @@ -354,6 +355,16 @@ impl Proxy { .map(|s| s.trim().to_ascii_lowercase()) .filter(|s| !s.is_empty()) .collect(); + + let before = self.allowed_domains.len(); + self.allowed_domains.retain(|s| s != "*"); + if self.allowed_domains.len() < before { + log::warn!( + "proxy.allowed_domains: bare \"*\" is not a valid pattern and has been removed; \ + omit allowed_domains or leave it empty for open mode" + ); + } + if self.allowed_domains.is_empty() { log::warn!( "proxy.allowed_domains is empty: all redirect destinations are permitted (open mode)" @@ -1458,6 +1469,33 @@ mod tests { ); } + #[test] + fn proxy_normalize_removes_bare_wildcard() { + let mut proxy = Proxy { + certificate_check: true, + allowed_domains: vec!["*".to_string(), "tracker.com".to_string()], + }; + proxy.normalize(); + assert_eq!( + proxy.allowed_domains, + vec!["tracker.com".to_string()], + "should remove bare \"*\" (invalid pattern that blocks all traffic)" + ); + } + + #[test] + fn proxy_normalize_bare_wildcard_alone_yields_open_mode() { + let mut proxy = Proxy { + certificate_check: true, + allowed_domains: vec!["*".to_string()], + }; + proxy.normalize(); + assert!( + proxy.allowed_domains.is_empty(), + "bare \"*\" alone should normalize to empty list (open mode)" + ); + } + #[test] fn proxy_normalize_all_blank_yields_empty_list() { let mut proxy = Proxy { diff --git a/docs/guide/first-party-proxy.md b/docs/guide/first-party-proxy.md index 6373a77b..27e0ac42 100644 --- a/docs/guide/first-party-proxy.md +++ b/docs/guide/first-party-proxy.md @@ -450,8 +450,13 @@ allowed_domains = [ - The `*` prefix matches the base domain and any subdomain at any depth. - Matching is case-insensitive; entries are normalized to lowercase on startup. - The wildcard requires a dot boundary — `*.example.com` will **not** match `evil-example.com`. +- A bare `"*"` entry is **not** valid and will be removed at startup with a warning. Use an empty list for open mode. -**Default behavior**: When `allowed_domains` is omitted (or set to an empty list) every redirect destination is permitted. This default is intentional for zero-config development but should not be used in production. +:::note Unicode / Internationalized Domain Names +Matching uses ASCII case-folding (`to_ascii_lowercase`). Internationalized domain names (IDNs) in Punycode form (e.g., `xn--nxasmq6b.com`) are matched literally — the Unicode label and its Punycode equivalent are treated as different strings. If your ad network uses IDN domains, add the Punycode form to `allowed_domains`. +::: + +**Default behavior**: When `allowed_domains` is omitted (or set to an empty list) every redirect destination is permitted. This default exists solely for development convenience and **must be overridden in production**. ::: danger Production Recommendation Always set `allowed_domains` explicitly in production deployments. Without an allowlist, a signed proxy URL that follows redirects could be used to reach internal or unintended hosts (SSRF). From 8408a30da505dd2f9ac90e9fd251a6416daf420e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 30 Mar 2026 13:58:44 +0530 Subject: [PATCH 7/7] Resolve pr review findings --- crates/trusted-server-core/src/proxy.rs | 2 +- crates/trusted-server-core/src/settings.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 745a8414..3b970bea 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -58,7 +58,7 @@ impl<'a> ProxyRequestConfig<'a> { /// Build a proxy configuration that follows redirects and forwards the synthetic ID. /// /// `allowed_domains` defaults to `&[]` (open mode). Override it for the - /// first-party proxy by setting [`allowed_domains`] directly. + /// first-party proxy by setting `allowed_domains` directly. #[must_use] pub fn new(target_url: &'a str) -> Self { Self { diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index edeb1eef..15f39f65 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -375,7 +375,7 @@ impl Default for Proxy { } impl Proxy { - /// Normalizes [`allowed_domains`] in place. + /// Normalizes `allowed_domains` in place. /// /// Each entry is trimmed of surrounding whitespace and lowercased. /// Empty entries (including those that were only whitespace) are removed. @@ -400,7 +400,7 @@ impl Proxy { } if self.allowed_domains.is_empty() { - log::warn!( + log::info!( "proxy.allowed_domains is empty: all redirect destinations are permitted (open mode)" ); }