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
This commit is contained in:
2026-08-31 00:43:39 +00:00
parent a7f10ed04c
commit 847e5ca586
+130
View File
@@ -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}"
);
}
}