Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e2f1f63
Document trusted client IP header design
prk-Jr Aug 19, 2026
d940723
Clarify trusted header validation
prk-Jr Aug 19, 2026
932b9f8
Plan trusted client IP implementation
prk-Jr Aug 19, 2026
209bea2
Ignore local worktrees
prk-Jr Aug 19, 2026
3d542ff
Add trusted client IP configuration
prk-Jr Aug 19, 2026
6b7db93
Differentiate trusted client IP header errors
prk-Jr Aug 19, 2026
01c2c47
Resolve authenticated forwarded client IP
prk-Jr Aug 19, 2026
6314947
Use resolved client IP for middleware geo
prk-Jr Aug 19, 2026
bf2d1b2
Preserve authoritative client IP absence
prk-Jr Aug 19, 2026
99f8311
Document trusted client IP forwarding
prk-Jr Aug 19, 2026
5cb94e6
Clarify trusted header name restrictions
prk-Jr Aug 19, 2026
53dbaee
Clarify Fastly client IP sanitization order
prk-Jr Aug 19, 2026
800e40f
Strengthen trusted client IP shared secret validation
prk-Jr Aug 19, 2026
7d4da82
Strip client-supplied X-Forwarded-For at the edge
prk-Jr Aug 19, 2026
87d9697
Resolve the trusted client IP behind one sanitizing call
prk-Jr Aug 19, 2026
a8694da
Clarify trusted client IP secret validation
prk-Jr Aug 20, 2026
57c0aa6
Merge remote-tracking branch 'origin/main' into fix/trusted-client-ip…
prk-Jr Aug 20, 2026
b0c88ab
Validate trusted client IP shared secrets
prk-Jr Aug 20, 2026
2a7052e
Record trusted client IP verification
prk-Jr Aug 20, 2026
2cd4826
Clarify forwarded client IP header guidance
prk-Jr Aug 20, 2026
5d03b77
Harden trusted client IP header handling
prk-Jr Aug 20, 2026
e080e6e
Clarify trusted client IP VCL setup
prk-Jr Aug 21, 2026
ab4abcb
Merge branch 'main' into fix/trusted-client-ip-header
prk-Jr Aug 21, 2026
3ea0561
Omit unset trusted client IP config from serialized settings
prk-Jr Aug 22, 2026
8aaefb7
Merge branch 'main' into fix/trusted-client-ip-header
prk-Jr Aug 22, 2026
55d1702
Merge branch 'main' into fix/trusted-client-ip-header
prk-Jr Aug 24, 2026
3329711
Address trusted client IP review feedback
prk-Jr Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -600,6 +600,11 @@ fn build_router(state: &Arc<AppState>) -> 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)));

Expand Down
98 changes: 94 additions & 4 deletions crates/trusted-server-adapter-axum/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,58 @@ 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;

// ---------------------------------------------------------------------------
// 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<Settings>,
}

impl SanitizeRequestMiddleware {
/// Creates a new [`SanitizeRequestMiddleware`] with the given settings.
#[must_use]
pub fn new(settings: Arc<Settings>) -> Self {
Self { settings }
}
}

#[async_trait(?Send)]
impl Middleware for SanitizeRequestMiddleware {
async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result<Response, EdgeError> {
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<Settings>,
}
Expand Down Expand Up @@ -111,15 +149,35 @@ 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()
.body(Body::empty())
.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#"
Expand Down Expand Up @@ -197,4 +255,36 @@ mod tests {
"should apply operator-configured response headers"
);
}

#[test]
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 = 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| {
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::<Response, EdgeError>(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"
);
}
}
7 changes: 6 additions & 1 deletion crates/trusted-server-adapter-cloudflare/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -462,6 +462,11 @@ fn build_router(state: &Arc<AppState>) -> 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(
Expand Down
98 changes: 94 additions & 4 deletions crates/trusted-server-adapter-cloudflare/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,58 @@ 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;

// ---------------------------------------------------------------------------
// 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<Settings>,
}

impl SanitizeRequestMiddleware {
/// Creates a new [`SanitizeRequestMiddleware`] with the given settings.
#[must_use]
pub fn new(settings: Arc<Settings>) -> Self {
Self { settings }
}
}

#[async_trait(?Send)]
impl Middleware for SanitizeRequestMiddleware {
async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result<Response, EdgeError> {
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<Settings>,
}
Expand Down Expand Up @@ -124,15 +162,35 @@ 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()
.body(Body::empty())
.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
Expand Down Expand Up @@ -230,4 +288,36 @@ mod tests {
"should apply operator-configured response headers"
);
}

#[test]
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 = 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| {
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::<Response, EdgeError>(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"
);
}
}
16 changes: 15 additions & 1 deletion crates/trusted-server-adapter-fastly/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2195,13 +2195,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::<super::EcFinalizeState>()
.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"),
Expand Down
Loading
Loading