Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8f34db7
Add domain allowlist to block SSRF via first-party proxy redirects
prk-Jr Mar 16, 2026
bd88f8d
Normalize proxy allowed_domains and harden redirect allowlist enforce…
prk-Jr Mar 17, 2026
53f251f
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 17, 2026
043db9d
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 18, 2026
0514c1e
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 18, 2026
d5227de
Document proxy.allowed_domains in proxy and configuration guides
prk-Jr Mar 18, 2026
84be147
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 18, 2026
c771d3b
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 19, 2026
320ab6c
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 20, 2026
47936e5
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 21, 2026
a04b0e9
Enforce proxy allowlist on initial target and redirect hops
prk-Jr Mar 21, 2026
fbb3b6d
Fix format ci failure
prk-Jr Mar 21, 2026
cbbadc6
Merge branch 'main' into harden/ssrf-proxy-allowlist
aram356 Mar 23, 2026
ef74501
Addressed pr findings
prk-Jr Mar 25, 2026
30d0d4a
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 25, 2026
e02d126
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 26, 2026
83ea1dc
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 30, 2026
bd416b0
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 30, 2026
8408a30
Resolve pr review findings
prk-Jr Mar 30, 2026
9d2d2b6
Merge branch 'main' into harden/ssrf-proxy-allowlist
prk-Jr Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
164 changes: 163 additions & 1 deletion crates/common/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -563,6 +584,25 @@ async fn proxy_with_redirects(
return finalize_response(settings, req, &current_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,
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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"
);
}
}
12 changes: 12 additions & 0 deletions crates/common/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

fn default_certificate_check() -> bool {
Expand All @@ -290,6 +301,7 @@ impl Default for Proxy {
fn default() -> Self {
Self {
certificate_check: default_certificate_check(),
allowed_domains: Vec::new(),
}
}
}
Expand Down
17 changes: 16 additions & 1 deletion trusted-server.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading