diff --git a/src/gateway_mediation.rs b/src/gateway_mediation.rs index a2e49b4e..7f93cb0c 100644 --- a/src/gateway_mediation.rs +++ b/src/gateway_mediation.rs @@ -3,9 +3,15 @@ use std::collections::HashSet; const APP_METADATA_HEADER: &str = "x-wardnet-app-meta"; const APP_METADATA_MAX_BYTES: usize = 16_384; -const REQUEST_ALLOWED: &[&str] = &["content-type", "accept", APP_METADATA_HEADER]; +const REQUEST_ALLOWED: &[&str] = &[ + "content-type", + "content-encoding", + "accept", + APP_METADATA_HEADER, +]; const RESPONSE_ALLOWED: &[&str] = &[ "content-type", + "content-encoding", APP_METADATA_HEADER, "location", "retry-after", @@ -92,6 +98,8 @@ mod tests { fn request_policy_preserves_only_bounded_allowlist_with_multiplicity() { let mut source = HeaderMap::new(); source.append("content-type", HeaderValue::from_static("application/json")); + source.append("content-encoding", HeaderValue::from_static("gzip")); + source.append("content-encoding", HeaderValue::from_static("br")); source.append("accept", HeaderValue::from_static("application/json")); source.append(APP_METADATA_HEADER, HeaderValue::from_static("a")); source.append(APP_METADATA_HEADER, HeaderValue::from_static("b")); @@ -99,6 +107,14 @@ mod tests { let admitted = admit_request_headers(&source).unwrap(); assert_eq!(admitted.get("content-type").unwrap(), "application/json"); + assert_eq!( + admitted + .get_all("content-encoding") + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(), + ["gzip", "br"] + ); assert_eq!(admitted.get("accept").unwrap(), "application/json"); assert_eq!(admitted.get_all(APP_METADATA_HEADER).iter().count(), 2); assert!(admitted.get("authorization").is_none()); @@ -128,9 +144,14 @@ mod tests { fn connection_nominations_remove_otherwise_allowed_fields() { let mut source = HeaderMap::new(); source.append(APP_METADATA_HEADER, HeaderValue::from_static("keep-out")); - source.append("connection", HeaderValue::from_static(APP_METADATA_HEADER)); + source.append("content-encoding", HeaderValue::from_static("gzip")); + source.append( + "connection", + HeaderValue::from_static("x-wardnet-app-meta, content-encoding"), + ); let admitted = admit_request_headers(&source).unwrap(); assert!(admitted.get(APP_METADATA_HEADER).is_none()); + assert!(admitted.get("content-encoding").is_none()); let mut malformed = HeaderMap::new(); malformed.append("connection", HeaderValue::from_bytes(&[0xff]).unwrap()); @@ -145,6 +166,8 @@ mod tests { fn response_policy_preserves_representation_metadata_and_strips_authority() { let mut source = HeaderMap::new(); source.append("content-type", HeaderValue::from_static("application/json")); + source.append("content-encoding", HeaderValue::from_static("gzip")); + source.append("content-encoding", HeaderValue::from_static("br")); source.append(APP_METADATA_HEADER, HeaderValue::from_static("a")); source.append(APP_METADATA_HEADER, HeaderValue::from_static("b")); source.append("location", HeaderValue::from_static("/v1/items/42")); @@ -158,6 +181,14 @@ mod tests { let admitted = admit_response_headers(&source).unwrap(); assert_eq!(admitted.get("content-type").unwrap(), "application/json"); + assert_eq!( + admitted + .get_all("content-encoding") + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(), + ["gzip", "br"] + ); assert!(admitted.get(APP_METADATA_HEADER).is_none()); assert_eq!(admitted.get("location").unwrap(), "/v1/items/42"); assert_eq!(admitted.get("retry-after").unwrap(), "5"); diff --git a/tests/gateway_response_singleton_mediation.rs b/tests/gateway_response_singleton_mediation.rs new file mode 100644 index 00000000..d3a2eabc --- /dev/null +++ b/tests/gateway_response_singleton_mediation.rs @@ -0,0 +1,142 @@ +//! 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 { + // 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")); + 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(); +}