Skip to content
Open
8 changes: 8 additions & 0 deletions .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -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'
218 changes: 216 additions & 2 deletions crates/common/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,35 @@ 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`.
/// - `"*.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,
Expand Down Expand Up @@ -584,6 +613,17 @@ async fn proxy_with_redirects(
return finalize_response(settings, req, &current_url, beresp, stream_passthrough);
}

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!(
"following redirect {} => {} (status {})",
current_url,
Expand Down Expand Up @@ -1108,8 +1148,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;
Expand Down Expand Up @@ -1834,4 +1875,177 @@ 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<String> = 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"
);
}
}
Loading
Loading