From 42e68c42b817b1306921e6e6df894c7ad038d65c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 21:10:08 +0900 Subject: [PATCH 1/2] test(gateway): reject ambiguous singleton responses --- tests/gateway_response_singleton_mediation.rs | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/gateway_response_singleton_mediation.rs diff --git a/tests/gateway_response_singleton_mediation.rs b/tests/gateway_response_singleton_mediation.rs new file mode 100644 index 00000000..f0cc085a --- /dev/null +++ b/tests/gateway_response_singleton_mediation.rs @@ -0,0 +1,138 @@ +//! Hostile response-singleton acceptance for #440. +//! +//! `Location` and `Retry-After` are single-valued response fields under RFC 9110. +//! A gateway must not relay ambiguous duplicate values as an otherwise successful +//! upstream response. This child remains test-only; #441 owns the production +//! mediation repair. + +use axum::{ + Router, + body::Body, + http::{HeaderValue, Method, Request, StatusCode, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, + routing::any, +}; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppState, build_app}; + +async fn duplicate_location_response() -> Response { + let mut response = (StatusCode::FOUND, "ambiguous location").into_response(); + response + .headers_mut() + .append("location", HeaderValue::from_static("/v1/items/first")); + response + .headers_mut() + .append("location", HeaderValue::from_static("/v1/items/second")); + response +} + +async fn duplicate_retry_after_response() -> Response { + let mut response = (StatusCode::TOO_MANY_REQUESTS, "ambiguous retry").into_response(); + response + .headers_mut() + .append("retry-after", HeaderValue::from_static("5")); + response + .headers_mut() + .append("retry-after", HeaderValue::from_static("120")); + response +} + +async fn gateway_with_hostile_upstream() -> (Router, tokio::task::JoinHandle<()>) { + let upstream_app = Router::new() + .route("/v1/duplicate-location", any(duplicate_location_response)) + .route( + "/v1/duplicate-retry-after", + any(duplicate_retry_after_response), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("loopback upstream listener"); + let upstream_addr = listener.local_addr().expect("loopback upstream address"); + let upstream_task = tokio::spawn(async move { + axum::serve(listener, upstream_app) + .await + .expect("loopback upstream must serve until test cleanup"); + }); + + let app = build_app(AppState::seeded(Some("secret".to_string()))); + let route = serde_json::json!({ + "id": "response-singleton-red", + "path_prefix": "/headers", + "upstream": format!("http://{upstream_addr}"), + "mode": "monitor", + "enabled": true, + "block_threshold": null + }); + let created = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/routes") + .header(CONTENT_TYPE, "application/json") + .header("x-admin-token", "secret") + .body(Body::from(route.to_string())) + .expect("valid route registration request"), + ) + .await + .expect("Wardnet must answer route registration"); + assert_eq!(created.status(), StatusCode::CREATED); + + (app, upstream_task) +} + +#[tokio::test] +async fn duplicate_upstream_location_fails_closed() { + let (app, upstream_task) = gateway_with_hostile_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/headers/v1/duplicate-location") + .body(Body::empty()) + .expect("duplicate Location fixture"), + ) + .await + .expect("gateway must answer hostile upstream response"); + + assert_eq!( + response.status(), + StatusCode::BAD_GATEWAY, + "multiple Location values are an ambiguous upstream response and must fail closed" + ); + assert!( + response.headers().get("location").is_none(), + "ambiguous Location authority must not be relayed" + ); + + upstream_task.abort(); +} + +#[tokio::test] +async fn duplicate_upstream_retry_after_fails_closed() { + let (app, upstream_task) = gateway_with_hostile_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/headers/v1/duplicate-retry-after") + .body(Body::empty()) + .expect("duplicate Retry-After fixture"), + ) + .await + .expect("gateway must answer hostile upstream response"); + + assert_eq!( + response.status(), + StatusCode::BAD_GATEWAY, + "multiple Retry-After values are ambiguous and must fail closed" + ); + assert!( + response.headers().get("retry-after").is_none(), + "ambiguous Retry-After metadata must not be relayed" + ); + + upstream_task.abort(); +} From b8b5804b655a424967252d8ac22172eb4a1338fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 05:47:52 +0900 Subject: [PATCH 2/2] test(gateway): isolate duplicate Location mediation --- tests/gateway_response_singleton_mediation.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/gateway_response_singleton_mediation.rs b/tests/gateway_response_singleton_mediation.rs index f0cc085a..d3a2eabc 100644 --- a/tests/gateway_response_singleton_mediation.rs +++ b/tests/gateway_response_singleton_mediation.rs @@ -16,7 +16,11 @@ use tower::ServiceExt; use waf_ids_ai_soc::{AppState, build_app}; async fn duplicate_location_response() -> Response { - let mut response = (StatusCode::FOUND, "ambiguous location").into_response(); + // 201 exercises Location as representation/control metadata without invoking + // reqwest's redirect machinery, so this fixture isolates Wardnet's header + // mediation boundary instead of conflating it with EgressWeave-owned redirect + // authorization. + let mut response = (StatusCode::CREATED, "ambiguous location").into_response(); response .headers_mut() .append("location", HeaderValue::from_static("/v1/items/first"));