From 847e5ca586f0cf80dcf4fdbfeadd38ea099f508b Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 31 Aug 2026 00:43:39 +0000 Subject: [PATCH] test(adapters): wire tests for same-host/cross-host/hop-cap redirect policy (CLI-02) - same-host 302 followed with the credential header arriving at the followed hop (FWD-03's scrub is cross-host only) - cross-host 302 surfaced as HTTP_302 with the attacker endpoint receiving zero requests (load-bearing FWD-03 property, pinned) - redirect loop trips the hop cap -> loud INTERNAL transport error --- src/adapters/forward.rs | 130 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src/adapters/forward.rs b/src/adapters/forward.rs index cb5a4b6..5bc6c33 100644 --- a/src/adapters/forward.rs +++ b/src/adapters/forward.rs @@ -2825,4 +2825,134 @@ mod tests { "one byte past a full buffer still trips before extend" ); } + + /// CLI-02 (a): a same-host 302 is followed, and the credential + /// header is forwarded to the followed hop — the FWD-03 policy only + /// scrubs headers across *cross-host* hops, so a same-origin hop + /// must stay authenticated end to end. + #[tokio::test] + async fn same_host_redirect_is_followed_with_credential_forwarding() { + let hits = TestArc::new(std::sync::atomic::AtomicU32::new(0)); + let hit_counter = TestArc::clone(&hits); + let base = spawn_responder(TestArc::new(move |parts| { + hit_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if parts.target == "/x" { + http::Response::builder() + .status(302) + .header("location", "/final") + .body(Vec::new()) + .expect("redirect response builds") + } else { + let auth = parts + .headers + .get("authorization") + .cloned() + .unwrap_or_default(); + let body = format!(r#"{{"auth":"{auth}"}}"#); + http_response(200, "application/json", body.into_bytes()) + } + })) + .await; + let ctx = ctx_with_capability("svc", "tok-secret-marker".to_string()); + let envelope = call_forward_authed(&base, ctx, &Some(HttpAuthScheme::Bearer)).await; + match envelope.result { + Ok(value) => assert_eq!( + value, + json!({"auth": "Bearer tok-secret-marker"}), + "the followed hop must receive the credential header" + ), + other => panic!("same-host redirect must be followed to success, got {other:?}"), + } + assert_eq!( + hits.load(std::sync::atomic::Ordering::SeqCst), + 2, + "origin request plus exactly one followed hop" + ); + } + + /// CLI-02 (b), the FWD-03 load-bearing property: a cross-host 302 is + /// surfaced to the caller as `HTTP_302` and the redirected-to host + /// receives zero requests — credential-bearing or not, no second + /// request ever leaves for another host. + #[tokio::test] + async fn cross_host_redirect_is_surfaced_and_the_target_receives_zero_requests() { + let attacker_hits = TestArc::new(std::sync::atomic::AtomicU32::new(0)); + let attacker_counter = TestArc::clone(&attacker_hits); + let attacker = spawn_responder(TestArc::new(move |_| { + attacker_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + http_response(200, "text/plain", b"stolen".to_vec()) + })) + .await; + let base = spawn_responder(TestArc::new(move |_| { + http::Response::builder() + .status(302) + .header("location", format!("{attacker}/steal")) + .body(Vec::new()) + .expect("redirect response builds") + })) + .await; + let ctx = ctx_with_capability("svc", "tok-secret-marker".to_string()); + let envelope = call_forward_authed(&base, ctx, &Some(HttpAuthScheme::Bearer)).await; + match envelope.result { + Err(err) => { + assert_eq!(err.code, "HTTP_302", "message was: {}", err.message); + assert!( + !err.message.contains("tok-secret-marker"), + "error must carry no credential material: {}", + err.message + ); + } + other => panic!("cross-host redirect must surface the 3xx, got {other:?}"), + } + assert_eq!( + attacker_hits.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the cross-host target must receive zero requests" + ); + } + + /// CLI-02 (c): a same-host redirect loop runs into the hop cap and + /// fails loudly as an `INTERNAL` transport error naming the + /// redirect machinery — never silently looping or surfacing a + /// partial body. + #[tokio::test] + async fn redirect_hop_cap_errors_loudly() { + let hits = TestArc::new(std::sync::atomic::AtomicU32::new(0)); + let hit_counter = TestArc::clone(&hits); + let base = spawn_responder(TestArc::new(move |_| { + hit_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + http::Response::builder() + .status(302) + .header("location", "/next") + .body(Vec::new()) + .expect("redirect response builds") + })) + .await; + let envelope = call_forward(&base, noop_context()).await; + match envelope.result { + Err(err) => { + assert_eq!(err.code, "INTERNAL", "message was: {}", err.message); + assert!( + err.message.contains("HTTP request failed"), + "message was: {}", + err.message + ); + assert!( + err.message.contains("redirect"), + "message was: {}", + err.message + ); + } + other => panic!("hop-cap redirect must error loudly, got {other:?}"), + } + let hit_count = hits.load(std::sync::atomic::Ordering::SeqCst); + assert!( + hit_count >= 2, + "the chain followed before capping: {hit_count}" + ); + assert!( + hit_count <= 12, + "the hop cap bounded the redirect chain: {hit_count}" + ); + } }