Add domain allowlist to block SSRF via first-party proxy redirects#510
Add domain allowlist to block SSRF via first-party proxy redirects#510
Conversation
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
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Nice hardening update overall — the redirect allowlist behavior and wildcard boundary checks look solid.
I found two follow-ups worth addressing:
-
Medium:
proxy.allowed_domainsentries are currently used as-is, so empty/whitespace patterns can make the allowlist non-empty but non-matchable, unexpectedly blocking redirects.- Evidence:
crates/common/src/proxy.rs:587,crates/common/src/proxy.rs:470,crates/common/src/settings.rs:292 - Recommendation: normalize at config load time (trim/lowercase/drop empties), and optionally validate pattern shape (
example.comor*.example.com).
- Evidence:
-
Low: tests cover
is_host_allowedlogic well, but don’t yet exercise full redirect follow enforcement end-to-end inproxy_with_redirects.- Evidence:
crates/common/src/proxy.rs:1907,crates/common/src/proxy.rs:484 - Recommendation: add one allowed + one blocked redirect-chain test through
proxy_request/redirect flow.
- Evidence:
Everything else looked good, and CI is green.
There was a problem hiding this comment.
LGTM — core SSRF hardening looks correct and CI is green.
One suggestion for follow-up: the new proxy.allowed_domains setting should be documented in the proxy and configuration guides (docs/guide/first-party-proxy.md and docs/guide/configuration.md) so operators are aware of this security control. Specifically, it would be helpful to add a [proxy] section documenting:
allowed_domainssemantics and wildcard matching rules- The default-open behavior when the setting is omitted
- A production recommendation to explicitly set the allowlist
aram356
left a comment
There was a problem hiding this comment.
Summary
Solid, well-structured security PR. The core is_host_allowed logic is correct and handles the important edge cases (dot-boundary bypass, case sensitivity, deep subdomains, empty hosts). The main issues are documentation inaccuracies that would confuse operators.
Blocking
🔧 wrench
- Docs claim 403 but code returns 502:
TrustedServerError::Proxymaps toStatusCode::BAD_GATEWAY(502). Multiple places infirst-party-proxy.mdandconfiguration.mdstate 403. Either fix the docs or add a dedicated error variant that returns 403 for allowlist violations. (docs/guide/configuration.md:764, docs/guide/first-party-proxy.md:441,565,578) - Docs claim
/first-party/clickenforces the allowlist:handle_first_party_clickreturns a 302 directly to the browser — it never callsproxy_with_redirectswhere the allowlist check lives. The click endpoint relies ontstokensignature verification, not the domain allowlist. Remove/first-party/clickfrom the allowlist docs or clarify the distinction. (docs/guide/configuration.md:764) first-party-proxy.mdlines 565 and 578 (outside the diff): the example error responses showHTTP 403 Forbiddenfor signature mismatch and token expiry, but the actual code returns 502 for allTrustedServerError::Proxyvariants.
❓ question
- Initial proxy target URL not checked against allowlist: The allowlist only validates redirect hops (proxy.rs:617), not the original
tsurl. A signed URL pointing directly tohttp://169.254.169.254/latest/meta-data/would be proxied without allowlist checking. Thetstokensignature prevents arbitrary URL crafting, but if an attacker controls a legitimate signed URL target, the allowlist won't help. Is this intentional, or should the initial target also be validated?
Non-blocking
🤔 thinking
- No startup warning for open mode: When
allowed_domainsis empty, all redirects are permitted. Alog::warn!innormalize()would help operators notice this. (crates/trusted-server-core/src/settings.rs:349)
🌱 seedling
- Newtype for
AllowedDomains: Per project conventions,Vec<String>could become anAllowedDomainsnewtype encapsulating normalization and matching logic. - Additional edge-case tests: IP literal targets, empty Location headers — all correctly handled by current logic but worth explicit tests to prevent regressions.
👍 praise
- Dot-boundary enforcement: The
strip_suffix+ends_with('.')approach correctly prevents the classicevil-example.combypass of*.example.com. Well-tested at line 1924. - Scheme check before allowlist: Non-http/https redirect schemes are silently finalized (proxy.rs:613-614), preventing
data:,javascript:,ftp:protocol abuse.
CI Status
- fmt: PASS
- clippy: PASS
- rust tests: PASS
- JS tests: Pre-existing failure (unrelated jsdom@28 issue on main)
prk-Jr
left a comment
There was a problem hiding this comment.
All blocking items and the open question have been addressed:
Blocking fixes
- Added
TrustedServerError::AllowlistViolation { host }→StatusCode::FORBIDDEN(403). The docs atconfiguration.md:764andfirst-party-proxy.md:441now correctly say 403 for allowlist violations. - Removed
/first-party/clickfrom the allowlist enforcement description inconfiguration.md. The click endpoint is protected by tstoken signature verification only and never callsproxy_with_redirects. - Corrected
first-party-proxy.mdlines 565, 578, and 643 to showHTTP 502 Bad Gatewayfor signature mismatch and token expiry (those still go throughTrustedServerError::Proxy→ 502).
Open question — initial target now checked
Decided to extend the allowlist check to the initial tsurl target as well. The check now runs at the top of the proxy_with_redirects loop for every URL (iteration 0 = initial target, subsequent iterations = redirect hops). Added proxy_initial_target_blocked_by_allowlist test to cover this.
Non-blocking
- Added
log::warn!insettings.rs normalize()whenallowed_domainsis empty (open mode warning). AllowedDomainsnewtype deferred to a follow-up to keep this PR focused.
CI: 744 tests passing.
aram356
left a comment
There was a problem hiding this comment.
Summary
Solid SSRF mitigation adding domain allowlist enforcement to proxy redirect chains. The core matching logic (dot-boundary, case-insensitive, wildcard subdomains) is correct and well-tested. Three issues need attention before merge.
Blocking
🔧 wrench
- Allowlist scope broader than documented: The check lives in
proxy_with_redirectswhich gates all proxy calls (including integration proxies like testlight/GTM/GPT), but docs say it only governs first-party proxy. Users following the docs will break integration proxies. (crates/trusted-server-core/src/proxy.rs:553) - Missing IP address bypass tests: No tests verify that IP-literal redirect targets (
169.254.169.254,[::1]) are blocked — the most important SSRF vectors. The logic handles it correctly, but a security feature needs explicit proof. (crates/trusted-server-core/src/proxy.rs:496)
❓ question
- Wildcard
"*"silently blocks everything:allowed_domains = ["*"]falls through to exact match (host == "*"), blocking all traffic — opposite of user intent. Shouldnormalize()reject this with a warning? (crates/trusted-server-core/src/proxy.rs:496)
Non-blocking
🤔 thinking
- Default "allow all" language: Consider stronger wording than "intentional for zero-config development" — this is a security feature where the default is permissive. (
docs/guide/first-party-proxy.md:451) - Unicode/IDN domains:
to_ascii_lowercase()doesn't normalize Punycode. Low risk in ad-tech but worth a doc note. (crates/trusted-server-core/src/proxy.rs:496)
🌱 seedling
- No integration test for redirect-hop blocking: Only the initial target is tested end-to-end; no test for Nth-hop blocking in a redirect chain. (
crates/trusted-server-core/src/proxy.rs)
⛏ nitpick
&[String]→&[impl AsRef<str>]: Minor flexibility improvement forredirect_is_permitted. (crates/trusted-server-core/src/proxy.rs:484)
📝 note
- Empty host on redirect: Returns
AllowlistViolationinstead of the more descriptive "missing host" error. Not a bug — less informative for debugging. (crates/trusted-server-core/src/proxy.rs:639)
CI Status
- fmt: PASS
- clippy: PASS
- rust tests: PASS
- js tests: PASS
Summary
proxy.allowed_domains— an opt-in list of permitted redirect-target hostnames supporting exact match ("example.com") and subdomain wildcard ("*.example.com") with dot-boundary enforcement to preventevil-example.combypass.Changes
crates/common/src/proxy.rsis_host_allowedhelper; enforce allowlist on each redirect hop inproxy_with_redirects; 13 unit testscrates/common/src/settings.rsallowed_domains: Vec<String>field toProxystruct with serde defaulttrusted-server.toml[proxy]section; addallowed_domainskey with commented vendor examples.env.devTRUSTED_SERVER__PROXY__ALLOWED_DOMAINSenv var overrideCloses
Closes #414
Test plan
cargo test --workspacecargo clippy --all-targets --all-features -- -D warningscargo fmt --all -- --checkcd crates/js/lib && npx vitest runcd crates/js/lib && npm run formatcargo build --bin trusted-server-fastly --release --target wasm32-wasip1fastly compute serveChecklist
unwrap()in production code — useexpect("should ...")logmacros (notprintln!)