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/trusted-server-core/src/error.rs b/crates/trusted-server-core/src/error.rs index 8e352676..21ecd35b 100644 --- a/crates/trusted-server-core/src/error.rs +++ b/crates/trusted-server-core/src/error.rs @@ -60,6 +60,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 }, @@ -99,6 +103,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 79b368ab..01257c08 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, ) @@ -479,6 +492,38 @@ 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: &[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`. +/// +/// - `"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, @@ -514,6 +559,16 @@ async fn proxy_with_redirects( })); } + if !redirect_is_permitted(request_headers.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) @@ -584,6 +639,24 @@ async fn proxy_with_redirects( return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); } + 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 + ); + return Err(Report::new(TrustedServerError::AllowlistViolation { + host: next_host.to_string(), + })); + } + log::info!( "following redirect {} => {} (status {})", current_url, @@ -637,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 @@ -1108,8 +1182,9 @@ 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, - reconstruct_and_validate_signed_target, ProxyRequestConfig, SUPPORTED_ENCODINGS, + handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed, + reconstruct_and_validate_signed_target, redirect_is_permitted, ProxyRequestConfig, + SUPPORTED_ENCODINGS, }; use crate::error::{IntoHttpResponse, TrustedServerError}; use crate::test_support::tests::create_test_settings; @@ -1835,4 +1910,258 @@ 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" + ); + } + + // --- 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" + ); + } + + #[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() { + 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 375fa5c5..6f14dfac 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -319,6 +319,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 { @@ -329,6 +340,40 @@ impl Default for Proxy { fn default() -> Self { Self { certificate_check: default_certificate_check(), + allowed_domains: Vec::new(), + } + } +} + +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. + /// 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 + .iter() + .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::info!( + "proxy.allowed_domains is empty: all redirect destinations are permitted (open mode)" + ); } } } @@ -375,6 +420,7 @@ impl Settings { message: "Failed to deserialize TOML configuration".to_string(), })?; + settings.proxy.normalize(); settings.consent.validate(); settings.prepare_runtime()?; settings.validate_admin_coverage()?; @@ -412,6 +458,7 @@ impl Settings { })?; settings.integrations.normalize(); + settings.proxy.normalize(); settings.consent.validate(); settings.validate().map_err(|err| { @@ -1524,6 +1571,135 @@ mod tests { ); } + // --- 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_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 { + 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" + ); + }, + ); + } + + // --- admin endpoint coverage --- + #[test] fn test_publisher_rejects_cookie_domain_with_metacharacters() { for bad_domain in [ diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 146d5e46..fb41eb8f 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.) | @@ -716,6 +717,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`, 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..27e0ac42 100644 --- a/docs/guide/first-party-proxy.md +++ b/docs/guide/first-party-proxy.md @@ -425,6 +425,52 @@ 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`. +- A bare `"*"` entry is **not** valid and will be removed at startup with a warning. Use an empty list for open mode. + +:::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). + +```toml +[proxy] +allowed_domains = [ + "*.your-ad-network.com", + "tracker.your-partner.com", +] +``` + +::: + ### URL Rewrite Exclusions Exclude specific domains from rewriting: @@ -521,7 +567,7 @@ Only essential headers are forwarded to reduce overhead: **Invalid Signature**: ``` -HTTP 403 Forbidden +HTTP 502 Bad Gateway tstoken validation failed: signature mismatch ``` @@ -534,7 +580,7 @@ tstoken validation failed: signature mismatch **Expired URL**: ``` -HTTP 403 Forbidden +HTTP 502 Bad Gateway tstoken expired ``` @@ -599,7 +645,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 diff --git a/trusted-server.toml b/trusted-server.toml index 4a4f3128..b5255d14 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -148,11 +148,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"]