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"
|
"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