From e2f1f6364bd67e75a1614527b11d50ed38521dfc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 21:08:06 +0530 Subject: [PATCH 01/23] Document trusted client IP header design --- ...6-08-19-trusted-client-ip-header-design.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md diff --git a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md new file mode 100644 index 000000000..3dce9242c --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md @@ -0,0 +1,173 @@ +# Trusted Client IP Header Design + +## Summary + +Trusted Server's Fastly adapter currently treats +`fastly::Request::get_client_ip_addr()` as the reader address. On a direct +request that is correct, but on a request chained through another Fastly +service it is the fronting edge node. Geo lookup, EC generation, cluster +classification, consent jurisdiction, auction device IPs, and downstream +integration forwarding then all consume the edge-node address. + +Fastly preserves the original address in `Fastly-Client-IP`, but the header is +caller-controlled at the public edge unless the fronting service overwrites it. +Trusted Server must therefore not trust that header based on presence alone. + +This change adds an optional authenticated client-IP header. Existing +deployments remain unchanged until an operator explicitly configures both the +forwarded-IP and authentication headers with a shared secret. + +Related issues: #1040 and #1041. + +## Goals + +- Allow a CDN-fronted Fastly deployment to supply the reader's IP address. +- Establish an explicit trust boundary before using a forwarded address. +- Use one resolved address consistently for geo and all `ClientInfo` consumers. +- Preserve current peer-IP behavior when the feature is absent or authentication + fails. +- Remove trust headers before routing so they cannot leak downstream or be + interpreted by unrelated code. + +## Non-goals + +- Automatically infer whether a request came from another Fastly service. +- Trust `Fastly-Client-IP`, `Fastly-FF`, or `X-Forwarded-For` by presence. +- Change client-IP handling in Cloudflare, Spin, or Axum adapters. +- Add rotating or time-limited request signatures in this change. +- Make Fastly no-code request routing preserve a value it does not expose. + +## Configuration Contract + +Add an optional top-level section: + +```toml +[trusted_client_ip] +ip_header = "fastly-client-ip" +auth_header = "x-ts-client-ip-auth" +shared_secret = "replace-with-a-random-shared-secret" +``` + +All three fields are required when the section is present. `shared_secret` uses +the existing `Redacted` type so debug representations do not disclose +it. Configuration validation rejects empty secrets, invalid header names, +identical header names, and header names that cannot safely be consumed and +removed at request entry. + +The section is absent by default. Because the runtime configuration uses strict +unknown-key validation, the example and configuration reference must document +the exact field names. + +## Request Processing + +The Fastly entry point resolves the client address after application state has +loaded but before spoofable headers are sanitized: + +1. Capture `req.get_client_ip_addr()` as the fallback peer address. +2. If `trusted_client_ip` is absent, select the peer address. +3. If configured, read the configured authentication header and compare it with + the configured secret using fixed-size SHA-256 digests and a constant-time + comparison. +4. Only after authentication succeeds, parse the configured IP header as a + single IPv4 or IPv6 address. +5. Select the parsed forwarded address on success. Missing headers, a wrong + secret, a malformed header value, or a non-IP value all select the peer + address without rejecting the request. +6. Remove the configured IP and authentication headers, then run the existing + forwarded-header sanitizer. +7. Pass the selected address into `client_info_from_request` and use the same + value for entry-point geo response finalization. + +`Fastly-Client-IP` is added to the static spoofable-header list. This ensures it +is stripped even when the feature is not configured. A configured header is +read before sanitization and removed explicitly, so configuring +`fastly-client-ip` remains valid. + +Authentication failures do not log supplied secrets or IP values. A debug-level +message may record only the reason category (missing authentication, mismatch, +or invalid IP) and that the peer fallback was used. + +## Component Boundaries + +### Core settings + +`trusted-server-core/src/settings.rs` owns the serializable +`TrustedClientIpConfig`, validation, redaction, and constant-time secret +verification. Keeping the security rule with the configuration type prevents +adapter call sites from comparing variable-length secrets directly. + +### Fastly client-IP resolution + +`trusted-server-adapter-fastly/src/platform.rs` owns a small resolver that reads +Fastly request headers and returns either the authenticated forwarded IP or the +SDK peer IP. `client_info_from_request` accepts the already-resolved address so +it cannot accidentally re-read the immediate peer. + +### Fastly entry point and sanitization + +`trusted-server-adapter-fastly/src/main.rs` invokes the resolver once and shares +its result with request services and response geo finalization. +`trusted-server-adapter-fastly/src/compat.rs` removes the configured dynamic +headers and continues applying the static spoofable-header list. + +No core EC, consent, auction, or integration logic changes: those consumers +already use `RuntimeServices::client_info().client_ip` correctly. + +## Security Model + +The fronting CDN must overwrite both configured headers on every request sent to +Trusted Server. It must never preserve caller-provided values. The shared secret +must be generated randomly, stored in both the fronting service and Trusted +Server configuration, and excluded from responses and origin requests. + +This design protects against callers that can reach the Trusted Server hostname +and inject an arbitrary IP header, provided they do not know the shared secret. +It does not provide replay protection: any party that learns the static secret +can authenticate arbitrary IP values. A timestamped HMAC would address replay +and secret reuse but is outside #1041's requested scope. + +Direct requests remain supported. Without valid trust headers they use the +direct peer address, which is the reader address on a one-hop request. + +## Testing + +Tests follow red-green-refactor and cover: + +- absent configuration uses the peer IP; +- valid authentication plus an IPv4 header selects the forwarded IP; +- valid authentication plus an IPv6 header selects the forwarded IP; +- missing or incorrect authentication falls back to the peer IP; +- malformed or multi-valued IP input falls back to the peer IP; +- configured headers are removed after resolution; +- `Fastly-Client-IP` is stripped when configuration is absent; +- settings parse, validation, secret redaction, and default behavior; +- `client_info_from_request` and entry-point geo finalization receive the same + selected address. + +Targeted Fastly and core tests run after each change. Final verification uses +the repository's Fastly/core test alias, formatting, and target-matched Clippy. +The existing local Viceroy certificate-keychain failure may require running the +Fastly integration suite in an environment with native certificates available; +native unit tests and Wasm compilation still provide local evidence. + +## Documentation + +- Add a commented `[trusted_client_ip]` example to + `trusted-server.example.toml` using only `example.com`-safe material. +- Add the new section to `docs/guide/configuration.md`. +- Add Fastly front-door setup guidance to `docs/guide/fastly.md`, emphasizing + that both headers must be overwritten and that enabling the reader without a + correctly configured front door creates a spoofing vulnerability. +- Document the no-code request-routing limitation from #1041. + +## Acceptance Criteria + +- Configuration absent: runtime behavior remains peer-IP based and + `Fastly-Client-IP` is stripped. +- Valid configured authentication: geo and every `ClientInfo` consumer use the + forwarded reader IP. +- Missing, wrong, or malformed authentication: the request succeeds using the + peer IP. +- Invalid forwarded IP: the request succeeds using the peer IP. +- Trust headers never reach routing or downstream origins. +- Existing direct Fastly deployments require no configuration migration. From d94072376cb1adc9c1e126703b2753e39ddf6bea Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 21:10:12 +0530 Subject: [PATCH 02/23] Clarify trusted header validation --- ...6-08-19-trusted-client-ip-header-design.md | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md index 3dce9242c..3a5af1ea6 100644 --- a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md +++ b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md @@ -50,9 +50,19 @@ shared_secret = "replace-with-a-random-shared-secret" All three fields are required when the section is present. `shared_secret` uses the existing `Redacted` type so debug representations do not disclose -it. Configuration validation rejects empty secrets, invalid header names, -identical header names, and header names that cannot safely be consumed and -removed at request entry. +it. Configuration validation rejects empty secrets, invalid header names, and +identical header names. + +To ensure request entry can remove the fields without deleting a required HTTP +field, `ip_header` must either be `fastly-client-ip` or begin with `x-`, and +`auth_header` must begin with `x-`. Comparison is case-insensitive after parsing +through `http::HeaderName`. The two Fastly-injected TLS bridge fields +(`x-ts-tls-protocol` and `x-ts-tls-cipher`) are forbidden for either setting +because the entry point owns and re-injects them after sanitization. These rules +exclude framing, routing, representation, cookie, and authorization fields such +as `host`, `content-length`, `accept`, `cookie`, and `authorization`. A fronting +CDN whose native client-IP field does not meet this contract must copy it into a +dedicated `x-` field before forwarding. The section is absent by default. Because the runtime configuration uses strict unknown-key validation, the example and configuration reference must document @@ -65,11 +75,16 @@ loaded but before spoofable headers are sanitized: 1. Capture `req.get_client_ip_addr()` as the fallback peer address. 2. If `trusted_client_ip` is absent, select the peer address. -3. If configured, read the configured authentication header and compare it with - the configured secret using fixed-size SHA-256 digests and a constant-time - comparison. -4. Only after authentication succeeds, parse the configured IP header as a - single IPv4 or IPv6 address. +3. If configured, require exactly one authentication-header field value. It + must be valid UTF-8 and match the configured secret byte-for-byte, without + trimming or other normalization. Compare fixed-size SHA-256 digests using a + constant-time comparison. A missing, duplicated, non-UTF-8, empty, or + mismatched authentication value fails authentication. +4. Only after authentication succeeds, require exactly one IP-header field + value and parse it directly as `std::net::IpAddr`. Do not trim or normalize + the value. This accepts canonical or otherwise Rust-supported IPv4 and IPv6 + spellings but rejects whitespace, ports, IPv6 zone identifiers, + comma-separated lists, empty values, non-UTF-8 bytes, and duplicate fields. 5. Select the parsed forwarded address on success. Missing headers, a wrong secret, a malformed header value, or a non-IP value all select the peer address without rejecting the request. @@ -136,8 +151,10 @@ Tests follow red-green-refactor and cover: - absent configuration uses the peer IP; - valid authentication plus an IPv4 header selects the forwarded IP; - valid authentication plus an IPv6 header selects the forwarded IP; -- missing or incorrect authentication falls back to the peer IP; -- malformed or multi-valued IP input falls back to the peer IP; +- missing, empty, incorrect, non-UTF-8, or duplicate authentication falls back + to the peer IP; +- whitespace-padded, port-bearing, zone-qualified, comma-separated, non-UTF-8, + empty, or duplicate IP input falls back to the peer IP; - configured headers are removed after resolution; - `Fastly-Client-IP` is stripped when configuration is absent; - settings parse, validation, secret redaction, and default behavior; From 932b9f814ef6e35bdcf9ab3c0e891eb685ebb3ba Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 21:19:37 +0530 Subject: [PATCH 03/23] Plan trusted client IP implementation --- .../2026-08-19-trusted-client-ip-header.md | 655 ++++++++++++++++++ 1 file changed, 655 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md diff --git a/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md new file mode 100644 index 000000000..a7bd3342d --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md @@ -0,0 +1,655 @@ +# Trusted Client IP Header Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve the reader IP behind an authenticated fronting CDN while preserving peer-IP fallback and stripping all trust headers before routing. + +**Architecture:** Add an optional validated `TrustedClientIpConfig` to core settings, including fixed-size constant-time shared-secret verification. The Fastly adapter resolves the address exactly once from the original request, removes both static and configured spoofable headers, and shares the selected address with `ClientInfo` and response geo finalization. + +**Tech Stack:** Rust 2024, Serde/TOML, validator, `sha2`, `subtle`, Fastly Compute SDK, Viceroy, Markdown/VitePress documentation. + +--- + +## File Map + +| File | Responsibility | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/settings.rs` | Define, deserialize, validate, redact, and authenticate the optional trusted-client-IP configuration. | +| `crates/trusted-server-core/src/http_util.rs` | Treat `Fastly-Client-IP` as spoofable unless it was consumed before sanitization. | +| `crates/trusted-server-adapter-fastly/src/platform.rs` | Resolve one authenticated header value or fall back to the SDK peer IP; construct `ClientInfo` with the resolved value. | +| `crates/trusted-server-adapter-fastly/src/compat.rs` | Remove configured trust headers and the static spoofable-header set from the Fastly request. | +| `crates/trusted-server-adapter-fastly/src/main.rs` | Resolve once before sanitization and feed the same IP into request services and geo finalization. | +| `trusted-server.example.toml` | Show the optional configuration with fictional values. | +| `docs/guide/configuration.md` | Document fields, validation, fallback, and environment overrides. | +| `docs/guide/fastly.md` | Document the front-door overwrite requirement and no-code routing limitation. | + +### Task 1: Add the validated trusted-client-IP configuration + +**Files:** + +- Modify: `crates/trusted-server-core/src/settings.rs:1-20` +- Modify: `crates/trusted-server-core/src/settings.rs:1871-1950` +- Test: `crates/trusted-server-core/src/settings.rs:2590-end` + +- [ ] **Step 1: Write parsing and default-behavior tests** + +Add tests beside the existing settings tests. Build TOML from +`crate_test_settings_str()` and append: + +```rust +#[test] +fn trusted_client_ip_is_absent_by_default() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without trusted client IP"); + + assert!( + settings.trusted_client_ip.is_none(), + "should leave trusted client IP disabled by default" + ); +} + +#[test] +fn trusted_client_ip_parses_and_redacts_secret() { + let toml = format!( + "{}\n[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nauth_header = \"x-ts-client-ip-auth\"\nshared_secret = \"unit-test-shared-secret\"\n", + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml) + .expect("should parse trusted client IP settings"); + let config = settings + .trusted_client_ip + .as_ref() + .expect("should contain trusted client IP settings"); + + assert_eq!(config.ip_header, "fastly-client-ip"); + assert_eq!(config.auth_header, "x-ts-client-ip-auth"); + assert_eq!( + format!("{:?}", config.shared_secret), + "[REDACTED]", + "should redact shared secret" + ); +} +``` + +- [ ] **Step 2: Run the new tests and verify RED** + +Run: + +```bash +cargo test --package trusted-server-core --target aarch64-apple-darwin trusted_client_ip -- --nocapture +``` + +Expected: compilation fails because `Settings::trusted_client_ip` and +`TrustedClientIpConfig` do not exist. + +- [ ] **Step 3: Add the minimum serializable settings shape** + +Add the imports needed for fixed-size digest comparison: + +```rust +use sha2::{Digest as _, Sha256}; +use subtle::ConstantTimeEq as _; +``` + +Define the configuration immediately before `Settings`. Do not add the schema +validator attribute until Step 6, so the intermediate RED build remains valid: + +```rust +/// Authenticated client-IP forwarding configuration for a trusted front door. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct TrustedClientIpConfig { + /// Request header containing exactly one forwarded IPv4 or IPv6 address. + pub ip_header: String, + /// Request header containing the shared authentication secret. + pub auth_header: String, + /// Shared secret overwritten by the trusted front door on every request. + #[validate(custom(function = validate_redacted_not_empty))] + pub shared_secret: Redacted, +} + +impl TrustedClientIpConfig { + /// Compares a request authentication value with the configured secret. + #[must_use] + pub fn authenticates(&self, candidate: &str) -> bool { + let expected = Sha256::digest(self.shared_secret.expose().as_bytes()); + let actual = Sha256::digest(candidate.as_bytes()); + bool::from(expected.ct_eq(&actual)) + } +} +``` + +Add the optional nested field to `Settings`: + +```rust +/// Optional authenticated client-IP forwarding configuration. +#[serde(default)] +#[validate(nested)] +pub trusted_client_ip: Option, +``` + +- [ ] **Step 4: Add fail-closed configuration tests** + +Use a small helper that appends a `[trusted_client_ip]` block to the standard +test TOML. Add individual tests proving that parsing/validation rejects: + +```rust +#[test] +fn trusted_client_ip_rejects_identical_headers() { /* same name */ } + +#[test] +fn trusted_client_ip_rejects_case_insensitive_identical_headers() { + /* x-client-ip and X-Client-IP */ +} + +#[test] +fn trusted_client_ip_rejects_unsafe_ip_header() { /* ip_header = "host" */ } + +#[test] +fn trusted_client_ip_rejects_unsafe_auth_header() { /* auth_header = "authorization" */ } + +#[test] +fn trusted_client_ip_rejects_fastly_tls_bridge_headers() { + // Exercise x-ts-tls-protocol and x-ts-tls-cipher in both positions. +} + +#[test] +fn trusted_client_ip_rejects_empty_secret() { /* shared_secret = "" */ } + +#[test] +fn trusted_client_ip_rejects_malformed_header_names() { /* spaces/control bytes */ } + +#[test] +fn trusted_client_ip_rejects_incomplete_section() { /* omit each required field */ } + +#[test] +fn trusted_client_ip_authentication_is_exact() { + let config = trusted_client_ip_test_config(); + assert!(config.authenticates("unit-test-shared-secret")); + assert!(!config.authenticates("wrong-secret")); + assert!(!config.authenticates(" unit-test-shared-secret")); +} +``` + +Each rejection assertion must check that `Settings::from_toml` returns an error, +not merely that `validate()` fails on a manually constructed value. + +- [ ] **Step 5: Run the validation tests and verify RED** + +Run the same filtered core test command. Expected: parsing tests pass, while the +unsafe/identical-header tests fail because cross-field validation is not yet +implemented. + +- [ ] **Step 6: Implement header-name validation** + +Add `#[validate(schema(function = "validate_trusted_client_ip_config"))]` to +`TrustedClientIpConfig`, then add helpers that parse names through +`http::HeaderName`, compare normalized lowercase names, and enforce the spec: + +```rust +fn validate_trusted_client_ip_config( + config: &TrustedClientIpConfig, +) -> Result<(), ValidationError> { + let ip_header = http::HeaderName::from_bytes(config.ip_header.as_bytes()) + .map_err(|_| ValidationError::new("invalid_trusted_client_ip_header"))?; + let auth_header = http::HeaderName::from_bytes(config.auth_header.as_bytes()) + .map_err(|_| ValidationError::new("invalid_trusted_client_ip_auth_header"))?; + + if ip_header == auth_header { + return Err(ValidationError::new("duplicate_trusted_client_ip_headers")); + } + if ip_header.as_str() != "fastly-client-ip" && !ip_header.as_str().starts_with("x-") { + return Err(ValidationError::new("unsafe_trusted_client_ip_header")); + } + if !auth_header.as_str().starts_with("x-") { + return Err(ValidationError::new("unsafe_trusted_client_ip_auth_header")); + } + for reserved in ["x-ts-tls-protocol", "x-ts-tls-cipher"] { + if ip_header.as_str() == reserved || auth_header.as_str() == reserved { + return Err(ValidationError::new("reserved_trusted_client_ip_header")); + } + } + Ok(()) +} +``` + +Use descriptive validation messages if the validator API allows them without +duplicating logic. Do not include `shared_secret` in errors. + +- [ ] **Step 7: Run the filtered core tests and verify GREEN** + +Run the filtered command from Step 2. Expected: all `trusted_client_ip` tests +pass with no warnings. + +- [ ] **Step 8: Run the complete native core suite** + +Run: + +```bash +cargo test --package trusted-server-core --target aarch64-apple-darwin +``` + +Expected: PASS. + +- [ ] **Step 9: Commit the settings contract** + +```bash +git add crates/trusted-server-core/src/settings.rs +git commit -m "Add trusted client IP configuration" +``` + +### Task 2: Resolve and sanitize the authenticated Fastly client IP + +**Files:** + +- Modify: `crates/trusted-server-core/src/http_util.rs:35-65` +- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs:690-730` +- Modify: `crates/trusted-server-adapter-fastly/src/compat.rs:50-105` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs:115-165` +- Test: `crates/trusted-server-adapter-fastly/src/platform.rs:730-end` +- Test: `crates/trusted-server-adapter-fastly/src/compat.rs:65-110` + +- [ ] **Step 1: Write resolver tests for the desired behavior** + +In `platform.rs`, add a test helper returning a `TrustedClientIpConfig` with +fictional headers/secrets, plus focused tests for: + +```rust +#[test] +fn resolve_client_ip_uses_peer_without_config() { /* None config */ } + +#[test] +fn resolve_client_ip_accepts_authenticated_ipv4() { /* 198.51.100.7 */ } + +#[test] +fn resolve_client_ip_accepts_authenticated_ipv6() { /* 2001:db8::7 */ } + +#[test] +fn resolve_client_ip_falls_back_for_missing_authentication() { /* no auth */ } + +#[test] +fn resolve_client_ip_falls_back_for_empty_authentication() { /* auth = "" */ } + +#[test] +fn resolve_client_ip_falls_back_for_wrong_authentication() { /* wrong auth */ } + +#[test] +fn resolve_client_ip_falls_back_for_duplicate_authentication() { + // append_header twice; do not use set_header +} + +#[test] +fn resolve_client_ip_falls_back_for_missing_ip_header() { /* valid auth only */ } + +#[test] +fn resolve_client_ip_falls_back_for_invalid_ip_forms() { + // Separate assertions for whitespace, port, IPv6 zone, comma list, empty. +} + +#[test] +fn resolve_client_ip_falls_back_for_duplicate_ip_headers() { + // append_header twice. +} +``` + +Pass a documentation address such as `203.0.113.9` as the explicit peer value +so unit tests do not depend on SDK connection metadata. Build non-UTF-8 cases +with `fastly::http::HeaderValue::from_bytes` and test both auth and IP fields. + +- [ ] **Step 2: Run the Fastly resolver tests and verify RED** + +Run: + +```bash +cargo test-fastly resolve_client_ip -- --nocapture +``` + +Expected: compilation fails because `resolve_client_ip` does not exist. If +Viceroy again fails before executing tests because the macOS native certificate +keychain is unavailable, record that environmental blocker and still require +the compile phase to succeed after implementation. + +- [ ] **Step 3: Implement a single-value reader and resolver** + +Import `TrustedClientIpConfig` and add: + +```rust +fn single_header_str<'a>(req: &'a Request, name: &str) -> Option<&'a str> { + let mut values = req.get_header_all(name); + let value = values.next()?; + if values.next().is_some() { + return None; + } + value.to_str().ok() +} + +/// Selects an authenticated forwarded address or the immediate peer fallback. +#[must_use] +pub(crate) fn resolve_client_ip( + req: &Request, + peer_ip: Option, + config: Option<&TrustedClientIpConfig>, +) -> Option { + let Some(config) = config else { + return peer_ip; + }; + let Some(auth) = single_header_str(req, &config.auth_header) else { + return peer_ip; + }; + if !config.authenticates(auth) { + return peer_ip; + } + single_header_str(req, &config.ip_header) + .and_then(|value| value.parse::().ok()) + .or(peer_ip) +} +``` + +Keep failures non-fatal and do not log header values. If debug logs are added, +log only a fixed reason category. + +- [ ] **Step 4: Pass the resolved value into `ClientInfo`** + +Change the constructor signature and assignment: + +```rust +pub fn client_info_from_request(req: &Request, client_ip: Option) -> ClientInfo { + ClientInfo { + client_ip, + // existing TLS/JA4/server fields unchanged + } +} +``` + +Add a direct unit test proving a supplied address is preserved even though a +synthetic Fastly request has no client connection metadata. This is the +regression test for shared geo/`ClientInfo` wiring: Step 9 derives the geo input +back from this constructed `ClientInfo`, rather than retaining an independent +parallel value. + +In `main.rs`, update the existing call immediately to pass the already-captured +peer value: + +```rust +let client_info = client_info_from_request(&req, client_ip); +``` + +This is a compile-preserving signature migration only; behavior is still the +old peer-IP behavior until Step 9 wires the resolver. + +- [ ] **Step 5: Write the static sanitization test and verify RED** + +Extend `compat.rs` tests using the function's current one-argument signature: + +```rust +#[test] +fn sanitize_fastly_forwarded_headers_strips_fastly_client_ip_without_config() { + // Set Fastly-Client-IP, sanitize, assert absent. +} +``` + +Run: + +```bash +cargo test-fastly sanitize_fastly_forwarded_headers -- --nocapture +``` + +Expected: the new test fails because `Fastly-Client-IP` is still preserved. + +- [ ] **Step 6: Strip the static header and migrate the helper signature** + +Add `"fastly-client-ip"` to `SPOOFABLE_FORWARDED_HEADERS`. Change the Fastly +compatibility helper to accept an intentionally unused +`Option<&TrustedClientIpConfig>` while preserving its current static loop: + +Import `trusted_server_core::settings::TrustedClientIpConfig` in `compat.rs`. + +```rust +pub(crate) fn sanitize_fastly_forwarded_headers( + req: &mut fastly::Request, + _config: Option<&TrustedClientIpConfig>, +) { + // Existing static loop unchanged. +} +``` + +Update existing compat test calls and the entry-point call to pass `None`. Run +the Step 5 command again. Expected: PASS (or the documented Viceroy environment +failure after successful compilation). This leaves the code green before the +next behavior test. + +- [ ] **Step 7: Write configured sanitization tests and verify RED** + +Now that the two-argument API compiles, add: + +```rust +#[test] +fn sanitize_fastly_forwarded_headers_strips_configured_trust_headers() { + // Set custom IP and auth fields, sanitize with Some(config), assert absent. +} +``` + +Also cover a configuration whose `ip_header` is `fastly-client-ip` to prove +double removal is harmless. Run the focused sanitization command. Expected: the +custom-header test fails because the config argument is not yet consumed. + +- [ ] **Step 8: Implement configured sanitization and verify GREEN** + +Remove the configured IP/auth headers first, then loop over the static list. +Never read or log their values. Run the focused sanitization command again. +Expected: PASS (or the documented post-compilation Viceroy environment failure). + +- [ ] **Step 9: Wire one resolved address through the entry point** + +In `main.rs`, after `settings_snapshot` is created and before sanitization: + +Add `resolve_client_ip` to the existing `crate::platform` import. + +```rust +let trusted_client_ip = settings_snapshot + .as_deref() + .and_then(|settings| settings.trusted_client_ip.as_ref()); +let resolved_client_ip = resolve_client_ip( + &req, + req.get_client_ip_addr(), + trusted_client_ip, +); +compat::sanitize_fastly_forwarded_headers(&mut req, trusted_client_ip); +``` + +Remove the later direct `req.get_client_ip_addr()` capture. Pass +`resolved_client_ip` to `client_info_from_request`, then derive the geo/finalize +input from that object: + +```rust +let client_info = client_info_from_request(&req, resolved_client_ip); +let client_ip = client_info.client_ip; +``` + +Leave both calls to `apply_entry_point_finalize_headers(..., client_ip)` +unchanged. This creates one stored source of truth: the address inserted into +request services and the address passed to response geo are both read from the +same `ClientInfo` construction. The `client_info_from_request` preservation test +from Step 4 proves that selected addresses cross this boundary unchanged. + +The startup-error path has no settings snapshot, so it passes `None`, uses the +peer address, and still strips `Fastly-Client-IP` through the static list. + +- [ ] **Step 10: Run focused tests and verify GREEN** + +Run: + +```bash +cargo test-fastly resolve_client_ip -- --nocapture +cargo test-fastly sanitize_fastly_forwarded_headers -- --nocapture +``` + +Expected: PASS when Viceroy can run. On the known keychain failure, require both +commands to compile the Fastly Wasm test binary successfully before the same +external Viceroy startup error. + +- [ ] **Step 11: Verify Fastly compilation** + +Run: + +```bash +cargo check-fastly +``` + +Expected: PASS with no warnings. + +- [ ] **Step 12: Commit the Fastly behavior** + +```bash +git add \ + crates/trusted-server-core/src/http_util.rs \ + crates/trusted-server-adapter-fastly/src/platform.rs \ + crates/trusted-server-adapter-fastly/src/compat.rs \ + crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Resolve authenticated forwarded client IP" +``` + +### Task 3: Document secure front-door configuration + +**Files:** + +- Modify: `trusted-server.example.toml:12-20` +- Modify: `docs/guide/configuration.md:50-90` +- Modify: `docs/guide/fastly.md:50-135` + +- [ ] **Step 1: Add the commented example configuration** + +Add after `[publisher]` in `trusted-server.example.toml`: + +```toml +# Optional: trust a fronting CDN's reader IP only when it also supplies the +# matching shared secret. The front door must overwrite both headers. +# [trusted_client_ip] +# ip_header = "fastly-client-ip" +# auth_header = "x-ts-client-ip-auth" +# shared_secret = "replace-with-a-random-shared-secret" +``` + +- [ ] **Step 2: Add the configuration reference** + +Add `[trusted_client_ip]` to the key-sections table and document: + +- all three fields and their required status when the section exists; +- absence preserving peer-IP behavior; +- exact-one-value parsing and fail-to-peer behavior; +- safe header-name restrictions; +- `TRUSTED_SERVER__TRUSTED_CLIENT_IP__*` override names; +- redaction and random-secret requirements. + +Use only fictional `example.com` domains and documentation IP ranges. + +- [ ] **Step 3: Add Fastly front-door instructions** + +Add a "CDN-fronted client IP" section explaining: + +1. The public front door must overwrite the configured IP and auth headers on + every backend request; preserving browser-supplied values is unsafe. +2. For VCL/Fastly chaining, overwrite `Fastly-Client-IP` from the initial + `client.ip` and set the authentication header to the shared secret. +3. Configure the identical header names and secret in Trusted Server. +4. Direct or unauthenticated requests fall back to the immediate peer. +5. Fastly no-code request routing has no header-injection point; if it does not + preserve the reader IP, this mechanism cannot recover it. + +Link to Fastly's official `client.ip`, `Fastly-Client-IP`, and service-chaining +documentation. Do not include a deployable real secret. + +- [ ] **Step 4: Format and inspect documentation** + +Run: + +```bash +(cd docs && npm run format) +git diff --check +git diff -- trusted-server.example.toml docs/guide/configuration.md docs/guide/fastly.md +``` + +Expected: formatting succeeds; only the intended example and guide sections +change; no whitespace errors. + +- [ ] **Step 5: Commit documentation** + +```bash +git add trusted-server.example.toml docs/guide/configuration.md docs/guide/fastly.md +git commit -m "Document trusted client IP forwarding" +``` + +### Task 4: Run final verification and review + +**Files:** + +- Verify: all files changed by Tasks 1-3 + +- [ ] **Step 1: Run formatting checks** + +```bash +cargo fmt --all -- --check +(cd docs && npm run format) +``` + +Expected: PASS with no further diffs after formatting. + +- [ ] **Step 2: Run target-matched tests** + +```bash +cargo test --package trusted-server-core --target aarch64-apple-darwin +cargo test-fastly +``` + +Expected: all core and Fastly tests pass. If Viceroy remains blocked by the +native certificate keychain, preserve the complete error output and separately +confirm `cargo check-fastly` succeeds; do not report `cargo test-fastly` as +passing. + +- [ ] **Step 3: Run target-matched compilation and linting** + +```bash +cargo check-fastly +cargo clippy-fastly +``` + +Expected: PASS with warnings denied by the repository alias. + +- [ ] **Step 4: Run unaffected-adapter regression tests required by CI** + +Because `Settings` and the shared spoofable-header list are in core, run: + +```bash +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +Expected: PASS. + +- [ ] **Step 5: Review the final diff for security invariants** + +Confirm from the diff that: + +- no code trusts a forwarded IP without successful authentication; +- duplicate or non-UTF-8 fields cannot be accepted through a first-value API; +- no log or error formats `shared_secret` or request header values; +- both configured trust fields are removed before conversion/routing; +- `ClientInfo` and response geo receive the same resolved address; +- configuration absence preserves peer-IP behavior. + +- [ ] **Step 6: Commit any verification-only corrections** + +If verification required code changes, repeat the narrow failing test first, +then commit only the correction with an imperative sentence-case message. If no +changes were needed, do not create an empty commit. + +- [ ] **Step 7: Request final code review** + +Use `superpowers:requesting-code-review` against the branch diff from `main` and +address any correctness or security findings before handoff. From 209bea230163b0e200ab3736e2e359d11a56e10e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 21:22:25 +0530 Subject: [PATCH 04/23] Ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8ff935162..c00ec086d 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ src/*.html /guest-profiles /benchmark-results/** +/.worktrees/ # Playwright browser tests /crates/trusted-server-integration-tests/browser/node_modules/ From 3d542fffcf25d26d17697bcbb18a2392c54cd1d2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 21:31:20 +0530 Subject: [PATCH 05/23] Add trusted client IP configuration --- crates/trusted-server-core/src/settings.rs | 258 +++++++++++++++++++++ 1 file changed, 258 insertions(+) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 598c00056..c7708d0ba 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4,10 +4,12 @@ use error_stack::{Report, ResultExt}; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned}; use serde_json::Value as JsonValue; +use sha2::{Digest as _, Sha256}; use std::collections::{HashMap, HashSet}; use std::ops::{Deref, DerefMut}; use std::str::FromStr; use std::sync::OnceLock; +use subtle::ConstantTimeEq as _; use url::Url; use validator::{Validate, ValidationError}; @@ -1911,6 +1913,57 @@ pub struct TesterCookieConfig { pub enabled: bool, } +/// Authenticated forwarding configuration for a trusted client IP header. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +#[validate(schema(function = validate_trusted_client_ip))] +pub struct TrustedClientIpConfig { + /// Header containing the client IP address supplied by the trusted edge. + pub ip_header: String, + /// Header containing the shared-secret authentication value. + pub auth_header: String, + /// Shared secret required before accepting the forwarded client IP address. + #[validate(custom(function = validate_redacted_not_empty))] + pub shared_secret: Redacted, +} + +impl TrustedClientIpConfig { + /// Returns whether `candidate` exactly matches the configured shared secret. + #[must_use] + pub fn authenticates(&self, candidate: &str) -> bool { + let configured_digest = Sha256::digest(self.shared_secret.expose().as_bytes()); + let candidate_digest = Sha256::digest(candidate.as_bytes()); + + configured_digest.ct_eq(&candidate_digest).into() + } +} + +fn validate_trusted_client_ip(config: &TrustedClientIpConfig) -> Result<(), ValidationError> { + let ip_header = http::HeaderName::from_bytes(config.ip_header.as_bytes()) + .map_err(|_| ValidationError::new("invalid_trusted_client_ip_header"))?; + let auth_header = http::HeaderName::from_bytes(config.auth_header.as_bytes()) + .map_err(|_| ValidationError::new("invalid_trusted_client_ip_header"))?; + + if ip_header == auth_header { + return Err(ValidationError::new("identical_trusted_client_ip_headers")); + } + + for header in [&ip_header, &auth_header] { + if matches!(header.as_str(), "x-ts-tls-protocol" | "x-ts-tls-cipher") { + return Err(ValidationError::new("reserved_trusted_client_ip_header")); + } + } + + if ip_header.as_str() != "fastly-client-ip" && !ip_header.as_str().starts_with("x-") { + return Err(ValidationError::new("unsafe_trusted_client_ip_header")); + } + if !auth_header.as_str().starts_with("x-") { + return Err(ValidationError::new("unsafe_trusted_client_ip_header")); + } + + Ok(()) +} + #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Settings { @@ -1918,6 +1971,10 @@ pub struct Settings { pub publisher: Publisher, #[serde(default)] pub tester_cookie: TesterCookieConfig, + /// Optional authenticated trusted client IP forwarding configuration. + #[serde(default)] + #[validate(nested)] + pub trusted_client_ip: Option, #[serde(default)] #[validate(nested)] pub ec: Ec, @@ -2595,6 +2652,207 @@ mod tests { use crate::redacted::Redacted; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; + fn trusted_client_ip_toml(ip_header: &str, auth_header: &str, shared_secret: &str) -> String { + format!( + "{}\n[trusted_client_ip]\nip_header = \"{ip_header}\"\nauth_header = \"{auth_header}\"\nshared_secret = \"{shared_secret}\"\n", + crate_test_settings_str() + ) + } + + #[test] + fn trusted_client_ip_is_absent_by_default() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without trusted client IP configuration"); + + assert!( + settings.trusted_client_ip.is_none(), + "should leave trusted client IP configuration disabled by default" + ); + } + + #[test] + fn trusted_client_ip_parses_and_redacts_shared_secret_in_debug_output() { + let settings = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + "fictional-shared-secret", + )) + .expect("should parse valid trusted client IP configuration"); + let config = settings + .trusted_client_ip + .expect("should retain trusted client IP configuration"); + + assert_eq!(config.ip_header, "fastly-client-ip"); + assert_eq!(config.auth_header, "x-trusted-client-auth"); + let debug = format!("{config:?}"); + assert!( + debug.contains("[REDACTED]"), + "should redact trusted client IP shared secret in debug output" + ); + assert!( + !debug.contains("fictional-shared-secret"), + "should not expose trusted client IP shared secret in debug output" + ); + } + + #[test] + fn trusted_client_ip_accepts_x_prefixed_ip_header() { + let settings = Settings::from_toml(&trusted_client_ip_toml( + "x-trusted-client-ip", + "x-trusted-client-auth", + "fictional-shared-secret", + )) + .expect("should accept an x-prefixed trusted client IP header"); + let config = settings + .trusted_client_ip + .expect("should retain trusted client IP configuration"); + + assert_eq!( + config.ip_header, "x-trusted-client-ip", + "should retain the x-prefixed trusted client IP header" + ); + } + + #[test] + fn trusted_client_ip_authentication_requires_an_exact_match() { + let settings = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + "fictional-shared-secret", + )) + .expect("should parse valid trusted client IP configuration"); + let config = settings + .trusted_client_ip + .expect("should retain trusted client IP configuration"); + + assert!( + config.authenticates("fictional-shared-secret"), + "should authenticate an exact shared secret match" + ); + assert!( + !config.authenticates("fictional-wrong-secret"), + "should reject a different shared secret" + ); + assert!( + !config.authenticates(" fictional-shared-secret"), + "should reject a leading-whitespace shared secret" + ); + assert!( + !config.authenticates("fictional-shared-secret "), + "should reject a trailing-whitespace shared secret" + ); + } + + #[test] + fn trusted_client_ip_rejects_identical_header_names() { + for (ip_header, auth_header) in [ + ("x-trusted-client", "x-trusted-client"), + ("X-Trusted-Client", "x-trusted-client"), + ] { + let error = Settings::from_toml(&trusted_client_ip_toml( + ip_header, + auth_header, + "fictional-shared-secret", + )) + .expect_err("should reject identical trusted client IP header names"); + + assert!( + format!("{error:?}").contains("identical_trusted_client_ip_headers"), + "should identify duplicate trusted client IP header names" + ); + } + } + + #[test] + fn trusted_client_ip_rejects_unsafe_header_names() { + for (ip_header, auth_header) in [ + ("host", "x-trusted-client-auth"), + ("fastly-client-ip", "authorization"), + ] { + let error = Settings::from_toml(&trusted_client_ip_toml( + ip_header, + auth_header, + "fictional-shared-secret", + )) + .expect_err("should reject unsafe trusted client IP header names"); + let message = format!("{error:?}"); + + assert!( + message.contains("unsafe_trusted_client_ip_header"), + "should identify unsafe trusted client IP header names" + ); + assert!( + !message.contains("fictional-shared-secret"), + "should not include the shared secret in validation errors" + ); + } + } + + #[test] + fn trusted_client_ip_rejects_reserved_tls_bridge_headers() { + for (ip_header, auth_header) in [ + ("x-ts-tls-protocol", "x-trusted-client-auth"), + ("x-ts-tls-cipher", "x-trusted-client-auth"), + ("fastly-client-ip", "x-ts-tls-protocol"), + ("fastly-client-ip", "x-ts-tls-cipher"), + ] { + let error = Settings::from_toml(&trusted_client_ip_toml( + ip_header, + auth_header, + "fictional-shared-secret", + )) + .expect_err("should reject reserved TLS bridge headers"); + + assert!( + format!("{error:?}").contains("reserved_trusted_client_ip_header"), + "should identify reserved TLS bridge headers" + ); + } + } + + #[test] + fn trusted_client_ip_rejects_empty_secret_malformed_names_and_incomplete_sections() { + let empty_secret = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + "", + )); + assert!( + empty_secret.is_err(), + "should reject an empty trusted client IP shared secret" + ); + + for (ip_header, auth_header) in [ + ("invalid header", "x-trusted-client-auth"), + ("fastly-client-ip", "invalid header"), + ] { + let error = Settings::from_toml(&trusted_client_ip_toml( + ip_header, + auth_header, + "fictional-shared-secret", + )) + .expect_err("should reject malformed trusted client IP header names"); + assert!( + format!("{error:?}").contains("invalid_trusted_client_ip_header"), + "should identify malformed trusted client IP header names" + ); + } + + for section in [ + "[trusted_client_ip]\nauth_header = \"x-trusted-client-auth\"\nshared_secret = \"fictional-shared-secret\"", + "[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nshared_secret = \"fictional-shared-secret\"", + "[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nauth_header = \"x-trusted-client-auth\"", + "[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nauth_header = \"x-trusted-client-auth\"\nshared_secret = \"fictional-shared-secret\"\nunknown_field = true", + ] { + let result = + Settings::from_toml(&format!("{}\n{section}\n", crate_test_settings_str())); + assert!( + result.is_err(), + "should reject incomplete or unknown trusted client IP configuration" + ); + } + } + #[test] fn tinybird_defaults_to_disabled_placeholders() { let settings = Settings::from_toml(&crate_test_settings_str()) From 6b7db934f21fb32f257af737bf0104480db49075 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 21:37:23 +0530 Subject: [PATCH 06/23] Differentiate trusted client IP header errors --- crates/trusted-server-core/src/settings.rs | 79 +++++++++++++++++++--- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index c7708d0ba..2c3fd9d74 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1929,6 +1929,22 @@ pub struct TrustedClientIpConfig { impl TrustedClientIpConfig { /// Returns whether `candidate` exactly matches the configured shared secret. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::redacted::Redacted; + /// use trusted_server_core::settings::TrustedClientIpConfig; + /// + /// let config = TrustedClientIpConfig { + /// ip_header: "fastly-client-ip".to_owned(), + /// auth_header: "x-trusted-client-auth".to_owned(), + /// shared_secret: Redacted::new("fictional-shared-secret".to_owned()), + /// }; + /// + /// assert!(config.authenticates("fictional-shared-secret")); + /// assert!(!config.authenticates("fictional-wrong-secret")); + /// ``` #[must_use] pub fn authenticates(&self, candidate: &str) -> bool { let configured_digest = Sha256::digest(self.shared_secret.expose().as_bytes()); @@ -1942,7 +1958,7 @@ fn validate_trusted_client_ip(config: &TrustedClientIpConfig) -> Result<(), Vali let ip_header = http::HeaderName::from_bytes(config.ip_header.as_bytes()) .map_err(|_| ValidationError::new("invalid_trusted_client_ip_header"))?; let auth_header = http::HeaderName::from_bytes(config.auth_header.as_bytes()) - .map_err(|_| ValidationError::new("invalid_trusted_client_ip_header"))?; + .map_err(|_| ValidationError::new("invalid_trusted_client_ip_auth_header"))?; if ip_header == auth_header { return Err(ValidationError::new("identical_trusted_client_ip_headers")); @@ -1958,7 +1974,7 @@ fn validate_trusted_client_ip(config: &TrustedClientIpConfig) -> Result<(), Vali return Err(ValidationError::new("unsafe_trusted_client_ip_header")); } if !auth_header.as_str().starts_with("x-") { - return Err(ValidationError::new("unsafe_trusted_client_ip_header")); + return Err(ValidationError::new("unsafe_trusted_client_ip_auth_header")); } Ok(()) @@ -2765,9 +2781,17 @@ mod tests { #[test] fn trusted_client_ip_rejects_unsafe_header_names() { - for (ip_header, auth_header) in [ - ("host", "x-trusted-client-auth"), - ("fastly-client-ip", "authorization"), + for (ip_header, auth_header, expected_code) in [ + ( + "host", + "x-trusted-client-auth", + "unsafe_trusted_client_ip_header", + ), + ( + "fastly-client-ip", + "authorization", + "unsafe_trusted_client_ip_auth_header", + ), ] { let error = Settings::from_toml(&trusted_client_ip_toml( ip_header, @@ -2778,7 +2802,7 @@ mod tests { let message = format!("{error:?}"); assert!( - message.contains("unsafe_trusted_client_ip_header"), + message.contains(expected_code), "should identify unsafe trusted client IP header names" ); assert!( @@ -2822,9 +2846,17 @@ mod tests { "should reject an empty trusted client IP shared secret" ); - for (ip_header, auth_header) in [ - ("invalid header", "x-trusted-client-auth"), - ("fastly-client-ip", "invalid header"), + for (ip_header, auth_header, expected_code) in [ + ( + "invalid header", + "x-trusted-client-auth", + "invalid_trusted_client_ip_header", + ), + ( + "fastly-client-ip", + "invalid header", + "invalid_trusted_client_ip_auth_header", + ), ] { let error = Settings::from_toml(&trusted_client_ip_toml( ip_header, @@ -2833,7 +2865,7 @@ mod tests { )) .expect_err("should reject malformed trusted client IP header names"); assert!( - format!("{error:?}").contains("invalid_trusted_client_ip_header"), + format!("{error:?}").contains(expected_code), "should identify malformed trusted client IP header names" ); } @@ -2853,6 +2885,33 @@ mod tests { } } + #[test] + fn trusted_client_ip_rejects_control_byte_auth_header_without_exposing_secret() { + let mut settings = serde_json::to_value( + Settings::from_toml(&crate_test_settings_str()) + .expect("should parse base settings for JSON validation"), + ) + .expect("should serialize base settings for JSON validation"); + settings["trusted_client_ip"] = json!({ + "ip_header": "fastly-client-ip", + "auth_header": "x-trusted\u{0000}client-auth", + "shared_secret": "fictional-control-byte-secret", + }); + + let error = Settings::from_json_value(settings) + .expect_err("should reject a control byte in the trusted client IP auth header"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_auth_header"), + "should identify the malformed trusted client IP auth header" + ); + assert!( + !message.contains("fictional-control-byte-secret"), + "should not expose the trusted client IP shared secret in validation errors" + ); + } + #[test] fn tinybird_defaults_to_disabled_placeholders() { let settings = Settings::from_toml(&crate_test_settings_str()) From 01c2c47b57063fa1e38094f5636725ed1991d01b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 21:48:53 +0530 Subject: [PATCH 07/23] Resolve authenticated forwarded client IP --- .../src/compat.rs | 72 +++++- .../trusted-server-adapter-fastly/src/main.rs | 14 +- .../src/platform.rs | 212 +++++++++++++++++- crates/trusted-server-core/src/http_util.rs | 1 + 4 files changed, 286 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs index b46e8cc9b..6340b00d4 100644 --- a/crates/trusted-server-adapter-fastly/src/compat.rs +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -3,6 +3,7 @@ use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::Response as HttpResponse; use trusted_server_core::http_util::SPOOFABLE_FORWARDED_HEADERS; +use trusted_server_core::settings::TrustedClientIpConfig; /// Convert an [`HttpResponse`] into a `fastly::Response`. pub(crate) fn to_fastly_response(resp: HttpResponse) -> fastly::Response { @@ -52,9 +53,18 @@ pub(crate) fn to_fastly_response_skeleton(resp: HttpResponse) -> fastly::Respons /// Sanitize forwarded headers on a `fastly::Request`. /// -/// Strips headers that clients can spoof before any request-derived context -/// is built or the request is converted to core HTTP types. -pub(crate) fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { +/// Strips configured trust headers and headers that clients can spoof before +/// any request-derived context is built or the request is converted to core +/// HTTP types. +pub(crate) fn sanitize_fastly_forwarded_headers( + req: &mut fastly::Request, + config: Option<&TrustedClientIpConfig>, +) { + if let Some(config) = config { + req.remove_header(config.ip_header.as_str()); + req.remove_header(config.auth_header.as_str()); + } + for &name in SPOOFABLE_FORWARDED_HEADERS { if req.get_header(name).is_some() { log::debug!("Stripped spoofable header: {name}"); @@ -66,6 +76,15 @@ pub(crate) fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { #[cfg(test)] mod tests { use super::*; + use trusted_server_core::redacted::Redacted; + + fn trusted_client_ip_config(ip_header: &str) -> TrustedClientIpConfig { + TrustedClientIpConfig { + ip_header: ip_header.to_owned(), + auth_header: "x-trusted-client-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret".to_owned()), + } + } #[test] fn sanitize_fastly_forwarded_headers_strips_spoofable() { @@ -74,9 +93,10 @@ mod tests { req.set_header("x-forwarded-host", "evil.example.com"); req.set_header("x-forwarded-proto", "http"); req.set_header("fastly-ssl", "1"); + req.set_header("fastly-client-ip", "198.51.100.7"); req.set_header("host", "example.com"); - sanitize_fastly_forwarded_headers(&mut req); + sanitize_fastly_forwarded_headers(&mut req, None); assert!( req.get_header("forwarded").is_none(), @@ -94,9 +114,53 @@ mod tests { req.get_header("fastly-ssl").is_none(), "should strip fastly-ssl" ); + assert!( + req.get_header("fastly-client-ip").is_none(), + "should strip fastly-client-ip" + ); assert!(req.get_header("host").is_some(), "should preserve host"); } + #[test] + fn sanitize_fastly_forwarded_headers_strips_configured_headers() { + let config = trusted_client_ip_config("x-trusted-client-ip"); + let mut req = fastly::Request::get("https://example.com/"); + req.set_header("x-trusted-client-ip", "198.51.100.7"); + req.set_header("x-trusted-client-auth", "fictional-shared-secret"); + req.set_header("host", "example.com"); + + sanitize_fastly_forwarded_headers(&mut req, Some(&config)); + + assert!( + req.get_header("x-trusted-client-ip").is_none(), + "should strip the configured IP header" + ); + assert!( + req.get_header("x-trusted-client-auth").is_none(), + "should strip the configured auth header" + ); + assert!(req.get_header("host").is_some(), "should preserve host"); + } + + #[test] + fn sanitize_fastly_forwarded_headers_allows_static_and_dynamic_overlap() { + let config = trusted_client_ip_config("fastly-client-ip"); + let mut req = fastly::Request::get("https://example.com/"); + req.set_header("fastly-client-ip", "198.51.100.7"); + req.set_header("x-trusted-client-auth", "fictional-shared-secret"); + + sanitize_fastly_forwarded_headers(&mut req, Some(&config)); + + assert!( + req.get_header("fastly-client-ip").is_none(), + "should tolerate removing fastly-client-ip twice" + ); + assert!( + req.get_header("x-trusted-client-auth").is_none(), + "should strip the configured auth header" + ); + } + #[test] fn to_fastly_response_with_streaming_body_produces_empty_body() { use edgezero_core::http::StatusCode; diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 39d35b198..12e439851 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -39,7 +39,7 @@ mod tinybird; use crate::app::{EcFinalizeState, TrustedServerApp, load_settings_from_config_store}; use crate::ec_kv::FastlyEcKvStore; use crate::middleware::{HEADER_X_TS_FINALIZED, apply_finalize_headers, resolve_geo_for_response}; -use crate::platform::{FastlyPlatformGeo, client_info_from_request}; +use crate::platform::{FastlyPlatformGeo, client_info_from_request, resolve_client_ip}; use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; const TRUSTED_SERVER_CONFIG_STORE: &str = "trusted_server_config"; @@ -120,9 +120,13 @@ fn edgezero_main(mut req: FastlyRequest) { let (app, app_state) = TrustedServerApp::build_app_with_state(); let settings_snapshot = app_state.as_ref().map(|state| Arc::clone(&state.settings)); + let trusted_client_ip = settings_snapshot + .as_deref() + .and_then(|settings| settings.trusted_client_ip.as_ref()); + let resolved_client_ip = resolve_client_ip(&req, req.get_client_ip_addr(), trusted_client_ip); // Strip client-spoofable forwarded headers before dispatch. - compat::sanitize_fastly_forwarded_headers(&mut req); + compat::sanitize_fastly_forwarded_headers(&mut req, trusted_client_ip); // Re-inject a trusted TLS scheme signal after sanitization has stripped any // client-sent fastly-ssl header. Setting it from Fastly's native TLS @@ -134,9 +138,6 @@ fn edgezero_main(mut req: FastlyRequest) { req.set_header("fastly-ssl", "1"); } - // Capture client IP before the request is consumed by dispatch. - let client_ip = req.get_client_ip_addr(); - // Strip any client-supplied x-ts-tls-* headers before injecting the trusted // values from the Fastly SDK. Must run after sanitize_fastly_forwarded_headers. req.remove_header("x-ts-tls-protocol"); @@ -156,7 +157,8 @@ fn edgezero_main(mut req: FastlyRequest) { // Capture metadata from the original FastlyRequest before conversion. These // accessors only return real values on the client request, so store them in // request extensions for build_per_request_services and EC bot classification. - let client_info = client_info_from_request(&req); + let client_info = client_info_from_request(&req, resolved_client_ip); + let client_ip = client_info.client_ip; let device_signals = derive_device_signals(&req); // Dispatch directly through the EdgeZero router without an intermediate diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 9e7920e1c..0f5f04e84 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -21,6 +21,7 @@ use trusted_server_core::platform::{ PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, }; +use trusted_server_core::settings::TrustedClientIpConfig; // --------------------------------------------------------------------------- // FastlyPlatformConfigStore @@ -694,7 +695,43 @@ impl PlatformGeo for FastlyPlatformGeo { } } -/// Extract [`ClientInfo`] from the original Fastly request. +fn single_utf8_header<'a>(req: &'a Request, name: &str) -> Option<&'a str> { + let mut values = req.get_header_all(name); + let value = values.next()?; + if values.next().is_some() { + return None; + } + value.to_str().ok() +} + +/// Resolve the request's client IP from an authenticated forwarding header. +/// +/// When no trusted-client-IP configuration is present, or when either header +/// is missing, duplicated, malformed, or unauthenticated, this returns the +/// Fastly SDK peer address unchanged. +#[must_use] +pub(crate) fn resolve_client_ip( + req: &Request, + peer_ip: Option, + config: Option<&TrustedClientIpConfig>, +) -> Option { + let Some(config) = config else { + return peer_ip; + }; + let Some(auth_candidate) = single_utf8_header(req, &config.auth_header) else { + return peer_ip; + }; + if !config.authenticates(auth_candidate) { + return peer_ip; + } + let Some(ip_candidate) = single_utf8_header(req, &config.ip_header) else { + return peer_ip; + }; + + ip_candidate.parse::().ok().or(peer_ip) +} + +/// Extract [`ClientInfo`] from the original Fastly request and resolved client IP. /// /// Fastly's TLS, JA4, and HTTP/2 fingerprint accessors only return real values /// on the client request before it is converted to platform HTTP types. This @@ -703,9 +740,9 @@ impl PlatformGeo for FastlyPlatformGeo { /// extensions so `build_per_request_services` can read back metadata the /// reconstructed request cannot expose. #[must_use] -pub fn client_info_from_request(req: &Request) -> ClientInfo { +pub fn client_info_from_request(req: &Request, client_ip: Option) -> ClientInfo { ClientInfo { - client_ip: req.get_client_ip_addr(), + client_ip, tls_protocol: req.get_tls_protocol().ok().flatten().map(str::to_string), tls_cipher: req .get_tls_cipher_openssl_name() @@ -741,6 +778,175 @@ mod tests { use super::*; use edgezero_core::body::Body; use edgezero_core::http::request_builder; + use fastly::http::HeaderValue; + use trusted_server_core::redacted::Redacted; + use trusted_server_core::settings::TrustedClientIpConfig; + + const PEER_IP: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); + + fn trusted_client_ip_config() -> TrustedClientIpConfig { + TrustedClientIpConfig { + ip_header: "fastly-client-ip".to_owned(), + auth_header: "x-trusted-client-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret".to_owned()), + } + } + + fn authenticated_request(ip: impl AsRef<[u8]>) -> Request { + let mut req = Request::get("https://example.com/"); + req.set_header("x-trusted-client-auth", "fictional-shared-secret"); + req.set_header("fastly-client-ip", ip.as_ref()); + req + } + + #[test] + fn resolve_client_ip_uses_peer_when_config_is_absent() { + let req = authenticated_request("198.51.100.7"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), None); + + assert_eq!(resolved, Some(PEER_IP), "should preserve the peer IP"); + } + + #[test] + fn resolve_client_ip_accepts_authenticated_ipv4() { + let req = authenticated_request("198.51.100.7"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!( + resolved, + Some(IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7))), + "should use the authenticated IPv4 address" + ); + } + + #[test] + fn resolve_client_ip_accepts_authenticated_ipv6() { + let req = authenticated_request("2001:db8::7"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!( + resolved, + Some(IpAddr::V6(std::net::Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 7, + ))), + "should use the authenticated IPv6 address" + ); + } + + #[test] + fn resolve_client_ip_uses_peer_when_auth_is_missing_empty_or_wrong() { + for auth_value in [None, Some(""), Some("fictional-wrong-secret")] { + let mut req = Request::get("https://example.com/"); + if let Some(auth_value) = auth_value { + req.set_header("x-trusted-client-auth", auth_value); + } + req.set_header("fastly-client-ip", "198.51.100.7"); + + let resolved = + resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!( + resolved, + Some(PEER_IP), + "should fall back for auth value {auth_value:?}" + ); + } + } + + #[test] + fn resolve_client_ip_uses_peer_when_auth_is_duplicated() { + let mut req = authenticated_request("198.51.100.7"); + req.append_header("x-trusted-client-auth", "fictional-shared-secret"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!(resolved, Some(PEER_IP), "should reject duplicate auth"); + } + + #[test] + fn resolve_client_ip_uses_peer_when_auth_is_not_utf8() { + let mut req = Request::get("https://example.com/"); + req.set_header( + "x-trusted-client-auth", + HeaderValue::from_bytes(b"fictional-shared-secret\xff") + .expect("should build non-UTF-8 auth header"), + ); + req.set_header("fastly-client-ip", "198.51.100.7"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!(resolved, Some(PEER_IP), "should reject non-UTF-8 auth"); + } + + #[test] + fn resolve_client_ip_uses_peer_when_ip_is_missing() { + let mut req = Request::get("https://example.com/"); + req.set_header("x-trusted-client-auth", "fictional-shared-secret"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!(resolved, Some(PEER_IP), "should require an IP header"); + } + + #[test] + fn resolve_client_ip_uses_peer_when_ip_text_is_invalid() { + for ip_value in [ + " 198.51.100.7", + "198.51.100.7 ", + "198.51.100.7:443", + "2001:db8::7%example0", + "198.51.100.7, 203.0.113.10", + "", + ] { + let req = authenticated_request(ip_value); + + let resolved = + resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!( + resolved, + Some(PEER_IP), + "should reject invalid IP value {ip_value:?}" + ); + } + } + + #[test] + fn resolve_client_ip_uses_peer_when_ip_is_duplicated() { + let mut req = authenticated_request("198.51.100.7"); + req.append_header("fastly-client-ip", "203.0.113.10"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!(resolved, Some(PEER_IP), "should reject duplicate IP values"); + } + + #[test] + fn resolve_client_ip_uses_peer_when_ip_is_not_utf8() { + let req = authenticated_request( + HeaderValue::from_bytes(b"198.51.100.7\xff").expect("should build non-UTF-8 IP header"), + ); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!(resolved, Some(PEER_IP), "should reject non-UTF-8 IP"); + } + + #[test] + fn client_info_from_request_preserves_supplied_client_ip() { + let req = Request::get("https://example.com/"); + let supplied_ip = Some(IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7))); + + let client_info = client_info_from_request(&req, supplied_ip); + + assert_eq!( + client_info.client_ip, supplied_ip, + "should preserve the supplied client IP" + ); + } #[test] fn edge_request_to_fastly_replaces_url_derived_host_header() { diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 5ad7011fe..604e7a647 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -42,6 +42,7 @@ pub const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ "x-forwarded-host", "x-forwarded-proto", "fastly-ssl", + "fastly-client-ip", ]; /// Strip forwarded headers that clients can spoof. From 63149471b00922fc55ac9474e0413fdefb5a36c0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 22:02:08 +0530 Subject: [PATCH 08/23] Use resolved client IP for middleware geo --- .../src/middleware.rs | 99 ++++++++++++++++++- 1 file changed, 95 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 2c00ac2ff..d6e4a7805 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -25,7 +25,7 @@ use trusted_server_core::constants::{ HEADER_X_TS_ENV, HEADER_X_TS_VERSION, }; use trusted_server_core::geo::GeoInfo; -use trusted_server_core::platform::PlatformGeo; +use trusted_server_core::platform::{ClientInfo, PlatformGeo}; use trusted_server_core::settings::Settings; pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized"; @@ -67,7 +67,12 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - let client_ip = FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip); + let client_ip = ctx + .request() + .extensions() + .get::() + .and_then(|info| info.client_ip) + .or_else(|| FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip)); let mut response = match next.run(ctx).await { Ok(r) => r, @@ -247,7 +252,7 @@ mod tests { use std::collections::HashMap; use std::net::IpAddr; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; @@ -257,7 +262,7 @@ mod tests { use edgezero_core::params::PathParams; use error_stack::Report; use futures::executor::block_on; - use trusted_server_core::platform::{PlatformError, PlatformGeo}; + use trusted_server_core::platform::{ClientInfo, PlatformError, PlatformGeo}; fn empty_response() -> Response { response_builder() @@ -282,6 +287,23 @@ mod tests { } } + struct RecordingGeo { + lookups: Arc>>>, + } + + impl PlatformGeo for RecordingGeo { + fn lookup( + &self, + client_ip: Option, + ) -> Result, Report> { + self.lookups + .lock() + .expect("should lock recorded geo lookups") + .push(client_ip); + Ok(None) + } + } + fn test_settings() -> Settings { Settings::from_toml( r#" @@ -500,6 +522,75 @@ mod tests { // FinalizeResponseMiddleware::handle tests // --------------------------------------------------------------------------- + #[test] + fn finalize_handle_uses_client_info_ip_for_geo_lookup() { + let reader_ip = IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7)); + let peer_ip = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); + let settings = settings_with_response_headers(vec![]); + let lookups = Arc::new(Mutex::new(Vec::new())); + let middleware = FinalizeResponseMiddleware::new( + Arc::new(settings), + Arc::new(RecordingGeo { + lookups: Arc::clone(&lookups), + }), + ); + let mut ctx = empty_ctx(); + ctx.request_mut().extensions_mut().insert(ClientInfo { + client_ip: Some(reader_ip), + ..ClientInfo::default() + }); + FastlyRequestContext::insert( + ctx.request_mut(), + FastlyRequestContext { + client_ip: Some(peer_ip), + }, + ); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + block_on(middleware.handle(ctx, Next::new(&[], &*handler))).expect("should succeed"); + + assert_eq!( + *lookups.lock().expect("should lock recorded geo lookups"), + vec![Some(reader_ip)], + "should look up geo using the resolved ClientInfo IP" + ); + } + + #[test] + fn finalize_handle_uses_fastly_context_ip_when_client_info_is_absent() { + let peer_ip = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); + let settings = settings_with_response_headers(vec![]); + let lookups = Arc::new(Mutex::new(Vec::new())); + let middleware = FinalizeResponseMiddleware::new( + Arc::new(settings), + Arc::new(RecordingGeo { + lookups: Arc::clone(&lookups), + }), + ); + let mut ctx = empty_ctx(); + FastlyRequestContext::insert( + ctx.request_mut(), + FastlyRequestContext { + client_ip: Some(peer_ip), + }, + ); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + block_on(middleware.handle(ctx, Next::new(&[], &*handler))).expect("should succeed"); + + assert_eq!( + *lookups.lock().expect("should lock recorded geo lookups"), + vec![Some(peer_ip)], + "should fall back to the Fastly request context IP" + ); + } + #[test] fn finalize_handle_injects_geo_unavailable_on_ok_response() { let settings = settings_with_response_headers(vec![]); From bf2d1b2e8106aa7c38aeb85caaf17e6efa976cf2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 22:08:42 +0530 Subject: [PATCH 09/23] Preserve authoritative client IP absence --- .../src/middleware.rs | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index d6e4a7805..8a4f12137 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -67,12 +67,10 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - let client_ip = ctx - .request() - .extensions() - .get::() - .and_then(|info| info.client_ip) - .or_else(|| FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip)); + let client_ip = ctx.request().extensions().get::().map_or_else( + || FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip), + |info| info.client_ip, + ); let mut response = match next.run(ctx).await { Ok(r) => r, @@ -559,6 +557,41 @@ mod tests { ); } + #[test] + fn finalize_handle_preserves_none_from_present_client_info() { + let peer_ip = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); + let settings = settings_with_response_headers(vec![]); + let lookups = Arc::new(Mutex::new(Vec::new())); + let middleware = FinalizeResponseMiddleware::new( + Arc::new(settings), + Arc::new(RecordingGeo { + lookups: Arc::clone(&lookups), + }), + ); + let mut ctx = empty_ctx(); + ctx.request_mut() + .extensions_mut() + .insert(ClientInfo::default()); + FastlyRequestContext::insert( + ctx.request_mut(), + FastlyRequestContext { + client_ip: Some(peer_ip), + }, + ); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + block_on(middleware.handle(ctx, Next::new(&[], &*handler))).expect("should succeed"); + + assert_eq!( + *lookups.lock().expect("should lock recorded geo lookups"), + vec![None], + "should preserve no IP from the authoritative ClientInfo" + ); + } + #[test] fn finalize_handle_uses_fastly_context_ip_when_client_info_is_absent() { let peer_ip = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); From 99f8311a1fdac17020760a99affade7bc95f91e4 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 22:12:23 +0530 Subject: [PATCH 10/23] Document trusted client IP forwarding --- docs/guide/configuration.md | 76 ++++++++++++++++++++++++++++++++----- docs/guide/fastly.md | 47 +++++++++++++++++++++++ trusted-server.example.toml | 7 ++++ 3 files changed, 120 insertions(+), 10 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ddb6544ce..2ae3570ac 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -66,16 +66,17 @@ fail and the service will return its startup-error response. ## Key Sections -| Section | Purpose | -| ------------------- | -------------------------------------------- | -| `[publisher]` | Domain, origin, proxy settings | -| `[ec]` | Edge Cookie (EC) ID generation | -| `[tester_cookie]` | Optional tester-cookie endpoint | -| `[proxy]` | Proxy SSRF allowlist and asset routes | -| `[image_optimizer]` | Reusable Image Optimizer profile sets | -| `[request_signing]` | Ed25519 request signing | -| `[auction]` | Auction orchestration | -| `[integrations.*]` | Partner integrations (Prebid, Next.js, etc.) | +| Section | Purpose | +| --------------------- | -------------------------------------------- | +| `[publisher]` | Domain, origin, proxy settings | +| `[trusted_client_ip]` | Authenticated client-IP forwarding | +| `[ec]` | Edge Cookie (EC) ID generation | +| `[tester_cookie]` | Optional tester-cookie endpoint | +| `[proxy]` | Proxy SSRF allowlist and asset routes | +| `[image_optimizer]` | Reusable Image Optimizer profile sets | +| `[request_signing]` | Ed25519 request signing | +| `[auction]` | Auction orchestration | +| `[integrations.*]` | Partner integrations (Prebid, Next.js, etc.) | ## Example: Production Setup @@ -352,6 +353,61 @@ a zero-byte cap fails every non-empty publisher response. TRUSTED_SERVER__PUBLISHER__MAX_BUFFERED_BODY_BYTES=16777216 ``` +## Trusted Client IP Configuration + +Use this optional section when a trusted CDN service forwards requests to the +Fastly service running Trusted Server. It lets Trusted Server use the reader's +address instead of the immediate fronting edge node's address. + +### `[trusted_client_ip]` + +| Field | Type | Required | Description | +| --------------- | ------ | -------- | ------------------------------------------------- | +| `ip_header` | String | Yes | Header containing exactly one reader IP address | +| `auth_header` | String | Yes | Header containing exactly one shared-secret value | +| `shared_secret` | String | Yes | Secret shared with the trusted front door | + +All three fields are required when the section exists. When the section is +absent, Trusted Server continues to use the immediate peer address. + +```toml +[trusted_client_ip] +ip_header = "fastly-client-ip" +auth_header = "x-ts-client-ip-auth" +shared_secret = "replace-with-a-random-shared-secret" +``` + +The front door must overwrite both headers on every request. Trusted Server +accepts the forwarded address only when the request has exactly one +`auth_header` value that matches `shared_secret` byte-for-byte and exactly one +`ip_header` value that parses directly as IPv4 or IPv6. Values are not trimmed +or normalized. Missing, empty, duplicate, non-UTF-8, mismatched, or malformed +values do not reject the request; Trusted Server safely falls back to the +immediate peer address. Both configured headers are removed before routing. + +Header names are validated case-insensitively. `ip_header` must be +`fastly-client-ip` or start with `x-`, while `auth_header` must start with `x-`. +The names must differ. Neither field may use the reserved +`x-ts-tls-protocol` or `x-ts-tls-cipher` header. These restrictions prevent the +configuration from consuming routing, framing, cookie, authorization, or +Trusted Server TLS bridge headers. + +Generate `shared_secret` with a cryptographically secure random generator, +store the same value only in the front door and Trusted Server configuration, +and never commit it. The value is redacted from configuration debug output. + +**Environment Overrides**: + +```bash +TRUSTED_SERVER__TRUSTED_CLIENT_IP__IP_HEADER=fastly-client-ip +TRUSTED_SERVER__TRUSTED_CLIENT_IP__AUTH_HEADER=x-ts-client-ip-auth +TRUSTED_SERVER__TRUSTED_CLIENT_IP__SHARED_SECRET=replace-with-a-random-shared-secret +``` + +Because the typed environment overlay cannot create a missing section, add +`[trusted_client_ip]` and all three fields to the TOML before using these +overrides. + ## Tester Cookie Configuration Settings for the optional tester-cookie endpoints. This feature is disabled by diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 20faf1995..669771d9e 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -72,6 +72,53 @@ When you're ready to use your own domain: - Fastly Compute **only accepts client traffic via TLS** (HTTPS) - Origins and backends can be non-TLS if needed +## CDN-fronted Client IP + +When another CDN or Fastly service fronts Trusted Server, the Fastly Compute +client address identifies the immediate edge node rather than the original +reader. Trusted Server can instead consume an authenticated reader-IP header, +but only after the public front door is configured to overwrite both the IP and +authentication headers on every backend request. Preserving values supplied by +the browser is unsafe because a caller could choose the IP used for geolocation +and other request processing. + +For a VCL-to-Compute [service chain](https://www.fastly.com/documentation/guides/getting-started/services/service-chaining/), +overwrite [`Fastly-Client-IP`](https://www.fastly.com/documentation/reference/http/http-headers/Fastly-Client-IP/) +from the initial [`client.ip`](https://www.fastly.com/documentation/reference/vcl/variables/client-connection/client-ip/) +and set a dedicated authentication header in the fronting service. For example: + +```vcl +sub vcl_recv { + if (fastly.ff.visits_this_service == 0 && req.restarts == 0) { + set req.http.Fastly-Client-IP = client.ip; + } + set req.http.X-TS-Client-IP-Auth = "replace-with-a-random-shared-secret"; +} +``` + +Use a cryptographically random secret in production; the value above is only a +placeholder. Configure the identical header names and secret in Trusted Server: + +```toml +[trusted_client_ip] +ip_header = "fastly-client-ip" +auth_header = "x-ts-client-ip-auth" +shared_secret = "replace-with-a-random-shared-secret" +``` + +`Fastly-Client-IP` is not protected from modification when it first enters +Fastly, which is why overwriting it and authenticating the handoff are both +required. Trusted Server removes both trust headers before routing. Direct +requests and requests with missing, invalid, or duplicated trust headers remain +available and use the immediate peer address instead. + +::: warning No-code request routing limitation +Fastly no-code request routing does not provide a point to inject these headers. +If that routing path does not preserve the original reader IP, Trusted Server +cannot recover it with this mechanism. Use a fronting service that can overwrite +both headers before forwarding the request. +::: + ## Create Config and Secret Stores For features like request signing, you'll need to create Fastly stores: diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 19ecda4a5..b41524e58 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -11,6 +11,13 @@ origin_url = "https://origin.example.com" # origin_host_header_override = "www.example.com" proxy_secret = "change-me-proxy-secret" +# Optional: trust a fronting CDN's reader IP only when it also supplies the +# matching shared secret. The front door must overwrite both headers. +# [trusted_client_ip] +# ip_header = "fastly-client-ip" +# auth_header = "x-ts-client-ip-auth" +# shared_secret = "replace-with-a-random-shared-secret" + [ec] passphrase = "trusted-server-placeholder-secret" ec_store = "ec_identity_store" From 5cb94e640eedb4efedc9e0d32c29f022cb088fa0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 22:14:57 +0530 Subject: [PATCH 11/23] Clarify trusted header name restrictions --- docs/guide/configuration.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 2ae3570ac..3f078e3a0 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -388,9 +388,11 @@ immediate peer address. Both configured headers are removed before routing. Header names are validated case-insensitively. `ip_header` must be `fastly-client-ip` or start with `x-`, while `auth_header` must start with `x-`. The names must differ. Neither field may use the reserved -`x-ts-tls-protocol` or `x-ts-tls-cipher` header. These restrictions prevent the -configuration from consuming routing, framing, cookie, authorization, or -Trusted Server TLS bridge headers. +`x-ts-tls-protocol` or `x-ts-tls-cipher` header. These restrictions exclude +standard sensitive headers such as `Host`, `Content-Length`, `Cookie`, and +`Authorization`, as well as Trusted Server's TLS bridge headers. Choose +dedicated `x-` names that no other application or routing logic uses, because +Trusted Server removes the configured headers before routing. Generate `shared_secret` with a cryptographically secure random generator, store the same value only in the front door and Trusted Server configuration, From 53dbaeef96e2887f16b2ac2621041133544b1105 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 22:29:05 +0530 Subject: [PATCH 12/23] Clarify Fastly client IP sanitization order --- crates/trusted-server-core/src/http_util.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 604e7a647..ce7c3919a 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -34,9 +34,11 @@ pub fn copy_custom_headers(from: &Request, to: &mut Request) /// Headers that clients can spoof to hijack URL rewriting. /// -/// On Fastly Compute the service is the edge — there is no upstream proxy that -/// legitimately sets these. Stripping them forces [`RequestInfo::from_request`] -/// to fall back to the trustworthy `Host` header and [`ClientInfo`] TLS detection. +/// On Fastly Compute these values are client-spoofable at request entry. The +/// Fastly adapter may first consume an authenticated `fastly-client-ip`, but +/// removes it before routing along with every other listed header. Stripping +/// them forces [`RequestInfo::from_request`] to fall back to the trustworthy +/// `Host` header and [`ClientInfo`] TLS detection. pub const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ "forwarded", "x-forwarded-host", From 800e40f32b4a7fa3add58af64a64ba3733025b35 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 23:32:54 +0530 Subject: [PATCH 13/23] Strengthen trusted client IP shared secret validation Reject secrets shorter than the 32-character minimum already applied to ec.passphrase, and route the secret through reject_placeholder_secrets so the placeholder published in the example config and guides fails startup. The secret is the only gate on forging the client address that geolocation, EC identity derivation, and bot protection consume. --- .../src/compat.rs | 12 +- .../src/platform.rs | 19 ++- crates/trusted-server-core/src/settings.rs | 115 +++++++++++++++--- docs/guide/configuration.md | 20 ++- ...6-08-19-trusted-client-ip-header-design.md | 7 +- 5 files changed, 138 insertions(+), 35 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs index 6340b00d4..52c215296 100644 --- a/crates/trusted-server-adapter-fastly/src/compat.rs +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -82,7 +82,7 @@ mod tests { TrustedClientIpConfig { ip_header: ip_header.to_owned(), auth_header: "x-trusted-client-auth".to_owned(), - shared_secret: Redacted::new("fictional-shared-secret".to_owned()), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), } } @@ -126,7 +126,10 @@ mod tests { let config = trusted_client_ip_config("x-trusted-client-ip"); let mut req = fastly::Request::get("https://example.com/"); req.set_header("x-trusted-client-ip", "198.51.100.7"); - req.set_header("x-trusted-client-auth", "fictional-shared-secret"); + req.set_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); req.set_header("host", "example.com"); sanitize_fastly_forwarded_headers(&mut req, Some(&config)); @@ -147,7 +150,10 @@ mod tests { let config = trusted_client_ip_config("fastly-client-ip"); let mut req = fastly::Request::get("https://example.com/"); req.set_header("fastly-client-ip", "198.51.100.7"); - req.set_header("x-trusted-client-auth", "fictional-shared-secret"); + req.set_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); sanitize_fastly_forwarded_headers(&mut req, Some(&config)); diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 0f5f04e84..0e8ac2144 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -788,13 +788,16 @@ mod tests { TrustedClientIpConfig { ip_header: "fastly-client-ip".to_owned(), auth_header: "x-trusted-client-auth".to_owned(), - shared_secret: Redacted::new("fictional-shared-secret".to_owned()), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), } } fn authenticated_request(ip: impl AsRef<[u8]>) -> Request { let mut req = Request::get("https://example.com/"); - req.set_header("x-trusted-client-auth", "fictional-shared-secret"); + req.set_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); req.set_header("fastly-client-ip", ip.as_ref()); req } @@ -859,7 +862,10 @@ mod tests { #[test] fn resolve_client_ip_uses_peer_when_auth_is_duplicated() { let mut req = authenticated_request("198.51.100.7"); - req.append_header("x-trusted-client-auth", "fictional-shared-secret"); + req.append_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); @@ -871,7 +877,7 @@ mod tests { let mut req = Request::get("https://example.com/"); req.set_header( "x-trusted-client-auth", - HeaderValue::from_bytes(b"fictional-shared-secret\xff") + HeaderValue::from_bytes(b"fictional-shared-secret-0123456789\xff") .expect("should build non-UTF-8 auth header"), ); req.set_header("fastly-client-ip", "198.51.100.7"); @@ -884,7 +890,10 @@ mod tests { #[test] fn resolve_client_ip_uses_peer_when_ip_is_missing() { let mut req = Request::get("https://example.com/"); - req.set_header("x-trusted-client-auth", "fictional-shared-secret"); + req.set_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 2c3fd9d74..2be6229f8 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1928,6 +1928,26 @@ pub struct TrustedClientIpConfig { } impl TrustedClientIpConfig { + /// Placeholder shared secrets shipped in the example configuration and docs. + pub const SHARED_SECRET_PLACEHOLDERS: &[&str] = &["replace-with-a-random-shared-secret"]; + + /// Minimum accepted `shared_secret` length. + /// + /// Matches `Ec::MIN_PASSPHRASE_LENGTH`. This secret is the only gate on + /// forging the client address that geolocation, EC identity derivation, and + /// bot protection consume, so it is held to the same strength as the EC + /// passphrase. + const MIN_SHARED_SECRET_LENGTH: usize = Ec::MIN_PASSPHRASE_LENGTH; + + /// Returns `true` if `shared_secret` matches a known placeholder value + /// (case-insensitive). + #[must_use] + pub fn is_placeholder_shared_secret(shared_secret: &str) -> bool { + Self::SHARED_SECRET_PLACEHOLDERS + .iter() + .any(|p| p.eq_ignore_ascii_case(shared_secret)) + } + /// Returns whether `candidate` exactly matches the configured shared secret. /// /// # Examples @@ -1939,10 +1959,10 @@ impl TrustedClientIpConfig { /// let config = TrustedClientIpConfig { /// ip_header: "fastly-client-ip".to_owned(), /// auth_header: "x-trusted-client-auth".to_owned(), - /// shared_secret: Redacted::new("fictional-shared-secret".to_owned()), + /// shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), /// }; /// - /// assert!(config.authenticates("fictional-shared-secret")); + /// assert!(config.authenticates("fictional-shared-secret-0123456789")); /// assert!(!config.authenticates("fictional-wrong-secret")); /// ``` #[must_use] @@ -1977,6 +1997,12 @@ fn validate_trusted_client_ip(config: &TrustedClientIpConfig) -> Result<(), Vali return Err(ValidationError::new("unsafe_trusted_client_ip_auth_header")); } + if config.shared_secret.expose().len() < TrustedClientIpConfig::MIN_SHARED_SECRET_LENGTH { + return Err(ValidationError::new( + "short_trusted_client_ip_shared_secret", + )); + } + Ok(()) } @@ -2194,6 +2220,13 @@ impl Settings { if Publisher::is_placeholder_proxy_secret(self.publisher.proxy_secret.expose()) { insecure_fields.push("publisher.proxy_secret".to_owned()); } + if let Some(trusted_client_ip) = &self.trusted_client_ip + && TrustedClientIpConfig::is_placeholder_shared_secret( + trusted_client_ip.shared_secret.expose(), + ) + { + insecure_fields.push("trusted_client_ip.shared_secret".to_owned()); + } for partner in &self.ec.partners { if EcPartner::is_placeholder_api_token(partner.api_token.expose()) { insecure_fields.push(format!("ec.partners[{}].api_token", partner.source_domain)); @@ -2691,7 +2724,7 @@ mod tests { let settings = Settings::from_toml(&trusted_client_ip_toml( "fastly-client-ip", "x-trusted-client-auth", - "fictional-shared-secret", + "fictional-shared-secret-0123456789", )) .expect("should parse valid trusted client IP configuration"); let config = settings @@ -2706,7 +2739,7 @@ mod tests { "should redact trusted client IP shared secret in debug output" ); assert!( - !debug.contains("fictional-shared-secret"), + !debug.contains("fictional-shared-secret-0123456789"), "should not expose trusted client IP shared secret in debug output" ); } @@ -2716,7 +2749,7 @@ mod tests { let settings = Settings::from_toml(&trusted_client_ip_toml( "x-trusted-client-ip", "x-trusted-client-auth", - "fictional-shared-secret", + "fictional-shared-secret-0123456789", )) .expect("should accept an x-prefixed trusted client IP header"); let config = settings @@ -2734,7 +2767,7 @@ mod tests { let settings = Settings::from_toml(&trusted_client_ip_toml( "fastly-client-ip", "x-trusted-client-auth", - "fictional-shared-secret", + "fictional-shared-secret-0123456789", )) .expect("should parse valid trusted client IP configuration"); let config = settings @@ -2742,7 +2775,7 @@ mod tests { .expect("should retain trusted client IP configuration"); assert!( - config.authenticates("fictional-shared-secret"), + config.authenticates("fictional-shared-secret-0123456789"), "should authenticate an exact shared secret match" ); assert!( @@ -2750,11 +2783,11 @@ mod tests { "should reject a different shared secret" ); assert!( - !config.authenticates(" fictional-shared-secret"), + !config.authenticates(" fictional-shared-secret-0123456789"), "should reject a leading-whitespace shared secret" ); assert!( - !config.authenticates("fictional-shared-secret "), + !config.authenticates("fictional-shared-secret-0123456789 "), "should reject a trailing-whitespace shared secret" ); } @@ -2768,7 +2801,7 @@ mod tests { let error = Settings::from_toml(&trusted_client_ip_toml( ip_header, auth_header, - "fictional-shared-secret", + "fictional-shared-secret-0123456789", )) .expect_err("should reject identical trusted client IP header names"); @@ -2796,7 +2829,7 @@ mod tests { let error = Settings::from_toml(&trusted_client_ip_toml( ip_header, auth_header, - "fictional-shared-secret", + "fictional-shared-secret-0123456789", )) .expect_err("should reject unsafe trusted client IP header names"); let message = format!("{error:?}"); @@ -2806,7 +2839,7 @@ mod tests { "should identify unsafe trusted client IP header names" ); assert!( - !message.contains("fictional-shared-secret"), + !message.contains("fictional-shared-secret-0123456789"), "should not include the shared secret in validation errors" ); } @@ -2823,7 +2856,7 @@ mod tests { let error = Settings::from_toml(&trusted_client_ip_toml( ip_header, auth_header, - "fictional-shared-secret", + "fictional-shared-secret-0123456789", )) .expect_err("should reject reserved TLS bridge headers"); @@ -2861,7 +2894,7 @@ mod tests { let error = Settings::from_toml(&trusted_client_ip_toml( ip_header, auth_header, - "fictional-shared-secret", + "fictional-shared-secret-0123456789", )) .expect_err("should reject malformed trusted client IP header names"); assert!( @@ -2871,10 +2904,10 @@ mod tests { } for section in [ - "[trusted_client_ip]\nauth_header = \"x-trusted-client-auth\"\nshared_secret = \"fictional-shared-secret\"", - "[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nshared_secret = \"fictional-shared-secret\"", + "[trusted_client_ip]\nauth_header = \"x-trusted-client-auth\"\nshared_secret = \"fictional-shared-secret-0123456789\"", + "[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nshared_secret = \"fictional-shared-secret-0123456789\"", "[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nauth_header = \"x-trusted-client-auth\"", - "[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nauth_header = \"x-trusted-client-auth\"\nshared_secret = \"fictional-shared-secret\"\nunknown_field = true", + "[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nauth_header = \"x-trusted-client-auth\"\nshared_secret = \"fictional-shared-secret-0123456789\"\nunknown_field = true", ] { let result = Settings::from_toml(&format!("{}\n{section}\n", crate_test_settings_str())); @@ -2895,7 +2928,7 @@ mod tests { settings["trusted_client_ip"] = json!({ "ip_header": "fastly-client-ip", "auth_header": "x-trusted\u{0000}client-auth", - "shared_secret": "fictional-control-byte-secret", + "shared_secret": "fictional-control-byte-secret-0123", }); let error = Settings::from_json_value(settings) @@ -2907,11 +2940,55 @@ mod tests { "should identify the malformed trusted client IP auth header" ); assert!( - !message.contains("fictional-control-byte-secret"), + !message.contains("fictional-control-byte-secret-0123"), "should not expose the trusted client IP shared secret in validation errors" ); } + #[test] + fn trusted_client_ip_rejects_a_short_shared_secret() { + let error = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + "fictional-too-short", + )) + .expect_err("should reject a shared secret below the minimum length"); + + assert!( + format!("{error:?}").contains("short_trusted_client_ip_shared_secret"), + "should identify the undersized trusted client IP shared secret" + ); + } + + #[test] + fn trusted_client_ip_rejects_placeholder_shared_secrets() { + for placeholder in TrustedClientIpConfig::SHARED_SECRET_PLACEHOLDERS { + assert!( + TrustedClientIpConfig::is_placeholder_shared_secret(placeholder), + "should detect placeholder shared secret '{placeholder}'" + ); + assert!( + TrustedClientIpConfig::is_placeholder_shared_secret(&placeholder.to_uppercase()), + "should detect placeholder shared secret case-insensitively" + ); + + let settings = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + placeholder, + )) + .expect("should parse a placeholder trusted client IP shared secret"); + let error = settings + .reject_placeholder_secrets() + .expect_err("should reject a placeholder trusted client IP shared secret"); + + assert!( + format!("{error:?}").contains("trusted_client_ip.shared_secret"), + "should name the placeholder trusted client IP shared secret field" + ); + } + } + #[test] fn tinybird_defaults_to_disabled_placeholders() { let settings = Settings::from_toml(&crate_test_settings_str()) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 3f078e3a0..ca35ef879 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -357,15 +357,17 @@ TRUSTED_SERVER__PUBLISHER__MAX_BUFFERED_BODY_BYTES=16777216 Use this optional section when a trusted CDN service forwards requests to the Fastly service running Trusted Server. It lets Trusted Server use the reader's -address instead of the immediate fronting edge node's address. +address instead of the immediate fronting edge node's address. Only the Fastly +adapter honours this section; the Cloudflare, Spin, and Axum adapters validate +it but keep using their own runtime client address. ### `[trusted_client_ip]` -| Field | Type | Required | Description | -| --------------- | ------ | -------- | ------------------------------------------------- | -| `ip_header` | String | Yes | Header containing exactly one reader IP address | -| `auth_header` | String | Yes | Header containing exactly one shared-secret value | -| `shared_secret` | String | Yes | Secret shared with the trusted front door | +| Field | Type | Required | Description | +| --------------- | ------ | -------- | --------------------------------------------------------- | +| `ip_header` | String | Yes | Header containing exactly one reader IP address | +| `auth_header` | String | Yes | Header containing exactly one shared-secret value | +| `shared_secret` | String | Yes | Secret shared with the trusted front door, 32+ characters | All three fields are required when the section exists. When the section is absent, Trusted Server continues to use the immediate peer address. @@ -397,6 +399,12 @@ Trusted Server removes the configured headers before routing. Generate `shared_secret` with a cryptographically secure random generator, store the same value only in the front door and Trusted Server configuration, and never commit it. The value is redacted from configuration debug output. +Configuration fails validation when the secret is shorter than 32 characters, +matching the minimum applied to `ec.passphrase`, and startup fails when it is +still the documented placeholder value. + +Trusted Server strips any client-supplied `X-Forwarded-For` at the edge, so the +front door cannot use that header to carry the reader address. **Environment Overrides**: diff --git a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md index 3a5af1ea6..9853c50a1 100644 --- a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md +++ b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md @@ -50,8 +50,11 @@ shared_secret = "replace-with-a-random-shared-secret" All three fields are required when the section is present. `shared_secret` uses the existing `Redacted` type so debug representations do not disclose -it. Configuration validation rejects empty secrets, invalid header names, and -identical header names. +it. Configuration validation rejects invalid header names, identical header +names, and secrets shorter than the 32-character minimum already applied to +`ec.passphrase`. The shared `reject_placeholder_secrets` startup gate also +rejects the placeholder secret published in the example configuration and +guides, so a copied config cannot ship a publicly known secret. To ensure request entry can remove the fields without deleting a required HTTP field, `ip_header` must either be `fastly-client-ip` or begin with `x-`, and From 7d4da82fcb25e5856eeb38dcce7df7c6e6c1f696 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 23:34:24 +0530 Subject: [PATCH 14/23] Strip client-supplied X-Forwarded-For at the edge The shared proxy code forwards an inbound X-Forwarded-For to publisher origins, and no adapter has a trustworthy upstream forwarded-for chain, so a client could choose the address the origin attributes the request to. The Spin adapter already stripped it; moving the rule into the shared spoofable-header list closes the same gap on Fastly without changing Spin behavior. Integrations that need the address keep injecting their own value from the resolved client IP. --- .../trusted-server-adapter-fastly/src/compat.rs | 15 +++++++++++++++ crates/trusted-server-core/src/http_util.rs | 14 +++++++++++++- docs/guide/fastly.md | 5 +++++ .../2026-08-19-trusted-client-ip-header-design.md | 8 ++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs index 52c215296..a8fa952ff 100644 --- a/crates/trusted-server-adapter-fastly/src/compat.rs +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -167,6 +167,21 @@ mod tests { ); } + #[test] + fn sanitize_fastly_forwarded_headers_strips_client_supplied_forwarded_for() { + let mut req = fastly::Request::get("https://example.com/"); + req.set_header("x-forwarded-for", "198.51.100.99, 10.0.0.1"); + req.set_header("host", "example.com"); + + sanitize_fastly_forwarded_headers(&mut req, None); + + assert!( + req.get_header("x-forwarded-for").is_none(), + "should strip client-supplied x-forwarded-for" + ); + assert!(req.get_header("host").is_some(), "should preserve host"); + } + #[test] fn to_fastly_response_with_streaming_body_produces_empty_body() { use edgezero_core::http::StatusCode; diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index ce7c3919a..e9c141166 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -32,17 +32,24 @@ pub fn copy_custom_headers(from: &Request, to: &mut Request) } } -/// Headers that clients can spoof to hijack URL rewriting. +/// Headers that clients can spoof to hijack URL rewriting or the client address. /// /// On Fastly Compute these values are client-spoofable at request entry. The /// Fastly adapter may first consume an authenticated `fastly-client-ip`, but /// removes it before routing along with every other listed header. Stripping /// them forces [`RequestInfo::from_request`] to fall back to the trustworthy /// `Host` header and [`ClientInfo`] TLS detection. +/// +/// `x-forwarded-for` is listed because the shared proxy code forwards an inbound +/// value to publisher origins. No adapter has a trustworthy upstream +/// forwarded-for chain, so leaving it would let a client choose the address the +/// origin attributes the request to. Integrations that need the address inject +/// their own `X-Forwarded-For` from [`ClientInfo::client_ip`] instead. pub const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ "forwarded", "x-forwarded-host", "x-forwarded-proto", + "x-forwarded-for", "fastly-ssl", "fastly-client-ip", ]; @@ -670,6 +677,7 @@ mod tests { set_header(&mut req, "forwarded", "host=evil.com;proto=https"); set_header(&mut req, "x-forwarded-host", "evil.com"); set_header(&mut req, "x-forwarded-proto", "https"); + set_header(&mut req, "x-forwarded-for", "198.51.100.99, 10.0.0.1"); set_header(&mut req, "fastly-ssl", "1"); sanitize_forwarded_headers(&mut req); @@ -686,6 +694,10 @@ mod tests { req.headers().get("x-forwarded-proto").is_none(), "should strip X-Forwarded-Proto header" ); + assert!( + req.headers().get("x-forwarded-for").is_none(), + "should strip client-supplied X-Forwarded-For header" + ); assert!( req.headers().get("fastly-ssl").is_none(), "should strip Fastly-SSL header" diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 669771d9e..9a2711f96 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -112,6 +112,11 @@ required. Trusted Server removes both trust headers before routing. Direct requests and requests with missing, invalid, or duplicated trust headers remain available and use the immediate peer address instead. +Trusted Server also strips any client-supplied `X-Forwarded-For` at the edge, so +the fronting service cannot use that header to carry the reader address. +Integrations that need the address send their own `X-Forwarded-For` derived from +the resolved client IP. + ::: warning No-code request routing limitation Fastly no-code request routing does not provide a point to inject these headers. If that routing path does not preserve the original reader IP, Trusted Server diff --git a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md index 9853c50a1..80a354de2 100644 --- a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md +++ b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md @@ -101,6 +101,14 @@ is stripped even when the feature is not configured. A configured header is read before sanitization and removed explicitly, so configuring `fastly-client-ip` remains valid. +`X-Forwarded-For` joins the same list. The shared proxy code forwards an inbound +value to publisher origins, so leaving it would let a client choose the address +the origin attributes the request to while Trusted Server itself used the +authenticated one. The Spin adapter already stripped it for this reason; moving +the rule into the shared list closes the equivalent gap on Fastly without +changing Spin behaviour. Integrations that need the address continue to send +their own `X-Forwarded-For` derived from the resolved client IP. + Authentication failures do not log supplied secrets or IP values. A debug-level message may record only the reason category (missing authentication, mismatch, or invalid IP) and that the peer fallback was used. From 87d96979aba1252f9c5a8e45e8d9651a839bcc43 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 19 Aug 2026 23:34:38 +0530 Subject: [PATCH 15/23] Resolve the trusted client IP behind one sanitizing call Fold resolution and forwarded-header sanitization into resolve_and_sanitize_client_ip so resolution cannot be reordered after the sanitization that removes the headers it reads. Log every fallback taken while a configuration is present at debug level, without the secret or the address, so a rotated secret or renamed header stops failing silently. Document the front door requirement to leave exactly one value per trust header, since duplicates select the peer address. --- .../src/compat.rs | 45 +++++++++++++++++++ .../trusted-server-adapter-fastly/src/main.rs | 9 ++-- .../src/platform.rs | 12 ++++- docs/guide/fastly.md | 14 +++++- ...6-08-19-trusted-client-ip-header-design.md | 4 +- 5 files changed, 76 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs index a8fa952ff..ad653abb9 100644 --- a/crates/trusted-server-adapter-fastly/src/compat.rs +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -1,10 +1,14 @@ //! Compatibility bridge between `fastly` SDK types and `http` crate types. +use std::net::IpAddr; + use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::Response as HttpResponse; use trusted_server_core::http_util::SPOOFABLE_FORWARDED_HEADERS; use trusted_server_core::settings::TrustedClientIpConfig; +use crate::platform::resolve_client_ip; + /// Convert an [`HttpResponse`] into a `fastly::Response`. pub(crate) fn to_fastly_response(resp: HttpResponse) -> fastly::Response { let (parts, body) = resp.into_parts(); @@ -73,6 +77,20 @@ pub(crate) fn sanitize_fastly_forwarded_headers( } } +/// Resolve the trusted client IP, then strip every trust and spoofable header. +/// +/// Resolution has to observe the trust headers *before* sanitization removes +/// them. Both steps live behind this one call so that ordering is structural +/// rather than a convention the entry point has to remember. +pub(crate) fn resolve_and_sanitize_client_ip( + req: &mut fastly::Request, + config: Option<&TrustedClientIpConfig>, +) -> Option { + let client_ip = resolve_client_ip(req, req.get_client_ip_addr(), config); + sanitize_fastly_forwarded_headers(req, config); + client_ip +} + #[cfg(test)] mod tests { use super::*; @@ -182,6 +200,33 @@ mod tests { assert!(req.get_header("host").is_some(), "should preserve host"); } + #[test] + fn resolve_and_sanitize_client_ip_reads_trust_headers_before_stripping_them() { + let config = trusted_client_ip_config("x-trusted-client-ip"); + let mut req = fastly::Request::get("https://example.com/"); + req.set_header("x-trusted-client-ip", "198.51.100.7"); + req.set_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); + + let resolved = resolve_and_sanitize_client_ip(&mut req, Some(&config)); + + assert_eq!( + resolved, + Some(IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7))), + "should resolve the forwarded IP before sanitization removes the headers" + ); + assert!( + req.get_header("x-trusted-client-ip").is_none(), + "should strip the configured IP header after resolving" + ); + assert!( + req.get_header("x-trusted-client-auth").is_none(), + "should strip the configured auth header after resolving" + ); + } + #[test] fn to_fastly_response_with_streaming_body_produces_empty_body() { use edgezero_core::http::StatusCode; diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 12e439851..24032bdc6 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -39,7 +39,7 @@ mod tinybird; use crate::app::{EcFinalizeState, TrustedServerApp, load_settings_from_config_store}; use crate::ec_kv::FastlyEcKvStore; use crate::middleware::{HEADER_X_TS_FINALIZED, apply_finalize_headers, resolve_geo_for_response}; -use crate::platform::{FastlyPlatformGeo, client_info_from_request, resolve_client_ip}; +use crate::platform::{FastlyPlatformGeo, client_info_from_request}; use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; const TRUSTED_SERVER_CONFIG_STORE: &str = "trusted_server_config"; @@ -123,10 +123,11 @@ fn edgezero_main(mut req: FastlyRequest) { let trusted_client_ip = settings_snapshot .as_deref() .and_then(|settings| settings.trusted_client_ip.as_ref()); - let resolved_client_ip = resolve_client_ip(&req, req.get_client_ip_addr(), trusted_client_ip); - // Strip client-spoofable forwarded headers before dispatch. - compat::sanitize_fastly_forwarded_headers(&mut req, trusted_client_ip); + // Resolve the trusted client IP, then strip client-spoofable forwarded + // headers before dispatch. One call keeps resolution ahead of the + // sanitization that removes the headers it reads. + let resolved_client_ip = compat::resolve_and_sanitize_client_ip(&mut req, trusted_client_ip); // Re-inject a trusted TLS scheme signal after sanitization has stripped any // client-sent fastly-ssl header. Setting it from Fastly's native TLS diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 0e8ac2144..638aed82b 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -709,6 +709,10 @@ fn single_utf8_header<'a>(req: &'a Request, name: &str) -> Option<&'a str> { /// When no trusted-client-IP configuration is present, or when either header /// is missing, duplicated, malformed, or unauthenticated, this returns the /// Fastly SDK peer address unchanged. +/// +/// Every fallback taken while a configuration *is* present logs at debug level +/// so a rotated secret or renamed header is diagnosable. Debug rather than warn +/// keeps a direct client from driving log volume by sending junk trust headers. #[must_use] pub(crate) fn resolve_client_ip( req: &Request, @@ -719,16 +723,22 @@ pub(crate) fn resolve_client_ip( return peer_ip; }; let Some(auth_candidate) = single_utf8_header(req, &config.auth_header) else { + log::debug!("Trusted client IP: auth header is missing, duplicated, or not UTF-8"); return peer_ip; }; if !config.authenticates(auth_candidate) { + log::debug!("Trusted client IP: auth header did not match the configured shared secret"); return peer_ip; } let Some(ip_candidate) = single_utf8_header(req, &config.ip_header) else { + log::debug!("Trusted client IP: IP header is missing, duplicated, or not UTF-8"); return peer_ip; }; - ip_candidate.parse::().ok().or(peer_ip) + ip_candidate.parse::().ok().or_else(|| { + log::debug!("Trusted client IP: IP header is not a bare IPv4 or IPv6 address"); + peer_ip + }) } /// Extract [`ClientInfo`] from the original Fastly request and resolved client IP. diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 9a2711f96..409d31802 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -90,14 +90,24 @@ and set a dedicated authentication header in the fronting service. For example: ```vcl sub vcl_recv { if (fastly.ff.visits_this_service == 0 && req.restarts == 0) { + unset req.http.Fastly-Client-IP; set req.http.Fastly-Client-IP = client.ip; } + unset req.http.X-TS-Client-IP-Auth; set req.http.X-TS-Client-IP-Auth = "replace-with-a-random-shared-secret"; } ``` -Use a cryptographically random secret in production; the value above is only a -placeholder. Configure the identical header names and secret in Trusted Server: +The `unset` before each `set` matters. Trusted Server ignores a forwarded +address whenever either trust header carries more than one value, so a client +that sends its own copy of either header could otherwise force the fallback and +keep its real address out of geolocation and bot protection. + +Use a cryptographically random secret of at least 32 characters in production. +The value above is a placeholder that Trusted Server rejects at startup. Keep it +in a private edge dictionary rather than inlining it in VCL, where it is +readable by anyone with service-configuration access and preserved in every +version diff. Configure the identical header names and secret in Trusted Server: ```toml [trusted_client_ip] diff --git a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md index 80a354de2..750545db6 100644 --- a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md +++ b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md @@ -92,7 +92,9 @@ loaded but before spoofable headers are sanitized: secret, a malformed header value, or a non-IP value all select the peer address without rejecting the request. 6. Remove the configured IP and authentication headers, then run the existing - forwarded-header sanitizer. + forwarded-header sanitizer. Steps 1-6 sit behind a single + `resolve_and_sanitize_client_ip` call so resolution cannot be reordered + after the sanitization that removes the headers it reads. 7. Pass the selected address into `client_info_from_request` and use the same value for entry-point geo response finalization. From a8694dafb0a88168102f7fd7cf3ae8667a6638bc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 20 Aug 2026 15:16:46 +0530 Subject: [PATCH 16/23] Clarify trusted client IP secret validation --- .../2026-08-19-trusted-client-ip-header.md | 54 +++++++++++++++++-- ...6-08-19-trusted-client-ip-header-design.md | 22 +++++--- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md index a7bd3342d..5b6e529c6 100644 --- a/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md +++ b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md @@ -1,5 +1,24 @@ # Trusted Client IP Header Implementation Plan +## Review follow-up: header-safe shared secrets + +PR review found that the request path reads the authentication field with +`HeaderValue::to_str`, while configuration originally accepted any string of at +least 32 UTF-8 bytes. A non-ASCII secret could therefore pass startup validation +but never authenticate a request. Whitespace accepted by `HeaderValue::to_str` +is also unsuitable because an intermediary may normalize it. + +- [ ] Add focused settings tests for the 31/32-byte boundary and rejection of + non-ASCII, horizontal-tab, space, DEL, and other control bytes. Assert + rejected values remain redacted, and run the tests first to demonstrate + the current failure. +- [ ] Accept only `shared_secret` bytes in the ASCII graphic range + `0x21..=0x7e`, retaining the 32-byte minimum and redacted errors. +- [ ] Update the configuration and Fastly guides to specify 32 or more ASCII + graphic bytes and recommend hexadecimal or base64url generation. +- [ ] Run focused settings tests, formatting, target-matched tests, Clippy, and + the Fastly release build before updating the PR. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Resolve the reader IP behind an authenticated fronting CDN while preserving peer-IP fallback and stripping all trust headers before routing. @@ -51,7 +70,7 @@ fn trusted_client_ip_is_absent_by_default() { #[test] fn trusted_client_ip_parses_and_redacts_secret() { let toml = format!( - "{}\n[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nauth_header = \"x-ts-client-ip-auth\"\nshared_secret = \"unit-test-shared-secret\"\n", + "{}\n[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nauth_header = \"x-ts-client-ip-auth\"\nshared_secret = \"unit-test-shared-secret-0123456789\"\n", crate_test_settings_str() ); let settings = Settings::from_toml(&toml) @@ -109,6 +128,9 @@ pub struct TrustedClientIpConfig { } impl TrustedClientIpConfig { + /// Minimum accepted authentication secret length in ASCII graphic bytes. + const MIN_SHARED_SECRET_LENGTH: usize = 32; + /// Compares a request authentication value with the configured secret. #[must_use] pub fn authenticates(&self, candidate: &str) -> bool { @@ -156,6 +178,20 @@ fn trusted_client_ip_rejects_fastly_tls_bridge_headers() { #[test] fn trusted_client_ip_rejects_empty_secret() { /* shared_secret = "" */ } +#[test] +fn trusted_client_ip_rejects_a_31_byte_secret() { /* shared_secret = 31 * "a" */ } + +#[test] +fn trusted_client_ip_accepts_a_32_byte_ascii_graphic_secret() { + /* shared_secret = 32 * "a" */ +} + +#[test] +fn trusted_client_ip_rejects_non_header_safe_secrets_without_exposing_them() { + // Exercise a >=32-byte non-ASCII value, HTAB, space, DEL, and another + // control byte. Assert each validation error omits the rejected value. +} + #[test] fn trusted_client_ip_rejects_malformed_header_names() { /* spaces/control bytes */ } @@ -165,9 +201,9 @@ fn trusted_client_ip_rejects_incomplete_section() { /* omit each required field #[test] fn trusted_client_ip_authentication_is_exact() { let config = trusted_client_ip_test_config(); - assert!(config.authenticates("unit-test-shared-secret")); + assert!(config.authenticates("unit-test-shared-secret-0123456789")); assert!(!config.authenticates("wrong-secret")); - assert!(!config.authenticates(" unit-test-shared-secret")); + assert!(!config.authenticates(" unit-test-shared-secret-0123456789")); } ``` @@ -180,7 +216,7 @@ Run the same filtered core test command. Expected: parsing tests pass, while the unsafe/identical-header tests fail because cross-field validation is not yet implemented. -- [ ] **Step 6: Implement header-name validation** +- [ ] **Step 6: Implement header-name and shared-secret validation** Add `#[validate(schema(function = "validate_trusted_client_ip_config"))]` to `TrustedClientIpConfig`, then add helpers that parse names through @@ -209,12 +245,20 @@ fn validate_trusted_client_ip_config( return Err(ValidationError::new("reserved_trusted_client_ip_header")); } } + let shared_secret = config.shared_secret.expose().as_bytes(); + if shared_secret.len() < TrustedClientIpConfig::MIN_SHARED_SECRET_LENGTH { + return Err(ValidationError::new("short_trusted_client_ip_shared_secret")); + } + if !shared_secret.iter().all(|byte| matches!(byte, b'!'..=b'~')) { + return Err(ValidationError::new("invalid_trusted_client_ip_shared_secret")); + } Ok(()) } ``` Use descriptive validation messages if the validator API allows them without -duplicating logic. Do not include `shared_secret` in errors. +duplicating logic. Do not include `shared_secret` in errors. Test the 31/32-byte +boundary plus non-ASCII, horizontal tab, space, DEL, and another control byte. - [ ] **Step 7: Run the filtered core tests and verify GREEN** diff --git a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md index 750545db6..e63631aee 100644 --- a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md +++ b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md @@ -51,8 +51,12 @@ shared_secret = "replace-with-a-random-shared-secret" All three fields are required when the section is present. `shared_secret` uses the existing `Redacted` type so debug representations do not disclose it. Configuration validation rejects invalid header names, identical header -names, and secrets shorter than the 32-character minimum already applied to -`ec.passphrase`. The shared `reject_placeholder_secrets` startup gate also +names, secrets containing bytes outside the ASCII graphic range +`0x21..=0x7e`, and secrets shorter than 32 ASCII bytes. This is intentionally +stricter than the request reader's `HeaderValue::to_str` contract, which also +accepts horizontal tab: excluding all whitespace prevents an intermediary from +normalizing a configured secret into a different request value. The shared +`reject_placeholder_secrets` startup gate also rejects the placeholder secret published in the example configuration and guides, so a copied config cannot ship a publicly known secret. @@ -79,10 +83,11 @@ loaded but before spoofable headers are sanitized: 1. Capture `req.get_client_ip_addr()` as the fallback peer address. 2. If `trusted_client_ip` is absent, select the peer address. 3. If configured, require exactly one authentication-header field value. It - must be valid UTF-8 and match the configured secret byte-for-byte, without - trimming or other normalization. Compare fixed-size SHA-256 digests using a - constant-time comparison. A missing, duplicated, non-UTF-8, empty, or - mismatched authentication value fails authentication. + must be representable by `HeaderValue::to_str` and match the configured + ASCII-graphic secret byte-for-byte, without trimming or other normalization. + Compare fixed-size SHA-256 digests using a constant-time comparison. A + missing, duplicated, non-ASCII, empty, or mismatched authentication value + fails authentication. 4. Only after authentication succeeds, require exactly one IP-header field value and parse it directly as `std::net::IpAddr`. Do not trim or normalize the value. This accepts canonical or otherwise Rust-supported IPv4 and IPv6 @@ -171,6 +176,9 @@ Tests follow red-green-refactor and cover: - configured headers are removed after resolution; - `Fastly-Client-IP` is stripped when configuration is absent; - settings parse, validation, secret redaction, and default behavior; +- a 31-byte secret is rejected and a 32-byte ASCII-graphic secret is accepted; +- non-ASCII, horizontal-tab, space, DEL, and other control-character shared + secrets fail configuration validation without appearing in the error; - `client_info_from_request` and entry-point geo finalization receive the same selected address. @@ -201,3 +209,5 @@ native unit tests and Wasm compilation still provide local evidence. - Invalid forwarded IP: the request succeeds using the peer IP. - Trust headers never reach routing or downstream origins. - Existing direct Fastly deployments require no configuration migration. +- Configuration accepts only shared secrets of 32 or more ASCII graphic bytes + (`0x21..=0x7e`), excluding whitespace and non-ASCII values. From b0c88ab74d79885b0bc64ffde35d47df6a52837e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 20 Aug 2026 15:52:13 +0530 Subject: [PATCH 17/23] Validate trusted client IP shared secrets --- crates/trusted-server-core/src/settings.rs | 169 +++++++++++++++++- docs/guide/configuration.md | 20 +-- docs/guide/fastly.md | 11 +- .../2026-08-19-trusted-client-ip-header.md | 6 +- 4 files changed, 184 insertions(+), 22 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 2be6229f8..4c1878b88 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1997,11 +1997,20 @@ fn validate_trusted_client_ip(config: &TrustedClientIpConfig) -> Result<(), Vali return Err(ValidationError::new("unsafe_trusted_client_ip_auth_header")); } - if config.shared_secret.expose().len() < TrustedClientIpConfig::MIN_SHARED_SECRET_LENGTH { + let shared_secret = config.shared_secret.expose(); + if shared_secret.len() < TrustedClientIpConfig::MIN_SHARED_SECRET_LENGTH { return Err(ValidationError::new( "short_trusted_client_ip_shared_secret", )); } + if !shared_secret + .bytes() + .all(|byte| matches!(byte, b'!'..=b'~')) + { + return Err(ValidationError::new( + "invalid_trusted_client_ip_shared_secret", + )); + } Ok(()) } @@ -2946,18 +2955,170 @@ mod tests { } #[test] - fn trusted_client_ip_rejects_a_short_shared_secret() { + fn trusted_client_ip_rejects_a_31_byte_shared_secret_without_exposing_it() { + let shared_secret = "1234567890123456789012345678901"; let error = Settings::from_toml(&trusted_client_ip_toml( "fastly-client-ip", "x-trusted-client-auth", - "fictional-too-short", + shared_secret, )) .expect_err("should reject a shared secret below the minimum length"); + let message = format!("{error:?}"); assert!( - format!("{error:?}").contains("short_trusted_client_ip_shared_secret"), + message.contains("short_trusted_client_ip_shared_secret"), "should identify the undersized trusted client IP shared secret" ); + assert!( + !message.contains(shared_secret), + "should not expose the undersized trusted client IP shared secret" + ); + } + + #[test] + fn trusted_client_ip_accepts_an_exactly_32_byte_ascii_graphic_shared_secret() { + let shared_secret = "0123456789abcdef0123456789ABCDEF"; + let settings = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + shared_secret, + )) + .expect("should accept an exactly 32-byte ASCII graphic shared secret"); + let config = settings + .trusted_client_ip + .expect("should retain trusted client IP configuration"); + + assert_eq!( + config.shared_secret.expose(), + shared_secret, + "should retain the accepted shared secret" + ); + } + + #[test] + fn trusted_client_ip_rejects_a_non_ascii_shared_secret_without_exposing_it() { + let shared_secret = "ascii-graphic-secret-0123456789é"; + let error = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + shared_secret, + )) + .expect_err("should reject a non-ASCII shared secret that exceeds 32 bytes"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_shared_secret"), + "should identify the non-header-safe trusted client IP shared secret" + ); + assert!( + !message.contains(shared_secret), + "should not expose the non-ASCII trusted client IP shared secret" + ); + } + + #[test] + fn trusted_client_ip_rejects_a_shared_secret_with_an_embedded_space_without_exposing_it() { + let shared_secret = "valid-shared-secret-with space-012345"; + let error = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + shared_secret, + )) + .expect_err("should reject a shared secret containing an ASCII space"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_shared_secret"), + "should identify the non-header-safe trusted client IP shared secret" + ); + assert!( + !message.contains(shared_secret), + "should not expose the shared secret containing an ASCII space" + ); + } + + #[test] + fn trusted_client_ip_rejects_a_shared_secret_with_an_embedded_tab_without_exposing_it() { + let shared_secret = "valid-shared-secret-with\t-tab-012345"; + let mut settings = serde_json::to_value( + Settings::from_toml(&crate_test_settings_str()) + .expect("should parse base settings for JSON validation"), + ) + .expect("should serialize base settings for JSON validation"); + settings["trusted_client_ip"] = json!({ + "ip_header": "fastly-client-ip", + "auth_header": "x-trusted-client-auth", + "shared_secret": shared_secret, + }); + + let error = Settings::from_json_value(settings) + .expect_err("should reject a shared secret containing a horizontal tab"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_shared_secret"), + "should identify the non-header-safe trusted client IP shared secret" + ); + assert!( + !message.contains(shared_secret), + "should not expose the shared secret containing a horizontal tab" + ); + } + + #[test] + fn trusted_client_ip_rejects_a_shared_secret_with_del_without_exposing_it() { + let shared_secret = "valid-shared-secret-with\u{007f}-del-012345"; + let mut settings = serde_json::to_value( + Settings::from_toml(&crate_test_settings_str()) + .expect("should parse base settings for JSON validation"), + ) + .expect("should serialize base settings for JSON validation"); + settings["trusted_client_ip"] = json!({ + "ip_header": "fastly-client-ip", + "auth_header": "x-trusted-client-auth", + "shared_secret": shared_secret, + }); + + let error = Settings::from_json_value(settings) + .expect_err("should reject a shared secret containing DEL"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_shared_secret"), + "should identify the non-header-safe trusted client IP shared secret" + ); + assert!( + !message.contains(shared_secret), + "should not expose the shared secret containing DEL" + ); + } + + #[test] + fn trusted_client_ip_rejects_a_shared_secret_with_a_control_byte_without_exposing_it() { + let shared_secret = "valid-shared-secret-with\u{0001}-control-012345"; + let mut settings = serde_json::to_value( + Settings::from_toml(&crate_test_settings_str()) + .expect("should parse base settings for JSON validation"), + ) + .expect("should serialize base settings for JSON validation"); + settings["trusted_client_ip"] = json!({ + "ip_header": "fastly-client-ip", + "auth_header": "x-trusted-client-auth", + "shared_secret": shared_secret, + }); + + let error = Settings::from_json_value(settings) + .expect_err("should reject a shared secret containing a control byte"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_shared_secret"), + "should identify the non-header-safe trusted client IP shared secret" + ); + assert!( + !message.contains(shared_secret), + "should not expose the shared secret containing a control byte" + ); } #[test] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ca35ef879..13a8df042 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -363,11 +363,11 @@ it but keep using their own runtime client address. ### `[trusted_client_ip]` -| Field | Type | Required | Description | -| --------------- | ------ | -------- | --------------------------------------------------------- | -| `ip_header` | String | Yes | Header containing exactly one reader IP address | -| `auth_header` | String | Yes | Header containing exactly one shared-secret value | -| `shared_secret` | String | Yes | Secret shared with the trusted front door, 32+ characters | +| Field | Type | Required | Description | +| --------------- | ------ | -------- | --------------------------------------------------------------------------------- | +| `ip_header` | String | Yes | Header containing exactly one reader IP address | +| `auth_header` | String | Yes | Header containing exactly one shared-secret value | +| `shared_secret` | String | Yes | Secret shared with the trusted front door, 32+ ASCII graphic bytes, no whitespace | All three fields are required when the section exists. When the section is absent, Trusted Server continues to use the immediate peer address. @@ -397,11 +397,11 @@ dedicated `x-` names that no other application or routing logic uses, because Trusted Server removes the configured headers before routing. Generate `shared_secret` with a cryptographically secure random generator, -store the same value only in the front door and Trusted Server configuration, -and never commit it. The value is redacted from configuration debug output. -Configuration fails validation when the secret is shorter than 32 characters, -matching the minimum applied to `ec.passphrase`, and startup fails when it is -still the documented placeholder value. +encode it as hex or base64url, store the same value only in the front door and +Trusted Server configuration, and never commit it. The value is redacted from +configuration debug output. Configuration requires at least 32 ASCII graphic +bytes (`!` through `~`) with no whitespace, controls, DEL, or non-ASCII bytes, +and startup fails when the value is still the documented placeholder. Trusted Server strips any client-supplied `X-Forwarded-For` at the edge, so the front door cannot use that header to carry the reader address. diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 409d31802..c44e502f1 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -103,11 +103,12 @@ address whenever either trust header carries more than one value, so a client that sends its own copy of either header could otherwise force the fallback and keep its real address out of geolocation and bot protection. -Use a cryptographically random secret of at least 32 characters in production. -The value above is a placeholder that Trusted Server rejects at startup. Keep it -in a private edge dictionary rather than inlining it in VCL, where it is -readable by anyone with service-configuration access and preserved in every -version diff. Configure the identical header names and secret in Trusted Server: +Use a cryptographically random secret of at least 32 ASCII graphic bytes in +production, encoded as hex or base64url with no whitespace. The value above is +a placeholder that Trusted Server rejects at startup. Keep it in a private edge +dictionary rather than inlining it in VCL, where it is readable by anyone with +service-configuration access and preserved in every version diff. Configure the +identical header names and secret in Trusted Server: ```toml [trusted_client_ip] diff --git a/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md index 5b6e529c6..4db87c2e2 100644 --- a/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md +++ b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md @@ -8,13 +8,13 @@ least 32 UTF-8 bytes. A non-ASCII secret could therefore pass startup validation but never authenticate a request. Whitespace accepted by `HeaderValue::to_str` is also unsuitable because an intermediary may normalize it. -- [ ] Add focused settings tests for the 31/32-byte boundary and rejection of +- [x] Add focused settings tests for the 31/32-byte boundary and rejection of non-ASCII, horizontal-tab, space, DEL, and other control bytes. Assert rejected values remain redacted, and run the tests first to demonstrate the current failure. -- [ ] Accept only `shared_secret` bytes in the ASCII graphic range +- [x] Accept only `shared_secret` bytes in the ASCII graphic range `0x21..=0x7e`, retaining the 32-byte minimum and redacted errors. -- [ ] Update the configuration and Fastly guides to specify 32 or more ASCII +- [x] Update the configuration and Fastly guides to specify 32 or more ASCII graphic bytes and recommend hexadecimal or base64url generation. - [ ] Run focused settings tests, formatting, target-matched tests, Clippy, and the Fastly release build before updating the PR. From 2a7052ec62eccc9944dcccf6426b4e24dff9bccd Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 20 Aug 2026 16:42:03 +0530 Subject: [PATCH 18/23] Record trusted client IP verification --- docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md index 4db87c2e2..8576213b8 100644 --- a/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md +++ b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md @@ -16,7 +16,7 @@ is also unsuitable because an intermediary may normalize it. `0x21..=0x7e`, retaining the 32-byte minimum and redacted errors. - [x] Update the configuration and Fastly guides to specify 32 or more ASCII graphic bytes and recommend hexadecimal or base64url generation. -- [ ] Run focused settings tests, formatting, target-matched tests, Clippy, and +- [x] Run focused settings tests, formatting, target-matched tests, Clippy, and the Fastly release build before updating the PR. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. From 2cd482611387b3d2fece1d7907af4277e813881c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 20 Aug 2026 16:49:54 +0530 Subject: [PATCH 19/23] Clarify forwarded client IP header guidance --- docs/guide/configuration.md | 6 ++++-- docs/guide/fastly.md | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 13a8df042..c0fc50d2e 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -403,8 +403,10 @@ configuration debug output. Configuration requires at least 32 ASCII graphic bytes (`!` through `~`) with no whitespace, controls, DEL, or non-ASCII bytes, and startup fails when the value is still the documented placeholder. -Trusted Server strips any client-supplied `X-Forwarded-For` at the edge, so the -front door cannot use that header to carry the reader address. +Unless `X-Forwarded-For` is explicitly selected as the authenticated +`ip_header`, Trusted Server strips it at the edge. Prefer `Fastly-Client-IP` or a +dedicated `x-` header for the reader address so `X-Forwarded-For` retains its +usual forwarding semantics. **Environment Overrides**: diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index c44e502f1..443382367 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -123,10 +123,10 @@ required. Trusted Server removes both trust headers before routing. Direct requests and requests with missing, invalid, or duplicated trust headers remain available and use the immediate peer address instead. -Trusted Server also strips any client-supplied `X-Forwarded-For` at the edge, so -the fronting service cannot use that header to carry the reader address. -Integrations that need the address send their own `X-Forwarded-For` derived from -the resolved client IP. +Unless it is explicitly selected as the authenticated `ip_header`, Trusted +Server strips `X-Forwarded-For` at the edge. Prefer `Fastly-Client-IP` or a +dedicated `x-` header for the reader address. Integrations that need the address +send their own `X-Forwarded-For` derived from the resolved client IP. ::: warning No-code request routing limitation Fastly no-code request routing does not provide a point to inject these headers. From 5d03b778ab1351d86e4ea1abc294abe9f8fa95d1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 20 Aug 2026 23:14:06 +0530 Subject: [PATCH 20/23] Harden trusted client IP header handling --- .../src/middleware.rs | 61 ++++++++++++++++- .../src/middleware.rs | 62 ++++++++++++++++- .../trusted-server-adapter-fastly/src/app.rs | 16 ++++- .../src/compat.rs | 15 ----- .../src/middleware.rs | 62 ++++++++++++++++- crates/trusted-server-core/src/http_util.rs | 66 +++++++++++++++---- docs/guide/configuration.md | 9 +-- docs/guide/fastly.md | 5 -- .../2026-08-19-trusted-client-ip-header.md | 20 ++++++ ...6-08-19-trusted-client-ip-header-design.md | 26 +++++--- 10 files changed, 290 insertions(+), 52 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 45cbedc2c..e2852ef36 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -7,6 +7,7 @@ use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; +use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; // --------------------------------------------------------------------------- @@ -35,7 +36,11 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { - async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + sanitize_trusted_client_ip_headers( + ctx.request_mut(), + self.settings.trusted_client_ip.as_ref(), + ); let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, &mut response); Ok(response) @@ -111,8 +116,17 @@ pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Respons mod tests { use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + use edgezero_core::body::Body; - use edgezero_core::http::response_builder; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{Method, request_builder, response_builder}; + use edgezero_core::middleware::Next; + use edgezero_core::params::PathParams; + use futures::executor::block_on; + use trusted_server_core::redacted::Redacted; + use trusted_server_core::settings::TrustedClientIpConfig; fn empty_response() -> Response { response_builder() @@ -120,6 +134,17 @@ mod tests { .expect("should build empty test response") } + fn empty_ctx() -> RequestContext { + let req = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-reader-ip", "198.51.100.7") + .header("x-reader-ip-auth", "fictional-shared-secret-0123456789") + .body(Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::new(HashMap::new())) + } + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { let mut s = Settings::from_toml( r#" @@ -197,4 +222,36 @@ mod tests { "should apply operator-configured response headers" ); } + + #[test] + fn finalize_middleware_strips_configured_trust_headers_before_routing() { + let mut settings = settings_with_response_headers(vec![]); + settings.trusted_client_ip = Some(TrustedClientIpConfig { + ip_header: "x-reader-ip".to_owned(), + auth_header: "x-reader-ip-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), + }); + let middleware = FinalizeResponseMiddleware::new(Arc::new(settings)); + let observed = Arc::new(Mutex::new(None)); + let handler_observed = Arc::clone(&observed); + let handler = Arc::new(move |ctx: RequestContext| { + let handler_observed = Arc::clone(&handler_observed); + async move { + *handler_observed.lock().expect("should lock observation") = Some(( + ctx.request().headers().contains_key("x-reader-ip"), + ctx.request().headers().contains_key("x-reader-ip-auth"), + )); + Ok::(empty_response()) + } + }); + + block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should run middleware"); + + assert_eq!( + *observed.lock().expect("should lock observation"), + Some((false, false)), + "should remove both configured trust headers before the handler" + ); + } } diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 5b605bcff..745c793cd 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -7,6 +7,7 @@ use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; +use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; // --------------------------------------------------------------------------- @@ -35,7 +36,7 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { - async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { let geo_available = ctx .request() .headers() @@ -44,6 +45,11 @@ impl Middleware for FinalizeResponseMiddleware { .filter(|s| !s.is_empty() && *s != "XX") .is_some(); + sanitize_trusted_client_ip_headers( + ctx.request_mut(), + self.settings.trusted_client_ip.as_ref(), + ); + let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, geo_available, &mut response); Ok(response) @@ -124,8 +130,17 @@ pub(crate) fn apply_finalize_headers( mod tests { use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + use edgezero_core::body::Body; - use edgezero_core::http::response_builder; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{Method, request_builder, response_builder}; + use edgezero_core::middleware::Next; + use edgezero_core::params::PathParams; + use futures::executor::block_on; + use trusted_server_core::redacted::Redacted; + use trusted_server_core::settings::TrustedClientIpConfig; fn empty_response() -> Response { response_builder() @@ -133,6 +148,17 @@ mod tests { .expect("should build empty test response") } + fn empty_ctx() -> RequestContext { + let req = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-reader-ip", "198.51.100.7") + .header("x-reader-ip-auth", "fictional-shared-secret-0123456789") + .body(Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::new(HashMap::new())) + } + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { // Build from explicit test settings: the settings baked into the // binary contain placeholder secrets that `get_settings()` rejects @@ -230,4 +256,36 @@ mod tests { "should apply operator-configured response headers" ); } + + #[test] + fn finalize_middleware_strips_configured_trust_headers_before_routing() { + let mut settings = settings_with_response_headers(vec![]); + settings.trusted_client_ip = Some(TrustedClientIpConfig { + ip_header: "x-reader-ip".to_owned(), + auth_header: "x-reader-ip-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), + }); + let middleware = FinalizeResponseMiddleware::new(Arc::new(settings)); + let observed = Arc::new(Mutex::new(None)); + let handler_observed = Arc::clone(&observed); + let handler = Arc::new(move |ctx: RequestContext| { + let handler_observed = Arc::clone(&handler_observed); + async move { + *handler_observed.lock().expect("should lock observation") = Some(( + ctx.request().headers().contains_key("x-reader-ip"), + ctx.request().headers().contains_key("x-reader-ip-auth"), + )); + Ok::(empty_response()) + } + }); + + block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should run middleware"); + + assert_eq!( + *observed.lock().expect("should lock observation"), + Some((false, false)), + "should remove both configured trust headers before the handler" + ); + } } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index d6090c983..4b1adcd90 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1971,13 +1971,27 @@ mod tests { server_region: Some("US-East".to_string()), }); - let _ = route(&router, req); + let response = route(&router, req); let observed = captured .lock() .expect("should lock captured client info") .clone() .expect("request filter should have observed the entry-point ClientInfo"); + assert_eq!( + observed.client_ip, + Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), + "request-scoped services should preserve the resolved client IP used by EC" + ); + let finalize = response + .extensions() + .get::() + .expect("fallback response should carry EC finalization state"); + assert_eq!( + finalize.ec_context.client_ip(), + Some("203.0.113.7"), + "EC should capture the resolved client IP from request-scoped services" + ); assert_eq!( observed.tls_protocol.as_deref(), Some("TLSv1.3"), diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs index ad653abb9..5ccd9c423 100644 --- a/crates/trusted-server-adapter-fastly/src/compat.rs +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -185,21 +185,6 @@ mod tests { ); } - #[test] - fn sanitize_fastly_forwarded_headers_strips_client_supplied_forwarded_for() { - let mut req = fastly::Request::get("https://example.com/"); - req.set_header("x-forwarded-for", "198.51.100.99, 10.0.0.1"); - req.set_header("host", "example.com"); - - sanitize_fastly_forwarded_headers(&mut req, None); - - assert!( - req.get_header("x-forwarded-for").is_none(), - "should strip client-supplied x-forwarded-for" - ); - assert!(req.get_header("host").is_some(), "should preserve host"); - } - #[test] fn resolve_and_sanitize_client_ip_reads_trust_headers_before_stripping_them() { let config = trusted_client_ip_config("x-trusted-client-ip"); diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 1bcede1fc..da68b27c8 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -7,6 +7,7 @@ use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; +use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; // --------------------------------------------------------------------------- @@ -34,9 +35,14 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { - async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { let geo_available = false; + sanitize_trusted_client_ip_headers( + ctx.request_mut(), + self.settings.trusted_client_ip.as_ref(), + ); + let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, geo_available, &mut response); Ok(response) @@ -151,8 +157,17 @@ pub(crate) fn apply_finalize_headers( mod tests { use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + use edgezero_core::body::Body; - use edgezero_core::http::response_builder; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{Method, request_builder, response_builder}; + use edgezero_core::middleware::Next; + use edgezero_core::params::PathParams; + use futures::executor::block_on; + use trusted_server_core::redacted::Redacted; + use trusted_server_core::settings::TrustedClientIpConfig; fn empty_response() -> Response { response_builder() @@ -160,6 +175,17 @@ mod tests { .expect("should build empty test response") } + fn empty_ctx() -> RequestContext { + let req = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-reader-ip", "198.51.100.7") + .header("x-reader-ip-auth", "fictional-shared-secret-0123456789") + .body(Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::new(HashMap::new())) + } + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { // Build from explicit test settings: the settings baked into the // binary contain placeholder secrets that `get_settings()` rejects @@ -257,4 +283,36 @@ mod tests { "should apply operator-configured response headers" ); } + + #[test] + fn finalize_middleware_strips_configured_trust_headers_before_routing() { + let mut settings = settings_with_response_headers(vec![]); + settings.trusted_client_ip = Some(TrustedClientIpConfig { + ip_header: "x-reader-ip".to_owned(), + auth_header: "x-reader-ip-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), + }); + let middleware = FinalizeResponseMiddleware::new(Arc::new(settings)); + let observed = Arc::new(Mutex::new(None)); + let handler_observed = Arc::clone(&observed); + let handler = Arc::new(move |ctx: RequestContext| { + let handler_observed = Arc::clone(&handler_observed); + async move { + *handler_observed.lock().expect("should lock observation") = Some(( + ctx.request().headers().contains_key("x-reader-ip"), + ctx.request().headers().contains_key("x-reader-ip-auth"), + )); + Ok::(empty_response()) + } + }); + + block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should run middleware"); + + assert_eq!( + *observed.lock().expect("should lock observation"), + Some((false, false)), + "should remove both configured trust headers before the handler" + ); + } } diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index e9c141166..208e32b25 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -9,7 +9,7 @@ use subtle::ConstantTimeEq as _; use crate::constants::INTERNAL_HEADERS; use crate::error::TrustedServerError; use crate::platform::ClientInfo; -use crate::settings::Settings; +use crate::settings::{Settings, TrustedClientIpConfig}; /// Copy `X-*` custom headers from one request to another, skipping TS-internal headers. /// @@ -39,21 +39,29 @@ pub fn copy_custom_headers(from: &Request, to: &mut Request) /// removes it before routing along with every other listed header. Stripping /// them forces [`RequestInfo::from_request`] to fall back to the trustworthy /// `Host` header and [`ClientInfo`] TLS detection. -/// -/// `x-forwarded-for` is listed because the shared proxy code forwards an inbound -/// value to publisher origins. No adapter has a trustworthy upstream -/// forwarded-for chain, so leaving it would let a client choose the address the -/// origin attributes the request to. Integrations that need the address inject -/// their own `X-Forwarded-For` from [`ClientInfo::client_ip`] instead. pub const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ "forwarded", "x-forwarded-host", "x-forwarded-proto", - "x-forwarded-for", "fastly-ssl", "fastly-client-ip", ]; +/// Remove the configured client-IP trust headers before routing. +/// +/// Only the Fastly adapter consumes these values, but every adapter removes +/// them so a shared configuration cannot expose an authentication secret to +/// publisher or integration request handling. +pub fn sanitize_trusted_client_ip_headers( + req: &mut Request, + config: Option<&TrustedClientIpConfig>, +) { + if let Some(config) = config { + req.headers_mut().remove(config.ip_header.as_str()); + req.headers_mut().remove(config.auth_header.as_str()); + } +} + /// Strip forwarded headers that clients can spoof. /// /// Call this at the edge entry point (before routing) to prevent @@ -471,6 +479,8 @@ pub fn enforce_max_body_size( mod tests { use super::*; use crate::platform::ClientInfo; + use crate::redacted::Redacted; + use crate::settings::TrustedClientIpConfig; use http::{HeaderName, HeaderValue, Method}; fn build_request(method: Method, uri: &str) -> Request { @@ -670,6 +680,41 @@ mod tests { // Sanitization tests + #[test] + fn sanitize_trusted_client_ip_headers_removes_only_configured_headers() { + let config = TrustedClientIpConfig { + ip_header: "x-reader-ip".to_owned(), + auth_header: "x-reader-ip-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), + }; + let mut req = build_request(Method::GET, "https://example.com/page"); + set_header(&mut req, "x-reader-ip", "198.51.100.7"); + set_header( + &mut req, + "x-reader-ip-auth", + "fictional-shared-secret-0123456789", + ); + set_header(&mut req, "x-unrelated", "preserved"); + + sanitize_trusted_client_ip_headers(&mut req, Some(&config)); + + assert!( + req.headers().get("x-reader-ip").is_none(), + "should remove the configured IP header" + ); + assert!( + req.headers().get("x-reader-ip-auth").is_none(), + "should remove the configured authentication header" + ); + assert_eq!( + req.headers() + .get("x-unrelated") + .expect("should preserve an unrelated header"), + "preserved", + "should not remove unrelated headers" + ); + } + #[test] fn sanitize_removes_all_spoofable_headers() { let mut req = build_request(Method::GET, "https://example.com/page"); @@ -677,7 +722,6 @@ mod tests { set_header(&mut req, "forwarded", "host=evil.com;proto=https"); set_header(&mut req, "x-forwarded-host", "evil.com"); set_header(&mut req, "x-forwarded-proto", "https"); - set_header(&mut req, "x-forwarded-for", "198.51.100.99, 10.0.0.1"); set_header(&mut req, "fastly-ssl", "1"); sanitize_forwarded_headers(&mut req); @@ -694,10 +738,6 @@ mod tests { req.headers().get("x-forwarded-proto").is_none(), "should strip X-Forwarded-Proto header" ); - assert!( - req.headers().get("x-forwarded-for").is_none(), - "should strip client-supplied X-Forwarded-For header" - ); assert!( req.headers().get("fastly-ssl").is_none(), "should strip Fastly-SSL header" diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c0fc50d2e..b5a2c66c8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -403,10 +403,11 @@ configuration debug output. Configuration requires at least 32 ASCII graphic bytes (`!` through `~`) with no whitespace, controls, DEL, or non-ASCII bytes, and startup fails when the value is still the documented placeholder. -Unless `X-Forwarded-For` is explicitly selected as the authenticated -`ip_header`, Trusted Server strips it at the edge. Prefer `Fastly-Client-IP` or a -dedicated `x-` header for the reader address so `X-Forwarded-For` retains its -usual forwarding semantics. +Redaction protects debug output and validation errors; it does not move the +value into a platform secret store. `ts config push` serializes the value in the +Trusted Server application-config blob, so restrict access to that configuration +store. Every adapter removes the configured IP and authentication headers before +routing, although only Fastly uses them for client-IP resolution. **Environment Overrides**: diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 443382367..a78458ab3 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -123,11 +123,6 @@ required. Trusted Server removes both trust headers before routing. Direct requests and requests with missing, invalid, or duplicated trust headers remain available and use the immediate peer address instead. -Unless it is explicitly selected as the authenticated `ip_header`, Trusted -Server strips `X-Forwarded-For` at the edge. Prefer `Fastly-Client-IP` or a -dedicated `x-` header for the reader address. Integrations that need the address -send their own `X-Forwarded-For` derived from the resolved client IP. - ::: warning No-code request routing limitation Fastly no-code request routing does not provide a point to inject these headers. If that routing path does not preserve the original reader IP, Trusted Server diff --git a/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md index 8576213b8..5bb7ffe62 100644 --- a/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md +++ b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md @@ -1,5 +1,25 @@ # Trusted Client IP Header Implementation Plan +## Review remediation + +- [x] Add a shared helper that removes the configured IP and authentication + headers from a core request, with a failing unit test proving both names + are removed while unrelated headers remain. +- [x] Invoke that helper from the outer request middleware in the Axum, + Cloudflare, and Spin adapters so a shared multi-adapter configuration + cannot expose either trust header to routing or integrations. +- [x] Add a regression assertion proving the Fastly entry-point `ClientInfo` + address reaches request-scoped services, which are the EC input. +- [x] Remove the partial `X-Forwarded-For` hardening from this PR and its + documentation. Handle trusted XFF reconstruction consistently across all + adapters in a separate change. +- [x] Document that redaction protects logs and debug output but the secret is + serialized into the Trusted Server application-config blob. +- [x] Retain `/.worktrees/` because this checkout has an active unrelated + worktree under that path; removing the ignore would expose its contents. +- [x] Run formatting plus Fastly, Axum, Cloudflare, and Spin tests and + target-matched Clippy checks. + ## Review follow-up: header-safe shared secrets PR review found that the request path reads the authentication field with diff --git a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md index e63631aee..125be4a89 100644 --- a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md +++ b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md @@ -33,7 +33,9 @@ Related issues: #1040 and #1041. - Automatically infer whether a request came from another Fastly service. - Trust `Fastly-Client-IP`, `Fastly-FF`, or `X-Forwarded-For` by presence. -- Change client-IP handling in Cloudflare, Spin, or Axum adapters. +- Change client-IP resolution in Cloudflare, Spin, or Axum adapters. Those + adapters still remove configured trust headers so a shared configuration + cannot expose the authentication value downstream. - Add rotating or time-limited request signatures in this change. - Make Fastly no-code request routing preserve a value it does not expose. @@ -108,13 +110,11 @@ is stripped even when the feature is not configured. A configured header is read before sanitization and removed explicitly, so configuring `fastly-client-ip` remains valid. -`X-Forwarded-For` joins the same list. The shared proxy code forwards an inbound -value to publisher origins, so leaving it would let a client choose the address -the origin attributes the request to while Trusted Server itself used the -authenticated one. The Spin adapter already stripped it for this reason; moving -the rule into the shared list closes the equivalent gap on Fastly without -changing Spin behaviour. Integrations that need the address continue to send -their own `X-Forwarded-For` derived from the resolved client IP. +The core request sanitizer removes the two configured trust headers on every +adapter. Only Fastly consumes them, but applying the removal invariant across +adapters prevents a shared multi-platform configuration from forwarding an +authentication value to routing or integrations. Cross-adapter treatment of +client-supplied `X-Forwarded-For` is separate from this feature. Authentication failures do not log supplied secrets or IP values. A debug-level message may record only the reason category (missing authentication, mismatch, @@ -143,6 +143,10 @@ its result with request services and response geo finalization. `trusted-server-adapter-fastly/src/compat.rs` removes the configured dynamic headers and continues applying the static spoofable-header list. +The Axum, Cloudflare, and Spin outer request middleware invokes the shared core +sanitizer before routing. Those adapters do not use this configuration to +resolve their client address. + No core EC, consent, auction, or integration logic changes: those consumers already use `RuntimeServices::client_info().client_ip` correctly. @@ -153,6 +157,11 @@ Trusted Server. It must never preserve caller-provided values. The shared secret must be generated randomly, stored in both the fronting service and Trusted Server configuration, and excluded from responses and origin requests. +`Redacted` prevents the secret from appearing in debug output and +validation errors; it does not move the value into a platform secret store. The +secret is serialized in the Trusted Server application-config blob, so access +to that configuration store must be restricted. + This design protects against callers that can reach the Trusted Server hostname and inject an arbitrary IP header, provided they do not know the shared secret. It does not provide replay protection: any party that learns the static secret @@ -174,6 +183,7 @@ Tests follow red-green-refactor and cover: - whitespace-padded, port-bearing, zone-qualified, comma-separated, non-UTF-8, empty, or duplicate IP input falls back to the peer IP; - configured headers are removed after resolution; +- configured headers are removed by every adapter before routing; - `Fastly-Client-IP` is stripped when configuration is absent; - settings parse, validation, secret redaction, and default behavior; - a 31-byte secret is rejected and a 32-byte ASCII-graphic secret is accepted; From e080e6e065b1a2c29425c8418cbf25304518b5cb Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 21 Aug 2026 10:47:37 +0530 Subject: [PATCH 21/23] Clarify trusted client IP VCL setup --- docs/guide/fastly.md | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index a78458ab3..6a37a263a 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -82,33 +82,44 @@ authentication headers on every backend request. Preserving values supplied by the browser is unsafe because a caller could choose the IP used for geolocation and other request processing. -For a VCL-to-Compute [service chain](https://www.fastly.com/documentation/guides/getting-started/services/service-chaining/), -overwrite [`Fastly-Client-IP`](https://www.fastly.com/documentation/reference/http/http-headers/Fastly-Client-IP/) +For a dedicated VCL-to-Compute [service chain](https://www.fastly.com/documentation/guides/getting-started/services/service-chaining/), +where every request from the VCL service goes to Trusted Server, overwrite +[`Fastly-Client-IP`](https://www.fastly.com/documentation/reference/http/http-headers/Fastly-Client-IP/) from the initial [`client.ip`](https://www.fastly.com/documentation/reference/vcl/variables/client-connection/client-ip/) -and set a dedicated authentication header in the fronting service. For example: +and set a dedicated authentication header in the fronting service. Read the +secret from a private (write-only) edge dictionary instead of placing it in the +VCL source. For example: ```vcl sub vcl_recv { if (fastly.ff.visits_this_service == 0 && req.restarts == 0) { unset req.http.Fastly-Client-IP; + unset req.http.X-TS-Client-IP-Auth; + set req.http.Fastly-Client-IP = client.ip; + set req.http.X-TS-Client-IP-Auth = + table.lookup(ts_private_config, "trusted_client_ip_secret"); } - unset req.http.X-TS-Client-IP-Auth; - set req.http.X-TS-Client-IP-Auth = "replace-with-a-random-shared-secret"; } ``` +In this example, attach an edge dictionary named `ts_private_config` to the +fronting VCL service and store the shared secret under the key +`trusted_client_ip_secret`. If the service also routes to other backends, wrap +the Trusted Server header setup in the same host or path condition that selects +Trusted Server. Strip any client-supplied authentication header on the other +routes, and do not send the dictionary value to unrelated backends. + The `unset` before each `set` matters. Trusted Server ignores a forwarded address whenever either trust header carries more than one value, so a client that sends its own copy of either header could otherwise force the fallback and keep its real address out of geolocation and bot protection. Use a cryptographically random secret of at least 32 ASCII graphic bytes in -production, encoded as hex or base64url with no whitespace. The value above is -a placeholder that Trusted Server rejects at startup. Keep it in a private edge -dictionary rather than inlining it in VCL, where it is readable by anyone with -service-configuration access and preserved in every version diff. Configure the -identical header names and secret in Trusted Server: +production, encoded as hex or base64url with no whitespace. Keep the fronting +copy in a private edge dictionary rather than inlining it in VCL, where it is +readable by anyone with service-configuration access and preserved in every +version diff. Configure the identical header names and secret in Trusted Server: ```toml [trusted_client_ip] @@ -117,12 +128,21 @@ auth_header = "x-ts-client-ip-auth" shared_secret = "replace-with-a-random-shared-secret" ``` +The `shared_secret` value above is an intentionally invalid placeholder. Replace +it with the exact value stored in the fronting service's edge dictionary. + `Fastly-Client-IP` is not protected from modification when it first enters Fastly, which is why overwriting it and authenticating the handoff are both required. Trusted Server removes both trust headers before routing. Direct requests and requests with missing, invalid, or duplicated trust headers remain available and use the immediate peer address instead. +The VCL example assumes that the reader connects directly to the fronting +Fastly service. If another CDN is in front, `client.ip` identifies that CDN's +node instead. In that topology, restrict direct access to the Fastly front door, +derive the IP header from the upstream CDN's protected reader-IP value, and +still overwrite both trust headers before the request enters Trusted Server. + ::: warning No-code request routing limitation Fastly no-code request routing does not provide a point to inject these headers. If that routing path does not preserve the original reader IP, Trusted Server From 3ea0561f5f6f551222be264a65e29b937a379540 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 22 Aug 2026 12:01:38 +0530 Subject: [PATCH 22/23] Omit unset trusted client IP config from serialized settings `#[serde(default)]` only affects deserialization, so an unconfigured `trusted_client_ip` was still written as `"trusted_client_ip": null` into every blob produced by `ts config push`. `Settings` uses `deny_unknown_fields`, so pushing an otherwise unchanged config before upgrading all instances - or rolling back after such a push - made older instances reject the blob even though the feature was never enabled. Skip serialization when the field is `None`, matching the existing `AuctionConfig` rollback-compatibility pattern, and document that a configured section requires restoring a compatible blob before rollback. Add regression tests proving a default payload omits the key and stays readable by a schema matching the base revision. --- crates/trusted-server-core/src/settings.rs | 77 +++++++++++++++++++++- docs/guide/configuration.md | 9 ++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 62e577849..9f920fed9 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2023,7 +2023,14 @@ pub struct Settings { #[serde(default)] pub tester_cookie: TesterCookieConfig, /// Optional authenticated trusted client IP forwarding configuration. - #[serde(default)] + /// + /// `None` must stay omitted from serialized config blobs: `Settings` + /// schemas that predate this field reject unknown keys, so emitting + /// `trusted_client_ip: null` would make an unchanged `ts config push` + /// break older instances during rollout or rollback. A configured value + /// remains serialized and requires restoring a compatible blob before + /// rolling back. + #[serde(default, skip_serializing_if = "Option::is_none")] #[validate(nested)] pub trusted_client_ip: Option, #[serde(default)] @@ -2780,6 +2787,74 @@ mod tests { ); } + /// Mirrors the `Settings` schema of the revision that predates + /// `trusted_client_ip`: every key that revision knew, and + /// `deny_unknown_fields` so an extra key fails deserialization exactly as an + /// older binary would reject a pushed config blob. + // The fields exist to model the accepted key set, never to be read. + #[allow(dead_code)] + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct BaseRevisionSettings { + #[serde(default)] + publisher: serde::de::IgnoredAny, + #[serde(default)] + tester_cookie: serde::de::IgnoredAny, + #[serde(default)] + ec: serde::de::IgnoredAny, + #[serde(default)] + integrations: serde::de::IgnoredAny, + #[serde(default)] + handlers: serde::de::IgnoredAny, + #[serde(default)] + response_headers: serde::de::IgnoredAny, + #[serde(default)] + request_signing: serde::de::IgnoredAny, + #[serde(default)] + rewrite: serde::de::IgnoredAny, + #[serde(default)] + auction: serde::de::IgnoredAny, + #[serde(default)] + consent: serde::de::IgnoredAny, + #[serde(default)] + proxy: serde::de::IgnoredAny, + #[serde(default)] + creative_opportunities: serde::de::IgnoredAny, + #[serde(default)] + image_optimizer: serde::de::IgnoredAny, + #[serde(default)] + tinybird: serde::de::IgnoredAny, + #[serde(default)] + debug: serde::de::IgnoredAny, + } + + #[test] + fn trusted_client_ip_is_omitted_from_serialized_config_when_unset() { + // `ts config push` serializes `Settings` verbatim. Emitting the key — + // even as `null` — makes a `deny_unknown_fields` binary from the base + // revision reject the blob during rollout or rollback. + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without trusted client IP configuration"); + + let value = serde_json::to_value(&settings).expect("should serialize settings"); + + assert!( + value.get("trusted_client_ip").is_none(), + "unset trusted_client_ip should not be serialized, got {value}" + ); + } + + #[test] + fn serialized_default_config_stays_readable_by_the_base_revision_schema() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without trusted client IP configuration"); + + let value = serde_json::to_value(&settings).expect("should serialize settings"); + + serde_json::from_value::(value) + .expect("base revision schema should accept a config blob with no trusted client IP"); + } + #[test] fn trusted_client_ip_parses_and_redacts_shared_secret_in_debug_output() { let settings = Settings::from_toml(&trusted_client_ip_toml( diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index aa58618b6..5c01fb08d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -370,7 +370,14 @@ it but keep using their own runtime client address. | `shared_secret` | String | Yes | Secret shared with the trusted front door, 32+ ASCII graphic bytes, no whitespace | All three fields are required when the section exists. When the section is -absent, Trusted Server continues to use the immediate peer address. +absent, Trusted Server continues to use the immediate peer address, and +`ts config push` omits the section from the published config blob so instances +running an older binary keep accepting the blob. + +Once the section is configured, the pushed blob carries it. Instances running a +binary that predates trusted client-IP support reject that blob, so upgrade +every instance before pushing a config that enables this section, and restore a +config without the section before rolling instances back. ```toml [trusted_client_ip] From 3329711dbd7724d0c16178d2ec9d79518852ef90 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 24 Aug 2026 10:26:47 +0530 Subject: [PATCH 23/23] Address trusted client IP review feedback Reserve every Trusted Server internal header name for the trusted client-IP configuration instead of only the two TLS bridge names, so a configured trust header cannot collide with an internal signal such as x-forwarded-for or x-geo-info-available. Move the trust-header strip out of FinalizeResponseMiddleware into a dedicated SanitizeRequestMiddleware on the Cloudflare, Spin, and Axum adapters, registered outermost with the ordering requirement stated at each registration site. The security invariant no longer hides inside a response-finalization middleware. Drop the duplicate /.worktrees/ gitignore entry added by this branch; line 41 already ignores that path. --- .gitignore | 1 - crates/trusted-server-adapter-axum/src/app.rs | 7 ++- .../src/middleware.rs | 53 ++++++++++++++---- .../src/app.rs | 7 ++- .../src/middleware.rs | 54 +++++++++++++++---- crates/trusted-server-adapter-spin/src/app.rs | 9 +++- .../src/middleware.rs | 54 +++++++++++++++---- crates/trusted-server-core/src/settings.rs | 12 +++-- docs/guide/configuration.md | 11 ++-- 9 files changed, 164 insertions(+), 44 deletions(-) diff --git a/.gitignore b/.gitignore index b71945639..24b9e06aa 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,6 @@ src/*.html /guest-profiles /benchmark-results/** -/.worktrees/ # Playwright browser tests /crates/trusted-server-integration-tests/browser/node_modules/ diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 4b71d07ce..9a371f805 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -37,7 +37,7 @@ use trusted_server_core::settings_data::{ use trusted_server_core::platform::RuntimeServices; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::{AxumPlatformConfigStore, build_runtime_services}; // --------------------------------------------------------------------------- @@ -600,6 +600,11 @@ fn build_router(state: &Arc) -> RouterService { let fallback = fallback_handler(Arc::clone(state)); let mut router = RouterService::builder() + // Outermost middleware: strips the configured trusted-client-IP + // headers before anything else sees the request. Must stay first — + // any middleware registered ahead of it would observe the + // shared-secret authentication header. + .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index e2852ef36..9f00f7614 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -10,18 +10,55 @@ use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; +// --------------------------------------------------------------------------- +// SanitizeRequestMiddleware +// --------------------------------------------------------------------------- + +/// Outermost middleware: strips the configured client-IP trust headers from the +/// request before any inner middleware or handler observes them. +/// +/// Must stay the first middleware registered in [`crate::app`]. Registering +/// another middleware ahead of it would re-expose the shared-secret +/// authentication header to request handling. Only the Fastly adapter consumes +/// these headers for client-IP resolution; every other adapter removes them so +/// a shared configuration cannot leak the secret into publisher or integration +/// request handling. +pub struct SanitizeRequestMiddleware { + settings: Arc, +} + +impl SanitizeRequestMiddleware { + /// Creates a new [`SanitizeRequestMiddleware`] with the given settings. + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for SanitizeRequestMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + sanitize_trusted_client_ip_headers( + ctx.request_mut(), + self.settings.trusted_client_ip.as_ref(), + ); + next.run(ctx).await + } +} + // --------------------------------------------------------------------------- // FinalizeResponseMiddleware // --------------------------------------------------------------------------- -/// Outermost middleware: injects all standard TS response headers. +/// Response-finalization middleware: injects all standard TS response headers. /// /// Geo lookup is unavailable in the Axum dev server — `X-Geo-Info-Available: false` /// is always emitted. Fastly-specific headers (`X-TS-Version`, `X-TS-ENV`) are /// skipped because the corresponding env vars are not set in a local dev context. /// -/// Registered first in the middleware chain so that every outgoing response — -/// including auth-rejected ones — carries a consistent set of headers. +/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of +/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected +/// ones — carries a consistent set of headers. pub struct FinalizeResponseMiddleware { settings: Arc, } @@ -36,11 +73,7 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { - async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - sanitize_trusted_client_ip_headers( - ctx.request_mut(), - self.settings.trusted_client_ip.as_ref(), - ); + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, &mut response); Ok(response) @@ -224,14 +257,14 @@ mod tests { } #[test] - fn finalize_middleware_strips_configured_trust_headers_before_routing() { + fn sanitize_middleware_strips_configured_trust_headers_before_routing() { let mut settings = settings_with_response_headers(vec![]); settings.trusted_client_ip = Some(TrustedClientIpConfig { ip_header: "x-reader-ip".to_owned(), auth_header: "x-reader-ip-auth".to_owned(), shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), }); - let middleware = FinalizeResponseMiddleware::new(Arc::new(settings)); + let middleware = SanitizeRequestMiddleware::new(Arc::new(settings)); let observed = Arc::new(Mutex::new(None)); let handler_observed = Arc::clone(&observed); let handler = Arc::new(move |ctx: RequestContext| { diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 86ac86987..6ce0a5ee3 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -36,7 +36,7 @@ use trusted_server_core::request_signing::{ }; use trusted_server_core::settings::Settings; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::build_runtime_services; // --------------------------------------------------------------------------- @@ -462,6 +462,11 @@ fn build_router(state: &Arc) -> RouterService { }; let mut router = RouterService::builder() + // Outermost middleware: strips the configured trusted-client-IP + // headers before anything else sees the request. Must stay first — + // any middleware registered ahead of it would observe the + // shared-secret authentication header. + .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) .get( diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 745c793cd..f2b3374c4 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -10,18 +10,55 @@ use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; +// --------------------------------------------------------------------------- +// SanitizeRequestMiddleware +// --------------------------------------------------------------------------- + +/// Outermost middleware: strips the configured client-IP trust headers from the +/// request before any inner middleware or handler observes them. +/// +/// Must stay the first middleware registered in [`crate::app`]. Registering +/// another middleware ahead of it would re-expose the shared-secret +/// authentication header to request handling. Only the Fastly adapter consumes +/// these headers for client-IP resolution; every other adapter removes them so +/// a shared configuration cannot leak the secret into publisher or integration +/// request handling. +pub struct SanitizeRequestMiddleware { + settings: Arc, +} + +impl SanitizeRequestMiddleware { + /// Creates a new [`SanitizeRequestMiddleware`] with the given settings. + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for SanitizeRequestMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + sanitize_trusted_client_ip_headers( + ctx.request_mut(), + self.settings.trusted_client_ip.as_ref(), + ); + next.run(ctx).await + } +} + // --------------------------------------------------------------------------- // FinalizeResponseMiddleware // --------------------------------------------------------------------------- -/// Outermost middleware: injects all standard TS response headers. +/// Response-finalization middleware: injects all standard TS response headers. /// /// Geo availability is determined by the presence of the `cf-ipcountry` header /// (injected by the Cloudflare Workers runtime). On the native host target the /// header is absent, so `X-Geo-Info-Available: false` is emitted. /// -/// Registered first in the middleware chain so that every outgoing response — -/// including auth-rejected ones — carries a consistent set of headers. +/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of +/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected +/// ones — carries a consistent set of headers. pub struct FinalizeResponseMiddleware { settings: Arc, } @@ -36,7 +73,7 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { - async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { let geo_available = ctx .request() .headers() @@ -45,11 +82,6 @@ impl Middleware for FinalizeResponseMiddleware { .filter(|s| !s.is_empty() && *s != "XX") .is_some(); - sanitize_trusted_client_ip_headers( - ctx.request_mut(), - self.settings.trusted_client_ip.as_ref(), - ); - let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, geo_available, &mut response); Ok(response) @@ -258,14 +290,14 @@ mod tests { } #[test] - fn finalize_middleware_strips_configured_trust_headers_before_routing() { + fn sanitize_middleware_strips_configured_trust_headers_before_routing() { let mut settings = settings_with_response_headers(vec![]); settings.trusted_client_ip = Some(TrustedClientIpConfig { ip_header: "x-reader-ip".to_owned(), auth_header: "x-reader-ip-auth".to_owned(), shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), }); - let middleware = FinalizeResponseMiddleware::new(Arc::new(settings)); + let middleware = SanitizeRequestMiddleware::new(Arc::new(settings)); let observed = Arc::new(Mutex::new(None)); let handler_observed = Arc::clone(&observed); let handler = Arc::new(move |ctx: RequestContext| { diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 06bb1a15a..f24b5b717 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -35,7 +35,9 @@ use trusted_server_core::request_signing::{ }; use trusted_server_core::settings::Settings; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware}; +use crate::middleware::{ + AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware, SanitizeRequestMiddleware, +}; use crate::platform::build_runtime_services; // --------------------------------------------------------------------------- @@ -766,6 +768,11 @@ fn build_router(state: &Arc) -> RouterService { |_ctx: RequestContext| async { Ok::(legacy_admin_alias_denied()) }; let mut builder = RouterService::builder() + // Outermost middleware: strips the configured trusted-client-IP + // headers before anything else sees the request. Must stay first — + // any middleware registered ahead of it would observe the + // shared-secret authentication header. + .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) // Innermost middleware: normalize every routed request (strip diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index da68b27c8..d3005d510 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -10,17 +10,54 @@ use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; +// --------------------------------------------------------------------------- +// SanitizeRequestMiddleware +// --------------------------------------------------------------------------- + +/// Outermost middleware: strips the configured client-IP trust headers from the +/// request before any inner middleware or handler observes them. +/// +/// Must stay the first middleware registered in [`crate::app`]. Registering +/// another middleware ahead of it would re-expose the shared-secret +/// authentication header to request handling. Only the Fastly adapter consumes +/// these headers for client-IP resolution; every other adapter removes them so +/// a shared configuration cannot leak the secret into publisher or integration +/// request handling. +pub struct SanitizeRequestMiddleware { + settings: Arc, +} + +impl SanitizeRequestMiddleware { + /// Creates a new [`SanitizeRequestMiddleware`] with the given settings. + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for SanitizeRequestMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + sanitize_trusted_client_ip_headers( + ctx.request_mut(), + self.settings.trusted_client_ip.as_ref(), + ); + next.run(ctx).await + } +} + // --------------------------------------------------------------------------- // FinalizeResponseMiddleware // --------------------------------------------------------------------------- -/// Outermost middleware: injects all standard TS response headers. +/// Response-finalization middleware: injects all standard TS response headers. /// /// Spin does not expose geo headers to the application, so /// `X-Geo-Info-Available: false` is emitted for every response. /// -/// Registered first in the middleware chain so that every outgoing response — -/// including auth-rejected ones — carries a consistent set of headers. +/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of +/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected +/// ones — carries a consistent set of headers. pub struct FinalizeResponseMiddleware { settings: Arc, } @@ -35,14 +72,9 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { - async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { let geo_available = false; - sanitize_trusted_client_ip_headers( - ctx.request_mut(), - self.settings.trusted_client_ip.as_ref(), - ); - let mut response = next.run(ctx).await?; apply_finalize_headers(&self.settings, geo_available, &mut response); Ok(response) @@ -285,14 +317,14 @@ mod tests { } #[test] - fn finalize_middleware_strips_configured_trust_headers_before_routing() { + fn sanitize_middleware_strips_configured_trust_headers_before_routing() { let mut settings = settings_with_response_headers(vec![]); settings.trusted_client_ip = Some(TrustedClientIpConfig { ip_header: "x-reader-ip".to_owned(), auth_header: "x-reader-ip-auth".to_owned(), shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), }); - let middleware = FinalizeResponseMiddleware::new(Arc::new(settings)); + let middleware = SanitizeRequestMiddleware::new(Arc::new(settings)); let observed = Arc::new(Mutex::new(None)); let handler_observed = Arc::clone(&observed); let handler = Arc::new(move |ctx: RequestContext| { diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index c56b01787..ae80f0194 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -18,6 +18,7 @@ use validator::{Validate, ValidationError}; use crate::auction_config_types::AuctionConfig; use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; +use crate::constants::INTERNAL_HEADERS; use crate::creative_opportunities::CreativeOpportunitiesConfig; use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; @@ -2670,7 +2671,7 @@ fn validate_trusted_client_ip(config: &TrustedClientIpConfig) -> Result<(), Vali } for header in [&ip_header, &auth_header] { - if matches!(header.as_str(), "x-ts-tls-protocol" | "x-ts-tls-cipher") { + if INTERNAL_HEADERS.contains(&header.as_str()) { return Err(ValidationError::new("reserved_trusted_client_ip_header")); } } @@ -3690,23 +3691,26 @@ mod tests { } #[test] - fn trusted_client_ip_rejects_reserved_tls_bridge_headers() { + fn trusted_client_ip_rejects_reserved_internal_headers() { for (ip_header, auth_header) in [ ("x-ts-tls-protocol", "x-trusted-client-auth"), ("x-ts-tls-cipher", "x-trusted-client-auth"), ("fastly-client-ip", "x-ts-tls-protocol"), ("fastly-client-ip", "x-ts-tls-cipher"), + ("x-forwarded-for", "x-trusted-client-auth"), + ("x-geo-info-available", "x-trusted-client-auth"), + ("fastly-client-ip", "x-ts-ec"), ] { let error = Settings::from_toml(&trusted_client_ip_toml( ip_header, auth_header, "fictional-shared-secret-0123456789", )) - .expect_err("should reject reserved TLS bridge headers"); + .expect_err("should reject reserved internal headers"); assert!( format!("{error:?}").contains("reserved_trusted_client_ip_header"), - "should identify reserved TLS bridge headers" + "should identify reserved internal headers" ); } } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 027c8dae2..35bb7a21f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -397,10 +397,13 @@ immediate peer address. Both configured headers are removed before routing. Header names are validated case-insensitively. `ip_header` must be `fastly-client-ip` or start with `x-`, while `auth_header` must start with `x-`. -The names must differ. Neither field may use the reserved -`x-ts-tls-protocol` or `x-ts-tls-cipher` header. These restrictions exclude -standard sensitive headers such as `Host`, `Content-Length`, `Cookie`, and -`Authorization`, as well as Trusted Server's TLS bridge headers. Choose +The names must differ. Neither field may use a header name reserved for +Trusted Server's own internal signals (for example `x-forwarded-for`, +`x-geo-info-available`, `x-ts-ec`, `x-ts-tls-protocol`, or `x-ts-tls-cipher`); +the full reserved set is the internal-header list that Trusted Server strips +before forwarding to third parties. These restrictions exclude standard +sensitive headers such as `Host`, `Content-Length`, `Cookie`, and +`Authorization`, as well as every Trusted Server internal header. Choose dedicated `x-` names that no other application or routing logic uses, because Trusted Server removes the configured headers before routing.