diff --git a/docs/security/gateway-header-mediation.md b/docs/security/gateway-header-mediation.md new file mode 100644 index 00000000..7b76607f --- /dev/null +++ b/docs/security/gateway-header-mediation.md @@ -0,0 +1,39 @@ +# Gateway header mediation contract + +Status: candidate security contract for PR #441; not released or authoritative on the protected branch until that PR is merged. + +Wardnet owns the generic gateway/SOC mediation boundary. This contract is deliberately narrower than an HTTP-transparent proxy: it preserves only application metadata required by the buyer path and rejects or removes transport, framing, management, and proxy authority that must not cross the gateway trust boundary. Egress authorization, sandbox isolation, LLM routing, and application guardrail semantics remain with their canonical owner repositories. + +## Request boundary + +The generic gateway admits only `Content-Type`, `Accept`, and bounded `X-Wardnet-App-Meta` values to the routed upstream. `X-Admin-Token`, `Authorization`, cookies, proxy credentials, client-supplied forwarding identity, `Host`, framing fields, upgrades, and other unlisted fields are not forwarded. + +`Content-Type` and `X-Admin-Token` are treated as security-relevant singletons at this boundary. Duplicate instances fail closed with `400 Bad Request`. The aggregate byte length of `X-Wardnet-App-Meta` is capped at 16,384 bytes before route-upstream contact. Any field nominated by `Connection` is removed even if that field would otherwise be admitted. + +This follows RFC 9110 section 7.6.1: an intermediary must parse `Connection`, remove every field named by a connection option, and remove `Connection` itself before forwarding. The allowlist is an intentional Wardnet policy restriction rather than an attempt to redefine HTTP semantics. + +## Response boundary + +Wardnet reconstructs the downstream response from an explicit response allowlist. The current candidate admits `Content-Type`, bounded `X-Wardnet-App-Meta`, `Location`, `Retry-After`, and `WWW-Authenticate`; it does not reflect `Connection`, fields named by `Connection`, proxy-authentication fields, upgrades, cookies, or other unadmitted authority. + +An invalid admitted upstream header envelope fails closed as `502 Bad Gateway`. `Content-Type` is currently enforced as a singleton. Response-singleton hardening for `Location` and `Retry-After` is tracked separately so this PR does not silently expand its causal scope: RFC 9110 sections 10.2.2 and 10.2.3 define each with a single field value grammar. + +`Content-Encoding` is representation metadata under RFC 9110 section 8.4, not hop-by-hop metadata. It is therefore a separate follow-on acceptance gap rather than something Wardnet may drop while relaying coded bytes. Transparent decompression is not an acceptable shortcut unless every affected representation field is transformed consistently. + +## Acceptance evidence + +The hostile buyer suite in `tests/gateway_header_mediation.rs` must exercise a real loopback upstream, not a mocked header helper alone. GREEN requires all of the following on one exact head: + +- admitted request media type, content negotiation, and bounded repeated application metadata survive the gateway; +- duplicated management credentials, duplicated request `Content-Type`, and oversized application metadata fail before the routed upstream receives the request; +- `Host`, framing authority, credentials, cookies, proxy authority, forwarding identity, upgrades, and dynamically connection-nominated fields do not cross the request boundary; +- admitted upstream representation metadata survives the response boundary while fixed and dynamically nominated hop-by-hop/security authority does not; +- repository CI, fuzzing, security/SAST/CodeQL governance, coverage/rustdoc evidence, buyer-path latency evidence, and parent/base compatibility are reacquired for the exact candidate head. + +A workflow result that is queued, `action_required`, skipped without jobs, or attached to a predecessor SHA is not transferable GREEN evidence. + +## References + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://www.rfc-editor.org/rfc/rfc9110 + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP/1.1* (RFC 9112). RFC Editor. https://www.rfc-editor.org/rfc/rfc9112 diff --git a/src/gateway_mediation.rs b/src/gateway_mediation.rs new file mode 100644 index 00000000..7f93cb0c --- /dev/null +++ b/src/gateway_mediation.rs @@ -0,0 +1,218 @@ +use axum::http::{HeaderMap, HeaderName}; +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", + "content-encoding", + "accept", + APP_METADATA_HEADER, +]; +const RESPONSE_ALLOWED: &[&str] = &[ + "content-type", + "content-encoding", + APP_METADATA_HEADER, + "location", + "retry-after", + "www-authenticate", +]; + +pub(crate) fn admit_request_headers(source: &HeaderMap) -> Result { + reject_duplicate(source, "x-admin-token")?; + reject_duplicate(source, "content-type")?; + reject_oversized_app_metadata(source)?; + admit_allowlisted(source, REQUEST_ALLOWED) +} + +pub(crate) fn admit_response_headers(source: &HeaderMap) -> Result { + reject_duplicate(source, "content-type")?; + reject_duplicate(source, "location")?; + reject_duplicate(source, "retry-after")?; + reject_oversized_app_metadata(source)?; + admit_allowlisted(source, RESPONSE_ALLOWED) +} + +fn reject_duplicate(source: &HeaderMap, name: &'static str) -> Result<(), String> { + if source.get_all(name).iter().take(2).count() > 1 { + return Err(format!("gateway header {name} must not be duplicated")); + } + Ok(()) +} + +fn reject_oversized_app_metadata(source: &HeaderMap) -> Result<(), String> { + let total = source + .get_all(APP_METADATA_HEADER) + .iter() + .fold(0usize, |size, value| { + size.saturating_add(value.as_bytes().len()) + }); + if total > APP_METADATA_MAX_BYTES { + return Err(format!( + "gateway header {APP_METADATA_HEADER} exceeds {APP_METADATA_MAX_BYTES} bytes" + )); + } + Ok(()) +} + +fn connection_nominations(source: &HeaderMap) -> Result, String> { + let mut nominated = HashSet::new(); + for value in source.get_all("connection").iter() { + let raw = value + .to_str() + .map_err(|_| "gateway Connection header must be visible ASCII".to_string())?; + for token in raw + .split(',') + .map(str::trim) + .filter(|token| !token.is_empty()) + { + let name = HeaderName::from_bytes(token.as_bytes()) + .map_err(|_| format!("gateway Connection nomination {token:?} is invalid"))?; + nominated.insert(name); + } + } + Ok(nominated) +} + +fn admit_allowlisted(source: &HeaderMap, allowed: &[&'static str]) -> Result { + let nominated = connection_nominations(source)?; + let mut admitted = HeaderMap::new(); + for &name in allowed { + let header_name = HeaderName::from_static(name); + if nominated.contains(&header_name) { + continue; + } + for value in source.get_all(name).iter() { + admitted.append(header_name.clone(), value.clone()); + } + } + Ok(admitted) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + #[test] + 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")); + source.append("authorization", HeaderValue::from_static("Bearer secret")); + + 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()); + } + + #[test] + fn duplicate_and_oversized_request_authority_fail_closed() { + let mut duplicate_admin = HeaderMap::new(); + duplicate_admin.append("x-admin-token", HeaderValue::from_static("a")); + duplicate_admin.append("x-admin-token", HeaderValue::from_static("b")); + assert!(admit_request_headers(&duplicate_admin).is_err()); + + let mut duplicate_type = HeaderMap::new(); + duplicate_type.append("content-type", HeaderValue::from_static("text/plain")); + duplicate_type.append("content-type", HeaderValue::from_static("application/json")); + assert!(admit_request_headers(&duplicate_type).is_err()); + + let mut oversized = HeaderMap::new(); + oversized.insert( + APP_METADATA_HEADER, + HeaderValue::from_str(&"x".repeat(APP_METADATA_MAX_BYTES + 1)).unwrap(), + ); + assert!(admit_request_headers(&oversized).is_err()); + } + + #[test] + fn connection_nominations_remove_otherwise_allowed_fields() { + let mut source = HeaderMap::new(); + source.append(APP_METADATA_HEADER, HeaderValue::from_static("keep-out")); + 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()); + assert!(admit_request_headers(&malformed).is_err()); + + let mut invalid_nomination = HeaderMap::new(); + invalid_nomination.append("connection", HeaderValue::from_static("bad name")); + assert!(admit_request_headers(&invalid_nomination).is_err()); + } + + #[test] + 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")); + source.append("retry-after", HeaderValue::from_static("5")); + source.append( + "www-authenticate", + HeaderValue::from_static("Bearer realm=\"buyer\""), + ); + source.append("set-cookie", HeaderValue::from_static("secret=1")); + source.append("connection", HeaderValue::from_static(APP_METADATA_HEADER)); + + 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"); + assert!(admitted.get("www-authenticate").is_some()); + assert!(admitted.get("set-cookie").is_none()); + } + + #[test] + fn response_policy_rejects_duplicate_singletons_and_oversized_metadata() { + for name in ["content-type", "location", "retry-after"] { + let mut duplicate = HeaderMap::new(); + duplicate.append(name, HeaderValue::from_static("first")); + duplicate.append(name, HeaderValue::from_static("second")); + assert!( + admit_response_headers(&duplicate).is_err(), + "duplicate {name} must fail closed" + ); + } + + let mut oversized = HeaderMap::new(); + oversized.insert( + APP_METADATA_HEADER, + HeaderValue::from_str(&"x".repeat(APP_METADATA_MAX_BYTES + 1)).unwrap(), + ); + assert!(admit_response_headers(&oversized).is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 799f4714..e9b751c5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,7 @@ pub use waf_ids_core::{ mod coraza_audit; mod credentials; +mod gateway_mediation; mod kev_import; mod misp_import; mod opencti_import; @@ -2533,6 +2534,11 @@ async fn gateway( .await; } + let admitted_request_headers = match gateway_mediation::admit_request_headers(&headers) { + Ok(headers) => headers, + Err(message) => return error(StatusCode::BAD_REQUEST, message), + }; + if route.upstream.starts_with("mock://") { return ( StatusCode::OK, @@ -2549,7 +2555,17 @@ async fn gateway( .into_response(); } - match proxy_request(&state, &route, &method, gateway_path, uri.query(), body).await { + match proxy_request_with_headers( + &state, + &route, + &method, + gateway_path, + uri.query(), + admitted_request_headers, + body, + ) + .await + { Ok(response) => response, Err(message) => error(StatusCode::BAD_GATEWAY, message), } @@ -2569,6 +2585,7 @@ fn client_ip_from_headers(headers: &HeaderMap) -> Option { .and_then(|value| value.parse().ok()) } +#[cfg(test)] async fn proxy_request( state: &AppState, route: &RouteConfig, @@ -2576,6 +2593,18 @@ async fn proxy_request( path: &str, query: Option<&str>, body: Bytes, +) -> Result { + proxy_request_with_headers(state, route, method, path, query, HeaderMap::new(), body).await +} + +async fn proxy_request_with_headers( + state: &AppState, + route: &RouteConfig, + method: &Method, + path: &str, + query: Option<&str>, + request_headers: HeaderMap, + body: Bytes, ) -> Result { let target = upstream_target(route, path, query)?; let method = reqwest::Method::from_bytes(method.as_str().as_bytes()) @@ -2583,17 +2612,23 @@ async fn proxy_request( let response = state .http .request(method, target) + .headers(request_headers) .body(body) .send() .await .map_err(|error| format!("upstream request failed: {error}"))?; let status = StatusCode::from_u16(response.status().as_u16()) .expect("reqwest upstream status codes are valid axum status codes"); + let admitted_response_headers = + gateway_mediation::admit_response_headers(response.headers()) + .map_err(|message| format!("upstream response rejected: {message}"))?; let bytes = response .bytes() .await .map_err(|error| format!("upstream body read failed: {error}"))?; - Ok((status, bytes).into_response()) + let mut response = (status, bytes).into_response(); + *response.headers_mut() = admitted_response_headers; + Ok(response) } pub fn upstream_target( diff --git a/tests/gateway_content_encoding.rs b/tests/gateway_content_encoding.rs new file mode 100644 index 00000000..c9ba1cb1 --- /dev/null +++ b/tests/gateway_content_encoding.rs @@ -0,0 +1,184 @@ +//! Hostile buyer acceptance for #447: end-to-end representation codings must +//! remain coupled to the byte-identical representation crossing Wardnet's +//! generic gateway boundary. +//! +//! This lane is intentionally test-only. Production mediation remains owned by +//! #441; once that parent settles, this child must adopt the complete parent +//! non-force before any production repair is authorized. + +use std::sync::Arc; + +use axum::{ + Router, + body::{Body, Bytes, to_bytes}, + extract::State, + http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, + routing::any, +}; +use tokio::sync::Mutex; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppState, build_app}; + +const REQUEST_GZIP_JSON: &[u8] = &[ + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0x2a, 0x28, 0xca, 0x4f, + 0x4a, 0x55, 0xb2, 0x2a, 0x29, 0x2a, 0x4d, 0xad, 0x05, 0x00, 0xa9, 0x27, 0x1b, 0xbb, 0x0e, 0x00, + 0x00, 0x00, +]; +const RESPONSE_GZIP_JSON: &[u8] = &[ + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0xca, 0xcf, 0x56, 0xb2, + 0x2a, 0x29, 0x2a, 0x4d, 0xad, 0x05, 0x00, 0x90, 0x5f, 0xd4, 0xa7, 0x0b, 0x00, 0x00, 0x00, +]; + +#[derive(Clone, Default)] +struct Capture { + request_headers: Arc>>, + request_body: Arc>>, +} + +async fn coded_upstream( + State(capture): State, + headers: HeaderMap, + body: Bytes, +) -> Response { + *capture.request_headers.lock().await = Some(headers); + *capture.request_body.lock().await = Some(body); + + let mut response = (StatusCode::OK, Body::from(RESPONSE_GZIP_JSON)).into_response(); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + response + .headers_mut() + .insert("content-encoding", HeaderValue::from_static("gzip")); + response + .headers_mut() + .insert("connection", HeaderValue::from_static("x-hop-secret")); + response + .headers_mut() + .insert("x-hop-secret", HeaderValue::from_static("must-not-reflect")); + response +} + +async fn gateway_with_coded_upstream() -> (Router, Capture, tokio::task::JoinHandle<()>) { + let capture = Capture::default(); + let upstream = Router::new() + .route("/v1/coded", any(coded_upstream)) + .with_state(capture.clone()); + 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) + .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": "content-encoding-red", + "path_prefix": "/coded", + "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, capture, upstream_task) +} + +#[tokio::test] +async fn request_preserves_content_encoding_with_byte_identical_representation() { + let (app, capture, upstream_task) = gateway_with_coded_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/coded/v1/coded") + .header(CONTENT_TYPE, "application/json") + .header("content-encoding", "gzip") + .header("connection", "x-hop-secret") + .header("x-hop-secret", "must-not-forward") + .body(Body::from(REQUEST_GZIP_JSON)) + .expect("coded buyer request"), + ) + .await + .expect("gateway must answer coded buyer request"); + assert_eq!(response.status(), StatusCode::OK); + + let headers = capture + .request_headers + .lock() + .await + .clone() + .expect("loopback upstream must receive request"); + assert_eq!( + headers + .get("content-encoding") + .and_then(|value| value.to_str().ok()), + Some("gzip"), + "coded representation bytes must not cross without their Content-Encoding metadata" + ); + assert!(headers.get("connection").is_none()); + assert!(headers.get("x-hop-secret").is_none()); + + let body = capture + .request_body + .lock() + .await + .clone() + .expect("loopback upstream must receive request body"); + assert_eq!(body.as_ref(), REQUEST_GZIP_JSON); + + upstream_task.abort(); +} + +#[tokio::test] +async fn response_preserves_content_encoding_with_byte_identical_representation() { + let (app, _capture, upstream_task) = gateway_with_coded_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/coded/v1/coded") + .body(Body::empty()) + .expect("coded response buyer request"), + ) + .await + .expect("gateway must answer coded response buyer request"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("content-encoding") + .and_then(|value| value.to_str().ok()), + Some("gzip"), + "coded response bytes must not cross without their Content-Encoding metadata" + ); + assert!(response.headers().get("connection").is_none()); + assert!(response.headers().get("x-hop-secret").is_none()); + + let body = to_bytes(response.into_body(), 1024) + .await + .expect("coded response body must remain readable"); + assert_eq!(body.as_ref(), RESPONSE_GZIP_JSON); + + upstream_task.abort(); +} diff --git a/tests/gateway_header_mediation.rs b/tests/gateway_header_mediation.rs new file mode 100644 index 00000000..e7921cde --- /dev/null +++ b/tests/gateway_header_mediation.rs @@ -0,0 +1,448 @@ +//! Hostile buyer acceptance for #440: Wardnet must preserve only explicitly +//! admitted end-to-end HTTP metadata across the generic gateway boundary. +//! +//! The benign preservation assertions are intentionally RED against the current +//! protected behavior. Security assertions pin the least-authority boundary at +//! the same time so the eventual GREEN cannot become a transparent header tunnel. +//! Production source stays byte-identical in this test-only phase. + +use std::sync::Arc; + +use axum::{ + Router, + body::Body, + extract::State, + http::{ + HeaderMap, HeaderValue, Method, Request, StatusCode, + header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE, HOST}, + }, + response::{IntoResponse, Response}, + routing::any, +}; +use tokio::sync::Mutex; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppState, build_app}; + +#[derive(Clone, Default)] +struct Capture { + request_headers: Arc>>, +} + +async fn capture_upstream(State(capture): State, headers: HeaderMap) -> Response { + *capture.request_headers.lock().await = Some(headers); + + let mut response = (StatusCode::ACCEPTED, r#"{"ok":true}"#).into_response(); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + response.headers_mut().append( + "x-wardnet-app-meta", + HeaderValue::from_static("buyer-contract-v1"), + ); + response.headers_mut().append( + "x-wardnet-app-meta", + HeaderValue::from_static("buyer-contract-v2"), + ); + response + .headers_mut() + .insert("location", HeaderValue::from_static("/v1/items/42")); + response + .headers_mut() + .insert("retry-after", HeaderValue::from_static("5")); + response.headers_mut().insert( + "www-authenticate", + HeaderValue::from_static("Bearer realm=\"buyer\""), + ); + + // These are never application metadata. The gateway must remove them even + // when benign response fields are admitted by the mediation policy. + response.headers_mut().insert( + "connection", + HeaderValue::from_static("x-wardnet-connection-secret, keep-alive"), + ); + response.headers_mut().insert( + "x-wardnet-connection-secret", + HeaderValue::from_static("must-not-reflect"), + ); + response.headers_mut().insert( + "proxy-authenticate", + HeaderValue::from_static("Basic realm=\"proxy\""), + ); + response + .headers_mut() + .insert("upgrade", HeaderValue::from_static("websocket")); + response.headers_mut().insert( + "set-cookie", + HeaderValue::from_static("upstream-secret=1; HttpOnly"), + ); + response +} + +async fn connection_nominated_response() -> Response { + let mut response = (StatusCode::ACCEPTED, "ok").into_response(); + response.headers_mut().append( + "x-wardnet-app-meta", + HeaderValue::from_static("must-not-reflect-when-nominated"), + ); + response.headers_mut().insert( + "connection", + HeaderValue::from_static("x-wardnet-app-meta, keep-alive"), + ); + response +} + +async fn gateway_with_loopback_upstream() -> (Router, Capture, tokio::task::JoinHandle<()>) { + let capture = Capture::default(); + let upstream_app = Router::new() + .route("/v1/items", any(capture_upstream)) + .route( + "/v1/connection-nominated", + any(connection_nominated_response), + ) + .with_state(capture.clone()); + 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": "header-mediation-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, capture, upstream_task) +} + +fn header_values(headers: &HeaderMap, name: &str) -> Vec { + headers + .get_all(name) + .iter() + .map(|value| value.to_str().expect("test header is ASCII").to_string()) + .collect() +} + +#[tokio::test] +async fn gateway_preserves_admitted_request_content_negotiation_metadata() { + let (app, capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/headers/v1/items") + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json") + .header("x-wardnet-app-meta", "request-contract-v1") + .header("x-wardnet-app-meta", "request-contract-v2") + .body(Body::from(r#"{"probe":true}"#)) + .expect("valid buyer request"), + ) + .await + .expect("gateway must answer buyer request"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let captured = capture + .request_headers + .lock() + .await + .clone() + .expect("loopback upstream must receive the request"); + assert_eq!( + captured + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json"), + "Wardnet must preserve an explicitly admitted request media type" + ); + assert_eq!( + captured.get(ACCEPT).and_then(|value| value.to_str().ok()), + Some("application/json"), + "Wardnet must preserve explicitly admitted response content negotiation" + ); + assert_eq!( + header_values(&captured, "x-wardnet-app-meta"), + ["request-contract-v1", "request-contract-v2"], + "application metadata multiplicity must remain intact" + ); + + upstream_task.abort(); +} + +#[tokio::test] +async fn gateway_strips_request_authority_credentials_and_hop_by_hop_fields() { + let (app, capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/headers/v1/items") + .header(CONTENT_TYPE, "application/json") + .header(HOST, "attacker.example") + .header(CONTENT_LENGTH, "999") + .header("x-admin-token", "management-secret") + .header("authorization", "Bearer buyer-secret") + .header("cookie", "session=buyer-secret") + .header("x-forwarded-for", "203.0.113.77") + .header("x-real-ip", "203.0.113.77") + .header("proxy-authorization", "Basic cHJveHk6c2VjcmV0") + .header("x-wardnet-app-meta", "must-not-forward-when-nominated") + .header( + "connection", + "x-wardnet-app-meta, x-wardnet-connection-secret, keep-alive", + ) + .header("x-wardnet-connection-secret", "must-not-forward") + .header("te", "trailers") + .header("trailer", "x-proof") + .header("upgrade", "websocket") + .body(Body::from(r#"{"probe":true}"#)) + .expect("hostile buyer request fixture"), + ) + .await + .expect("gateway must answer hostile buyer request"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let captured = capture + .request_headers + .lock() + .await + .clone() + .expect("loopback upstream must receive the sanitized request"); + for name in [ + "x-admin-token", + "authorization", + "cookie", + "x-forwarded-for", + "x-real-ip", + "proxy-authorization", + "x-wardnet-app-meta", + "connection", + "x-wardnet-connection-secret", + "te", + "trailer", + "upgrade", + ] { + assert!( + captured.get(name).is_none(), + "security-sensitive request field {name} crossed the gateway boundary" + ); + } + assert_ne!( + captured.get(HOST).and_then(|value| value.to_str().ok()), + Some("attacker.example"), + "attacker-controlled Host authority must not be forwarded" + ); + assert_ne!( + captured + .get(CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()), + Some("999"), + "attacker-controlled framing authority must not be forwarded" + ); + + upstream_task.abort(); +} + +#[tokio::test] +async fn duplicate_management_credentials_fail_closed_before_proxying() { + let (app, capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/headers/v1/items") + .header("x-admin-token", "first") + .header("x-admin-token", "second") + .body(Body::empty()) + .expect("duplicate sensitive header fixture"), + ) + .await + .expect("gateway must answer duplicate-sensitive-header request"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "ambiguous duplicated management credentials must fail closed" + ); + assert!( + capture.request_headers.lock().await.is_none(), + "a fail-closed request must not reach the upstream" + ); + + upstream_task.abort(); +} + +#[tokio::test] +async fn duplicate_singleton_content_type_fails_closed_before_proxying() { + let (app, capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/headers/v1/items") + .header(CONTENT_TYPE, "application/json") + .header(CONTENT_TYPE, "text/plain") + .body(Body::from("{}")) + .expect("ambiguous singleton header fixture"), + ) + .await + .expect("gateway must answer ambiguous-singleton request"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "ambiguous duplicated singleton application metadata must fail closed" + ); + assert!( + capture.request_headers.lock().await.is_none(), + "ambiguous singleton metadata must not reach the upstream" + ); + + upstream_task.abort(); +} + +#[tokio::test] +async fn oversized_admitted_application_metadata_fails_closed_before_proxying() { + let (app, capture, upstream_task) = gateway_with_loopback_upstream().await; + let oversized = "x".repeat(16_385); + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/headers/v1/items") + .header("x-wardnet-app-meta", oversized) + .body(Body::empty()) + .expect("oversized admitted metadata fixture"), + ) + .await + .expect("gateway must answer oversized-metadata request"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "oversized admitted metadata must fail closed before outbound allocation" + ); + assert!( + capture.request_headers.lock().await.is_none(), + "oversized metadata must not reach the upstream" + ); + + upstream_task.abort(); +} + +#[tokio::test] +async fn gateway_preserves_admitted_upstream_representation_metadata() { + let (app, _capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/headers/v1/items") + .body(Body::empty()) + .expect("valid buyer request"), + ) + .await + .expect("gateway must answer buyer request"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json"), + "Wardnet must preserve an explicitly admitted upstream representation media type" + ); + assert_eq!( + header_values(response.headers(), "x-wardnet-app-meta"), + ["buyer-contract-v1", "buyer-contract-v2"], + "bounded application metadata multiplicity must remain intact" + ); + assert_eq!( + response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()), + Some("/v1/items/42") + ); + assert_eq!( + response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()), + Some("5") + ); + assert_eq!( + response + .headers() + .get("www-authenticate") + .and_then(|value| value.to_str().ok()), + Some("Bearer realm=\"buyer\"") + ); + + for name in [ + "connection", + "x-wardnet-connection-secret", + "proxy-authenticate", + "upgrade", + "set-cookie", + ] { + assert!( + response.headers().get(name).is_none(), + "security-sensitive upstream field {name} must not be reflected" + ); + } + + upstream_task.abort(); +} + +#[tokio::test] +async fn gateway_strips_connection_nominated_response_metadata() { + let (app, _capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/headers/v1/connection-nominated") + .body(Body::empty()) + .expect("connection-nominated response fixture"), + ) + .await + .expect("gateway must answer hostile upstream response"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert!( + response.headers().get("x-wardnet-app-meta").is_none(), + "an otherwise admitted field named by Connection must not cross the gateway response boundary" + ); + assert!( + response.headers().get("connection").is_none(), + "Connection itself is hop-by-hop authority and must not be reflected" + ); + + upstream_task.abort(); +} 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(); +}