Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
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'
218 changes: 216 additions & 2 deletions crates/trusted-server-core/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).
Comment thread
prk-Jr marked this conversation as resolved.
/// 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 {
Comment thread
prk-Jr marked this conversation as resolved.
Outdated
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"`.
Comment thread
prk-Jr marked this conversation as resolved.
Comment thread
prk-Jr marked this conversation as resolved.
fn is_host_allowed(host: &str, pattern: &str) -> bool {
Comment thread
prk-Jr marked this conversation as resolved.
let host = host.to_ascii_lowercase();
let pattern = pattern.to_ascii_lowercase();

if let Some(suffix) = pattern.strip_prefix("*.") {
Comment thread
prk-Jr marked this conversation as resolved.
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,
Comment thread
prk-Jr marked this conversation as resolved.
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 ---
Comment thread
prk-Jr marked this conversation as resolved.

#[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"
);
}
}
Comment thread
prk-Jr marked this conversation as resolved.
Loading
Loading