From 8f34db740137ca2ae7b33dd24b759f7fa248a01d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 16 Mar 2026 19:25:37 +0530 Subject: [PATCH 1/3] 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/3] 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/3] 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: