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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user