Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f661d4d
test(gateway): reproduce lost end-to-end headers
seonghobae Sep 20, 2026
ef34819
style(test): format gateway mediation RED
seonghobae Sep 20, 2026
d8fca38
fix(test): preserve gateway RED payloads after format repair
seonghobae Sep 20, 2026
2130211
test(gateway): apply canonical rustfmt to header RED
seonghobae Sep 21, 2026
766e11d
test(gateway): apply canonical rustfmt to RED fixture
seonghobae Sep 21, 2026
4da4ab5
chore(gateway): adopt Coraza parent before header repair
seonghobae Sep 21, 2026
aec5f72
test(gateway): pin hostile header mediation boundaries
seonghobae Sep 21, 2026
859f22a
test(gateway): cover hostile header ambiguity and nomination
seonghobae Sep 21, 2026
70f5aaa
test(gateway): apply canonical rustfmt
seonghobae Sep 22, 2026
160bbc6
fix(gateway): mediate bounded end-to-end headers
seonghobae Sep 22, 2026
e32a620
revert: restore gateway source after incomplete update
seonghobae Sep 22, 2026
f7b384a
fix(gateway): add bounded least-authority header mediation
seonghobae Sep 22, 2026
f6af18a
fix(gateway): keep mediation tests compile-explicit
seonghobae Sep 22, 2026
42e68c4
test(gateway): reject ambiguous singleton responses
seonghobae Sep 22, 2026
7a769e5
merge(stack): adopt Coraza exact-request binding into gateway mediation
seonghobae Sep 22, 2026
01fda91
merge(stack): adopt current gateway mediation parent
seonghobae Sep 22, 2026
c9b7344
chore(gateway): stage one-shot mediation synthesis
seonghobae Sep 22, 2026
853f468
merge(parent): adopt Coraza rustfmt repair
seonghobae Sep 22, 2026
7f1c7ca
chore(gateway): rebind mediation synthesis to restacked parent
seonghobae Sep 22, 2026
be523f3
fix(ci): provision Rust before mediation synthesis
seonghobae Sep 22, 2026
4e57283
fix(ci): guard mediation synthesis across helper commits
seonghobae Sep 22, 2026
f42d8b5
fix(ci): avoid unrelated llvm-tools bootstrap in mediation synthesis
seonghobae Sep 22, 2026
5e80ff9
fix(ci): isolate gateway synthesis formatting failure
seonghobae Sep 23, 2026
a63b540
fix(ci): isolate synthesis rustfmt side effects
seonghobae Sep 23, 2026
26c5de5
test(gateway): preserve representation content encoding
seonghobae Sep 23, 2026
a240657
fix(ci): settle mediation synthesis clippy root causes
seonghobae Sep 23, 2026
4b93834
fix(ci): match synthesized proxy function indentation
seonghobae Sep 23, 2026
3ec25d1
fix(gateway): enforce bounded header mediation
github-actions[bot] Sep 23, 2026
68fc8ed
docs(security): specify gateway header mediation contract
seonghobae Sep 23, 2026
a6869e8
merge: adopt settled gateway mediation parent
seonghobae Sep 23, 2026
a208450
merge: adopt settled gateway mediation parent
seonghobae Sep 23, 2026
29a3516
fix(gateway): reject ambiguous singleton responses
seonghobae Sep 23, 2026
8ae44f9
merge: adopt singleton mediation repair parent
seonghobae Sep 23, 2026
44c03a0
merge: adopt singleton mediation repair parent
seonghobae Sep 23, 2026
8a25402
test(gateway): rustfmt content-encoding RED fixture
seonghobae Sep 23, 2026
b8b5804
test(gateway): isolate duplicate Location mediation
seonghobae Sep 23, 2026
bac6df0
Merge PR #446: test(gateway): reject ambiguous singleton responses
seonghobae Sep 24, 2026
16a0d08
fix(gateway): preserve content encoding metadata
seonghobae Sep 24, 2026
30b52ca
Merge PR #449: adopt repaired gateway parent into #448
seonghobae Sep 24, 2026
cd3c598
Merge pull request #448 from ContextualWisdomLab/test/447-content-enc…
seonghobae Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/security/gateway-header-mediation.md
Original file line number Diff line number Diff line change
@@ -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
218 changes: 218 additions & 0 deletions src/gateway_mediation.rs
Original file line number Diff line number Diff line change
@@ -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<HeaderMap, String> {
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<HeaderMap, String> {
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<HashSet<HeaderName>, 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<HeaderMap, String> {
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::<Vec<_>>(),
["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::<Vec<_>>(),
["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());
}
}
39 changes: 37 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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),
}
Expand All @@ -2569,31 +2585,50 @@ fn client_ip_from_headers(headers: &HeaderMap) -> Option<IpAddr> {
.and_then(|value| value.parse().ok())
}

#[cfg(test)]
async fn proxy_request(
state: &AppState,
route: &RouteConfig,
method: &Method,
path: &str,
query: Option<&str>,
body: Bytes,
) -> Result<Response, String> {
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<Response, String> {
let target = upstream_target(route, path, query)?;
let method = reqwest::Method::from_bytes(method.as_str().as_bytes())
.expect("axum HTTP methods are valid reqwest HTTP methods");
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(
Expand Down
Loading
Loading