diff --git a/docs/architecture.md b/docs/architecture.md index e1ee578b..abeafdd0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ flowchart LR ## Near-Term Integrations -- **WAF**: Coraza/OWASP CRS audit JSON/NDJSON ingest is available at `POST /api/waf/coraza/audit` (admin token). Interrupted transactions and CRS rule messages become `SecurityEvent` rows and feed gateway enforcement (DNSBL + `client_ip`/`path` threat indicators) so subsequent gateway decisions block matching clients. In-process Coraza embedding remains a follow-up — do not replace CRS with hand-rolled rules. +- **WAF**: Coraza/OWASP CRS audit JSON/NDJSON ingest is available at `POST /api/waf/coraza/audit` (admin token). The bounded `ProvenEngineConfig` port can also evaluate each live matched gateway request through a loopback Coraza sidecar before forwarding; Wardnet sends only method/effective URI/body/client IP plus a capped non-secret header allowlist, correlates response evidence to the exact request, fails block-mode traffic closed when the proven engine is unconfigured or its evidence is unusable, and preserves Coraza/CRS as detection authority. Runtime Configuration still owns how packaged deployments expose that adapter. Do not replace CRS with hand-written signatures or duplicate EgressWeave transport policy. - **IDS**: Suricata EVE JSON/NDJSON ingest is available at `POST /api/ids/suricata/eve` (admin token). Alert records become `SecurityEvent` rows for SOC export/KPI; full route correlation and live EVE tailing remain follow-ups. - **Threat Intelligence**: STIX 2.x indicator/bundle ingest is available at `POST /api/threat-intel/stix` (admin token), MISP Event/attribute JSON ingest at `POST /api/threat-intel/misp` (admin token), TAXII 2.1 collection poll at `POST /api/threat-intel/taxii/poll` (admin token; Basic/Bearer optional), OpenCTI observable/indicator export ingest at `POST /api/threat-intel/opencti` (admin token), and a CISA Known Exploited Vulnerabilities (KEV) catalog pull at `POST /api/threat-intel/cisa-kev` (admin token; fetches the official catalog and upserts one `cve` threat indicator per entry, severity escalated when CISA has tied the CVE to a known ransomware campaign). All update `ThreatIndicator` / `DnsblEntry` plus feed freshness. Live MISP REST pull and live OpenCTI GraphQL pull remain follow-ups. - **DNSBL Serving**: Hickory DNS should serve authoritative DNSBL responses directly after zone export semantics stabilize. diff --git a/docs/doctoring/in-path-coraza-adapter.md b/docs/doctoring/in-path-coraza-adapter.md new file mode 100644 index 00000000..ed051955 --- /dev/null +++ b/docs/doctoring/in-path-coraza-adapter.md @@ -0,0 +1,35 @@ +# In-path Coraza request adapter + +## Decision boundary + +Wardnet owns route selection, monitor/block policy, security-event production, and the decision to forward a request. It does not own OWASP CRS detection logic. When a `ProvenEngineConfig` sidecar is configured, Wardnet submits each matched live gateway request to a same-host Coraza/OWASP CRS evaluator before forwarding. The adapter is intentionally loopback-only; executable general-purpose egress authorization remains EgressWeave ownership and is not copied into Wardnet. + +The request envelope contains method, effective gateway URI, body, client address when known, a bounded non-secret header allowlist, Wardnet route-policy identity, contract identifier `coraza-live-evaluate-v1`, and a bounded Wardnet `correlation_id`. `Authorization`, `Cookie`, `Proxy-Authorization`, and `X-Admin-Token` are never forwarded. Raw `X-Forwarded-For` and `X-Real-IP` are also withheld until Wardnet's trusted-proxy attribution owner reaches protected truth; the sidecar receives the transport-derived `client_ip` separately. Header forwarding is capped at 32 fields / 8 KiB. If any allowlisted value cannot be represented as UTF-8 or the complete allowlisted envelope would exceed either cap, Wardnet records `engine_unavailable` and does not call the sidecar; a partial request projection is never eligible for a clean verdict. The v1 JSON envelope can carry the body only as UTF-8 text. The gateway obtains that text through `String::from_utf8_lossy`, whose `Cow` ownership records whether the original request bytes were already valid UTF-8. The adapter accepts only the borrowed projection: valid UTF-8 is therefore preserved exactly, including a literal U+FFFD present in the original request. An owned projection proves that invalid input bytes were replaced, so Wardnet records `engine_unavailable` without calling Coraza and block mode fails closed rather than accepting a verdict over altered bytes. A future binary-safe owner contract may remove this UTF-8 limitation while preserving byte identity end to end. Sidecar evaluation has a 1.5 s timeout and a 1 MiB response cap. + +The correlation value is evidence binding, not authentication. Wardnet combines a process-randomized prefix with an atomic per-process sequence so concurrent evaluations cannot share an identifier and a restarted process is distinguishable with overwhelming practical probability. The sidecar must return the exact `wardnet.correlation_id` it received. Method and URI remain additional context, but neither is sufficient replay authority by itself. A response that omits the correlation value or returns a stale value from a prior same-route request is uncorrelated evidence even when method and URI match. + +A sidecar response is not accepted merely because it is HTTP 2xx. Wardnet requires parseable JSON evidence carrying the exact current `wardnet.correlation_id` plus the exact request method and URI. A clean decision additionally requires an explicit non-interrupted transaction, a response status below 400, and an empty messages array. Coraza/CRS rule messages retain their rule text/ID as SOC evidence, but the live adapter projects enforcement authority separately: only explicit `is_interrupted=true`, HTTP 403/406 from the sidecar, or transaction response 403/406 is disruptive. Disruptive evidence is subject to the same exact-request correlation requirement as clean evidence. A non-interrupted successful transaction with rule messages remains monitor evidence and is never promoted to a block merely because audit severity maps to a high score. Wardnet adds policy identity plus sidecar ruleset identity when supplied. An unconfigured engine and malformed, oversized, uncorrelated, timed-out, or unreachable evidence are `engine_unavailable`; a block-mode route fails closed with HTTP 503. Independent Wardnet threat/DNSBL evidence may deny a request first; Coraza is the required authorization boundary only for block-mode traffic that has not already been denied and would otherwise proceed upstream. Monitor mode records the degraded evidence and may continue, preserving route-scoped semantics. + +The dedicated Coraza client also disables ambient HTTP proxy inheritance and redirects. Validation of a loopback URL is not enough if `HTTP_PROXY`/`ALL_PROXY` can divert the inspection envelope, so `tests/coraza_loopback_proxy_boundary.rs` installs an attacker-controlled ambient proxy and requires it to observe zero Coraza calls. + +`tests/coraza_proven_engine_adapter.rs` uses a protocol fixture, not a substitute detector. The fixture returns Coraza-shaped block/clean evidence by test URI so the test proves Wardnet forwards the hostile `User-Agent`, excludes credential-bearing headers, carries and echoes the current correlation, enforces block versus monitor semantics, and fails closed on unusable engine evidence. `tests/coraza_exact_request_binding.rs` proves that method/URI-only evidence and a replayed correlation captured from the prior same-route request both fail closed. `tests/coraza_non_utf8_body.rs` proves both sides of the request-body identity boundary: invalid UTF-8 bytes must produce HTTP 503 without invoking the sidecar, while valid UTF-8 containing a literal U+FFFD must reach the sidecar exactly and may receive a correlated clean verdict. Production detection authority remains a real Coraza deployment with a pinned OWASP CRS ruleset. + +## Operational acceptance + +Before exposing a block-mode route through this boundary, deploy the Coraza evaluator on loopback, pin and inventory the CRS policy/ruleset, then construct `AppState` with `ProvenEngineConfig::sidecar(...)`. The sidecar adapter must echo `wardnet.correlation_id` from each request into the matching response without caching or substituting a prior value. A legacy fixture or sidecar that returns only method/URI is intentionally incompatible and will produce `engine_unavailable`/HTTP 503 in block mode. The current bounded slice does not add a new environment-variable or database configuration source because Runtime Configuration is owned by its separate Wardnet lane. That owner must expose the released/configured adapter without reintroducing handler-time environment reads before this becomes a packaged production default. + +Treat `engine_unavailable` events as protection-loss evidence. Do not convert malformed, stale, or uncorrelated evidence to `Clean`, and do not add local request signatures to compensate for a missing Coraza engine. + +Hosted successor run `35509666499` re-proved the earlier hostile request-context boundaries, passed the focused Coraza suites, the full locked workspace test suite, formatting, and strict Clippy, then promoted the reviewed production candidate as `ede52a7a25efc2f98e47b64802484009a43a8532`. That commit predates the exact-request correlation repair and remains historical candidate evidence only, not protected-branch or release evidence. Any later PR head must reacquire its own exact-head checks, reviews, and thread state before normal protected integration. + +## Traceability + +Coraza. (n.d.). *Coraza Web Application Firewall documentation*. https://coraza.io/docs/ + +National Institute of Standards and Technology. (2007). *Guide to intrusion detection and prevention systems (IDPS)* (NIST Special Publication 800-94). https://doi.org/10.6028/NIST.SP.800-94 + +OWASP Foundation. (2025). *OWASP Core Rule Set documentation*. https://coreruleset.org/docs/ + +Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in computer systems. *Proceedings of the IEEE, 63*(9), 1278–1308. https://doi.org/10.1109/PROC.1975.9939 + +These references ground the use of a proven WAF/ruleset authority, evidence correlation, and fail-safe treatment of unavailable or unverifiable decisions. They are rationale, not evidence that a specific Coraza/CRS build has been deployed or released. No paper PDF is added in this lane because redistribution permission for the exact retrieved versions was not independently established. diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index 9b6b7015..95bc6739 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -86,6 +86,14 @@ When `WAF_IDS_STATE_PATH` is enabled, the process writes a temporary sibling fil 4. Keep the previous route JSON available for rollback. 5. Disable the route or switch back to `monitor` if legitimate traffic is blocked. +## Coraza Sidecar Acceptance + +When a block-mode route uses `ProvenEngineConfig`, the Coraza evaluator must run on the configured loopback endpoint. Wardnet's dedicated sidecar transport ignores ambient `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` settings and does not follow redirects. Do not add proxying or non-loopback sidecar endpoints locally; broader executable outbound authorization belongs to EgressWeave through a released owner contract. + +Each `coraza-live-evaluate-v1` request includes `wardnet.correlation_id`. The sidecar adapter must echo that exact value in the matching JSON response. It must not cache, reuse, normalize, or substitute a correlation value from another request. A response with no correlation value, a stale value from a prior same-method/same-URI request, malformed JSON, an oversized body, or a timeout/connect failure is `engine_unavailable`; block mode returns HTTP 503 rather than treating it as clean evidence. This applies to disruptive evidence as well as clean evidence. + +If a newly deployed sidecar causes a sudden rise in `engine_unavailable`, first verify that its response adapter echoes `wardnet.correlation_id` from the same request and preserves method/URI. Do not disable the correlation check as a recovery measure. Roll back the sidecar adapter or the route to the previous safe/monitor configuration instead. + ## Commercial Readiness Procedure 1. Register buyer-approved license metadata through `POST /api/commercial/license`. @@ -103,7 +111,7 @@ This baseline is suitable for local and controlled lab deployments. Internet-fac - durable database storage with backups - SSO/OIDC federation (multi-token RBAC with readonly role and audit-log auth are available) - asynchronous event persistence or a database-backed event store for high-throughput gateway traffic -- In-process Coraza embedding (HTTP audit ingest at `POST /api/waf/coraza/audit` already fuses block hits into DNSBL/`client_ip` indicators for gateway enforcement) +- Package the live Coraza boundary: the code-level `ProvenEngineConfig` loopback sidecar port now evaluates matched requests, binds accepted verdict evidence to a request-unique Wardnet correlation plus method/URI, and fails block mode closed when the proven engine is unconfigured or its evidence is unusable, while Runtime Configuration still owns its deployment/bootstrap surface. Audit ingest at `POST /api/waf/coraza/audit` remains available for SOC evidence. - Live Suricata EVE tailing / shipper (HTTP ingest of EVE alerts is available at `POST /api/ids/suricata/eve`) - Live MISP REST pull or live OpenCTI GraphQL pull (HTTP STIX/MISP/OpenCTI document ingest and TAXII 2.1 poll are available at `POST /api/threat-intel/stix`, `POST /api/threat-intel/misp`, `POST /api/threat-intel/opencti`, and `POST /api/threat-intel/taxii/poll`) - human approval workflow for AI SOC recommendations that change enforcement 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/docs/security/threat-model.md b/docs/security/threat-model.md index 4fc52235..1a50f501 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -18,6 +18,7 @@ - The state file is trusted only after JSON deserialization succeeds. - A non-loopback listener is untrusted until a write-capable admin principal exists in the credential registry. This follows the fail-secure and authenticator-management posture documented in the production guide and runbook: start closed, bootstrap secrets into the registry, then expose the listener only after a usable write credential exists. - Threat feed import payloads are untrusted operator-supplied data. +- A configured Coraza sidecar is an external security-decision authority. Wardnet accepts it only over loopback with redirects and ambient proxies disabled, forwards a complete bounded credential-minimized request envelope, rejects unrepresentable or truncated allowlisted headers before sidecar authorization, and requires every accepted clean or disruptive verdict to echo the current request's Wardnet correlation identifier plus matching method/URI. An unconfigured engine and malformed, oversized, stale, uncorrelated, timed-out, or unreachable evidence is `engine_unavailable`; block-mode routes fail closed. ## Security Grounding @@ -36,9 +37,12 @@ The authentication-specific NIST SP 800-57 Part 1 Rev. 5 and NIST SP 800-63B sou | State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error | Database, backup, schema migration | | Upstream SSRF through routes | Internal network exposure | Upstream scheme validation | Upstream allowlists, egress policy | | Gateway DoS | Availability loss | Rust memory safety, event retention limit | Rate limits, body limits, async event sink | +| WAF authority confusion, stale verdict replay, or sidecar spoofing | Attack bypass or false block | Loopback-only/no-redirect/no-ambient-proxy Coraza sidecar; process-distinguishable request correlation echoed by every accepted verdict; exact method/URI context; bounded response/time; explicit `engine_unavailable` evidence | Pin/inventory production Coraza + CRS release identity and expose configuration through the Runtime Configuration owner lane | | DNSBL abuse | Reputation damage | Loopback response-code validation | Authoritative DNS service, signing, publisher workflow | | Secret disclosure | Admin compromise | Support bundle excludes admin token; secrets bootstrapped into credential registry (`WAF_IDS_CREDENTIALS_PATH` preferred over long-lived env); health exposes source label only | External secret manager / SSO, rotation, access review | +The Coraza correlation identifier is not an authentication credential. Its purpose is to prevent Wardnet from applying a valid verdict to the wrong request. Transport locality, no-proxy/no-redirect behavior, explicit sidecar deployment authority, and pinned Coraza/CRS identity remain separate controls; the correlation field must not be treated as a substitute for them. + ## Human Approval Boundary AI SOC recommendations may explain, summarize, or suggest actions, but enforcement-changing decisions must remain human-approved until audit trails, rollback, and policy simulation are implemented. @@ -49,4 +53,8 @@ Barker, E. (2020). *Recommendation for key management: Part 1 - General* (NIST S Grassi, P. A., Garcia, M. E., & Fenton, J. L. (2020). *Digital identity guidelines: Authentication and lifecycle management* (NIST SP 800-63B). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-63b +National Institute of Standards and Technology. (2007). *Guide to intrusion detection and prevention systems (IDPS)* (NIST Special Publication 800-94). https://doi.org/10.6028/NIST.SP.800-94 + National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in computer systems. *Proceedings of the IEEE, 63*(9), 1278–1308. https://doi.org/10.1109/PROC.1975.9939 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 38c1559f..e9b751c5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,14 +38,17 @@ pub use waf_ids_core::{ mod coraza_audit; mod credentials; +mod gateway_mediation; mod kev_import; mod misp_import; mod opencti_import; +mod proven_engine; mod stix_import; mod suricata_eve; mod taxii; pub use credentials::{CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource}; pub use credentials::{listen_is_loopback_only, require_write_auth_for_bind}; +pub use proven_engine::ProvenEngineConfig; #[derive(Clone)] pub struct AppState { @@ -53,6 +56,8 @@ pub struct AppState { persist_lock: Arc>, http: reqwest::Client, feed_http: reqwest::Client, + waf_http: reqwest::Client, + proven_engine: ProvenEngineConfig, admin_token: Option, // RBAC: multiple admin tokens each mapped to an actor + write capability. // Empty falls back to the single `admin_token`. Token values are never logged. @@ -132,6 +137,11 @@ impl AppState { .redirect(reqwest::redirect::Policy::none()) .build() .expect("failed to build no-redirect feed client"), + waf_http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build no-redirect WAF client"), + proven_engine: ProvenEngineConfig::disabled(), admin_token: config.admin_token, admin_tokens: HashMap::new(), credentials_source: CredentialSource::None, @@ -177,6 +187,12 @@ impl AppState { self } + /// Configure live request evaluation by the Wardnet-owned proven-engine port. + pub fn with_proven_engine(mut self, config: ProvenEngineConfig) -> Self { + self.proven_engine = config; + self + } + /// Enable the Clearfolio document-viewer integration. `None` disables it /// (the default), so the admin console hides the viewer surface. pub fn with_clearfolio(mut self, config: Option) -> Self { @@ -2426,16 +2442,102 @@ async fn gateway( .into_response(); } - record_event( - &state, - client_ip, - Some(route.id.clone()), - "monitored", - scored.reason.clone(), - scored.score, - gateway_path, + let engine_uri = match uri.query() { + Some(query) if !query.is_empty() => format!("{gateway_path}?{query}"), + _ => gateway_path.to_string(), + }; + let mut proven_engine_recorded = false; + match proven_engine::evaluate_sidecar( + &state.waf_http, + &state.proven_engine, + proven_engine::SidecarEvaluation { + method: method.as_str(), + uri: &engine_uri, + body: &body_text, + client_ip, + headers: &headers, + policy_id: &route.id, + }, ) - .await; + .await + { + proven_engine::ProvenEngineOutcome::Clean => {} + proven_engine::ProvenEngineOutcome::Hit(hit) => { + let disruptive = hit.action == "block"; + let action = if route.mode == EnforcementMode::Block && disruptive { + "blocked" + } else { + "monitored" + }; + record_event( + &state, + client_ip, + Some(route.id.clone()), + action, + hit.reason.clone(), + hit.score, + gateway_path, + ) + .await; + proven_engine_recorded = true; + if action == "blocked" { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "action": "blocked", + "route_id": route.id, + "score": hit.score, + "reason": hit.reason, + "engine": "coraza" + })), + ) + .into_response(); + } + } + proven_engine::ProvenEngineOutcome::Unavailable { reason } => { + record_event( + &state, + client_ip, + Some(route.id.clone()), + "engine_unavailable", + reason.clone(), + 0, + gateway_path, + ) + .await; + proven_engine_recorded = true; + if route.mode == EnforcementMode::Block { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "action": "engine_unavailable", + "route_id": route.id, + "reason": reason, + "engine": "coraza" + })), + ) + .into_response(); + } + } + } + + if !proven_engine_recorded { + record_event( + &state, + client_ip, + Some(route.id.clone()), + "monitored", + scored.reason.clone(), + scored.score, + gateway_path, + ) + .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 ( @@ -2453,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), } @@ -2473,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, @@ -2480,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()) @@ -2487,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( @@ -4142,11 +4273,16 @@ mod tests { gateway_get_from_ip("/gateway/app?q=1%20UNION%20SELECT%201", "203.0.113.9"), ) .await; - app_request( + let monitor = app_request( &app, gateway_get_from_ip("/gateway/demo?q=hi", "203.0.113.9"), ) .await; + assert_eq!( + monitor.status(), + StatusCode::OK, + "monitor mode must continue traffic when Coraza is unavailable while retaining degraded evidence" + ); let all: Vec = json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await; assert_eq!(all.len(), 2); @@ -4164,7 +4300,10 @@ mod tests { json_body(app_request(&app, empty_request(Method::GET, "/api/events?limit=1")).await) .await; assert_eq!(recent.len(), 1); - assert_eq!(recent[0]["action"], "monitored"); + assert_eq!( + recent[0]["action"], "engine_unavailable", + "an unconfigured proven WAF is degraded security evidence even when monitor mode continues the request" + ); } #[tokio::test] @@ -5515,12 +5654,16 @@ mod tests { .await; assert_eq!(route_response.status(), StatusCode::CREATED); - let allowed = app_request( + let undecidable = app_request( &app, empty_request(Method::GET, "/gateway/cve-lookup?id=CVE-2021-44228"), ) .await; - assert_eq!(allowed.status(), StatusCode::OK); + assert_eq!( + undecidable.status(), + StatusCode::SERVICE_UNAVAILABLE, + "KEV metadata must not become a request signature; without a proven WAF, an otherwise-allowed block-mode request is unavailable rather than falsely blocked" + ); } #[tokio::test] diff --git a/src/proven_engine.rs b/src/proven_engine.rs new file mode 100644 index 00000000..3eaca16b --- /dev/null +++ b/src/proven_engine.rs @@ -0,0 +1,552 @@ +//! Live Coraza/OWASP CRS request-evaluation boundary. +//! +//! Wardnet owns the route/policy decision, not WAF signatures. This +//! adapter sends a bounded, credential-minimized request envelope to a +//! same-host Coraza sidecar and accepts only response evidence bound to +//! the current Wardnet correlation plus the exact method/URI. It deliberately +//! does not implement CRS rules or general-purpose egress policy. + +use std::borrow::Cow; +use std::collections::hash_map::RandomState; +use std::hash::{BuildHasher, Hasher}; +use std::net::IpAddr; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use futures_util::StreamExt; + +use crate::coraza_audit::{CorazaIngestedHit, parse_coraza_audit_body}; + +pub const SIDECAR_TIMEOUT: Duration = Duration::from_millis(1_500); +pub const SIDECAR_MAX_BODY_BYTES: usize = 1_048_576; +pub const FORWARDED_HEADER_LIMIT: usize = 32; +pub const FORWARDED_HEADERS_MAX_BYTES: usize = 8_192; + +static CORRELATION_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Wardnet-owned configuration for the live WAF boundary. +/// +/// The sidecar is intentionally restricted to loopback. Broader +/// executable outbound authorization belongs to EgressWeave and must +/// arrive through a released owner contract rather than being copied +/// into Wardnet. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProvenEngineConfig { + sidecar_url: Option, +} + +impl ProvenEngineConfig { + pub fn disabled() -> Self { + Self { sidecar_url: None } + } + + pub fn sidecar(url: impl Into) -> Result { + let url = url.into().trim().to_string(); + if url.is_empty() { + return Err("Coraza sidecar URL must not be blank".to_string()); + } + validate_loopback_sidecar_url(&url)?; + Ok(Self { + sidecar_url: Some(url), + }) + } + + pub(crate) fn sidecar_url(&self) -> Option<&str> { + self.sidecar_url.as_deref() + } + + pub fn is_configured(&self) -> bool { + self.sidecar_url.is_some() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ProvenEngineOutcome { + Clean, + Hit(CorazaIngestedHit), + Unavailable { reason: String }, +} + +fn validate_loopback_sidecar_url(url: &str) -> Result<(), String> { + let parsed = + reqwest::Url::parse(url).map_err(|error| format!("invalid Coraza sidecar URL: {error}"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err("Coraza sidecar URL must use http or https".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "Coraza sidecar URL requires a host".to_string())?; + let numeric_host = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + if host.eq_ignore_ascii_case("localhost") + || numeric_host + .parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) + { + Ok(()) + } else { + Err( + "Coraza sidecar must be loopback-local until released EgressWeave authorization is available" + .to_string(), + ) + } +} + +/// Bounded request-header allowlist sent to the WAF engine. +/// +/// Credential-bearing fields such as `Authorization`, `Cookie`, +/// `Proxy-Authorization`, and `X-Admin-Token` are intentionally absent. +pub(crate) fn engine_forwarded_headers( + headers: &axum::http::HeaderMap, +) -> Result, String> { + let allowlist = [ + "host", + "user-agent", + "accept", + "content-type", + "referer", + "origin", + "x-requested-with", + ]; + let mut forwarded = Vec::new(); + let mut total = 0usize; + for name in allowlist { + for value in headers.get_all(name) { + if forwarded.len() >= FORWARDED_HEADER_LIMIT { + return Err(format!( + "Coraza request header envelope exceeds {FORWARDED_HEADER_LIMIT} fields" + )); + } + let value = value + .to_str() + .map_err(|_| format!("Coraza allowlisted request header {name} is not UTF-8"))?; + let next = total.saturating_add(name.len()).saturating_add(value.len()); + if next > FORWARDED_HEADERS_MAX_BYTES { + return Err(format!( + "Coraza request header envelope exceeds {FORWARDED_HEADERS_MAX_BYTES} bytes" + )); + } + total = next; + forwarded.push((name.to_string(), value.to_string())); + } + } + Ok(forwarded) +} + +fn random_correlation_word(domain: &[u8]) -> u64 { + let state = RandomState::new(); + let mut hasher = state.build_hasher(); + hasher.write(domain); + hasher.finish() +} + +fn correlation_process_prefix() -> &'static str { + static PREFIX: OnceLock = OnceLock::new(); + PREFIX.get_or_init(|| { + let high = random_correlation_word(b"wardnet-coraza-correlation-high"); + let low = random_correlation_word(b"wardnet-coraza-correlation-low"); + format!("{high:016x}{low:016x}") + }) +} + +fn next_correlation_id() -> Result { + let sequence = CORRELATION_SEQUENCE + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .map_err(|_| "Coraza request correlation sequence exhausted".to_string())?; + Ok(format!("{}-{sequence:016x}", correlation_process_prefix())) +} + +fn sidecar_request_body( + method: &str, + uri: &str, + body: &str, + client_ip: Option, + headers: &[(String, String)], + policy_id: &str, + correlation_id: &str, +) -> serde_json::Value { + let mut request = serde_json::json!({ + "method": method, + "uri": uri, + "headers": headers + .iter() + .map(|(name, value)| serde_json::json!({"name": name, "value": value})) + .collect::>(), + }); + if !body.is_empty() { + request["body"] = serde_json::Value::String(body.to_string()); + } + let mut transaction = serde_json::json!({ "request": request }); + if let Some(ip) = client_ip { + transaction["client_ip"] = serde_json::Value::String(ip.to_string()); + } + serde_json::json!({ + "transaction": transaction, + "wardnet": { + "policy_id": policy_id, + "contract": "coraza-live-evaluate-v1", + "correlation_id": correlation_id + } + }) +} + +async fn bounded_sidecar_text(response: reqwest::Response) -> Result { + if let Some(length) = response.content_length() + && length as usize > SIDECAR_MAX_BODY_BYTES + { + return Err(format!( + "Coraza sidecar response exceeds {SIDECAR_MAX_BODY_BYTES} bytes" + )); + } + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "Coraza sidecar response read failed".to_string())?; + if bytes.len().saturating_add(chunk.len()) > SIDECAR_MAX_BODY_BYTES { + return Err(format!( + "Coraza sidecar response exceeds {SIDECAR_MAX_BODY_BYTES} bytes" + )); + } + bytes.extend_from_slice(&chunk); + } + String::from_utf8(bytes).map_err(|_| "Coraza sidecar response was not UTF-8".to_string()) +} + +fn response_request(value: &serde_json::Value) -> Option<&serde_json::Value> { + value + .get("transaction") + .and_then(|tx| tx.get("request")) + .or_else(|| value.get("request")) +} + +fn response_correlates( + value: &serde_json::Value, + method: &str, + uri: &str, + correlation_id: &str, +) -> bool { + let Some(request) = response_request(value) else { + return false; + }; + let returned_method = request + .get("method") + .or_else(|| request.pointer("/http/method")) + .and_then(|value| value.as_str()); + let returned_uri = request + .get("uri") + .or_else(|| request.pointer("/http/uri")) + .and_then(|value| value.as_str()); + let returned_correlation = value + .pointer("/wardnet/correlation_id") + .and_then(|value| value.as_str()); + returned_method.is_some_and(|returned| returned.eq_ignore_ascii_case(method)) + && returned_uri == Some(uri) + && returned_correlation == Some(correlation_id) +} + +fn response_proves_clean(value: &serde_json::Value) -> bool { + let tx = value.get("transaction").unwrap_or(value); + let explicitly_not_interrupted = + tx.get("is_interrupted").and_then(|value| value.as_bool()) == Some(false); + let status = tx + .pointer("/response/http_code") + .or_else(|| tx.pointer("/response/status")) + .and_then(|value| value.as_u64()); + let messages = value + .get("messages") + .or_else(|| tx.get("messages")) + .and_then(|value| value.as_array()); + explicitly_not_interrupted + && status.is_some_and(|code| code < 400) + && messages.is_some_and(|items| items.is_empty()) +} + +fn response_proves_disruption( + value: &serde_json::Value, + sidecar_status: reqwest::StatusCode, +) -> bool { + if matches!(sidecar_status.as_u16(), 403 | 406) { + return true; + } + let tx = value.get("transaction").unwrap_or(value); + tx.get("is_interrupted").and_then(|value| value.as_bool()) == Some(true) + || tx + .pointer("/response/http_code") + .or_else(|| tx.pointer("/response/status")) + .and_then(|value| value.as_u64()) + .is_some_and(|code| matches!(code, 403 | 406)) +} + +fn evidence_suffix(value: &serde_json::Value, policy_id: &str) -> String { + let ruleset = value + .pointer("/engine/ruleset") + .or_else(|| value.pointer("/producer/ruleset")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()); + match ruleset { + Some(ruleset) => format!("engine=coraza; policy={policy_id}; ruleset={ruleset}"), + None => format!("engine=coraza; policy={policy_id}"), + } +} + +fn outcome_from_sidecar_response( + status: reqwest::StatusCode, + body: &str, + method: &str, + uri: &str, + correlation_id: &str, + client_ip: Option, + policy_id: &str, +) -> ProvenEngineOutcome { + if !status.is_success() && !matches!(status.as_u16(), 403 | 406) { + return ProvenEngineOutcome::Unavailable { + reason: format!("Coraza sidecar HTTP {status}"), + }; + } + let value = match serde_json::from_str::(body) { + Ok(value) => value, + Err(_) => { + return ProvenEngineOutcome::Unavailable { + reason: "Coraza sidecar returned malformed JSON evidence".to_string(), + }; + } + }; + if !response_correlates(&value, method, uri, correlation_id) { + return ProvenEngineOutcome::Unavailable { + reason: "Coraza sidecar evidence did not correlate to the exact request".to_string(), + }; + } + let suffix = evidence_suffix(&value, policy_id); + match parse_coraza_audit_body(body) { + Ok(mut parsed) if !parsed.hits.is_empty() => { + let disruptive = response_proves_disruption(&value, status); + let idx = parsed + .hits + .iter() + .position(|hit| hit.action == "block") + .unwrap_or(0); + let mut hit = parsed.hits.swap_remove(idx); + hit.reason = format!("{}; {suffix}", hit.reason); + hit.action = if disruptive { "block" } else { "monitor" }.to_string(); + ProvenEngineOutcome::Hit(hit) + } + Ok(_) if matches!(status.as_u16(), 403 | 406) => { + ProvenEngineOutcome::Hit(CorazaIngestedHit { + client_ip, + action: "block".to_string(), + reason: format!("coraza/crs: transaction interrupted ({status}); {suffix}"), + score: 50, + path: uri.to_string(), + timestamp_unix: None, + }) + } + Ok(_) if response_proves_clean(&value) => ProvenEngineOutcome::Clean, + Ok(_) => ProvenEngineOutcome::Unavailable { + reason: "Coraza sidecar evidence did not prove a clean or interrupted transaction" + .to_string(), + }, + Err(reason) => ProvenEngineOutcome::Unavailable { + reason: format!("Coraza sidecar audit evidence invalid: {reason}"), + }, + } +} + +pub(crate) struct SidecarEvaluation<'request, 'body> { + pub(crate) method: &'request str, + pub(crate) uri: &'request str, + pub(crate) body: &'request Cow<'body, str>, + pub(crate) client_ip: Option, + pub(crate) headers: &'request axum::http::HeaderMap, + pub(crate) policy_id: &'request str, +} + +fn loopback_sidecar_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .expect("failed to build isolated Coraza sidecar client") + }) +} + +pub(crate) async fn evaluate_sidecar( + _client: &reqwest::Client, + config: &ProvenEngineConfig, + request: SidecarEvaluation<'_, '_>, +) -> ProvenEngineOutcome { + let Some(url) = config.sidecar_url() else { + return ProvenEngineOutcome::Unavailable { + reason: "Coraza proven engine is not configured".to_string(), + }; + }; + // `String::from_utf8_lossy` returns a borrowed Cow only when the original + // request bytes are valid UTF-8. Preserve valid text exactly, including a + // literal U+FFFD, but refuse the owned replacement produced for invalid + // bytes so Coraza can never authorize a lossy projection. + if matches!(request.body, &Cow::Owned(_)) { + return ProvenEngineOutcome::Unavailable { + reason: "Wardnet cannot prove the Coraza v1 request body is byte-exact because the UTF-8 projection was lossy" + .to_string(), + }; + } + let body = request.body.as_ref(); + let forwarded_headers = match engine_forwarded_headers(request.headers) { + Ok(headers) => headers, + Err(reason) => return ProvenEngineOutcome::Unavailable { reason }, + }; + let correlation_id = match next_correlation_id() { + Ok(value) => value, + Err(reason) => return ProvenEngineOutcome::Unavailable { reason }, + }; + let payload = sidecar_request_body( + request.method, + request.uri, + body, + request.client_ip, + &forwarded_headers, + request.policy_id, + &correlation_id, + ); + let response = match loopback_sidecar_client() + .post(url) + .json(&payload) + .timeout(SIDECAR_TIMEOUT) + .send() + .await + { + Ok(response) => response, + Err(error) => { + let reason = if error.is_timeout() { + "Coraza sidecar timed out" + } else if error.is_connect() { + "Coraza sidecar is unreachable" + } else { + "Coraza sidecar request failed" + }; + return ProvenEngineOutcome::Unavailable { + reason: reason.to_string(), + }; + } + }; + let status = response.status(); + let body = match bounded_sidecar_text(response).await { + Ok(body) => body, + Err(reason) => return ProvenEngineOutcome::Unavailable { reason }, + }; + outcome_from_sidecar_response( + status, + &body, + request.method, + request.uri, + &correlation_id, + request.client_ip, + request.policy_id, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::{HeaderMap, HeaderValue}; + + #[test] + fn sidecar_is_loopback_only() { + assert!(ProvenEngineConfig::sidecar("http://127.0.0.1:9000/evaluate").is_ok()); + assert!(ProvenEngineConfig::sidecar("http://[::1]:9000/evaluate").is_ok()); + assert!(ProvenEngineConfig::sidecar("http://localhost:9000/evaluate").is_ok()); + assert!(ProvenEngineConfig::sidecar("https://example.com/evaluate").is_err()); + assert!(ProvenEngineConfig::sidecar("file:///tmp/coraza").is_err()); + } + + #[test] + fn header_forwarding_is_bounded_and_excludes_credentials() { + let mut headers = HeaderMap::new(); + headers.insert("host", HeaderValue::from_static("wardnet.example")); + headers.insert("user-agent", HeaderValue::from_static("buyer-probe/1")); + headers.insert("authorization", HeaderValue::from_static("Bearer secret")); + headers.insert("cookie", HeaderValue::from_static("sid=secret")); + headers.insert("x-admin-token", HeaderValue::from_static("admin-secret")); + let forwarded = engine_forwarded_headers(&headers).expect("complete bounded headers"); + let names: Vec<&str> = forwarded.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!(names, vec!["host", "user-agent"]); + + let mut oversized = HeaderMap::new(); + oversized.insert( + "user-agent", + HeaderValue::from_str(&"x".repeat(FORWARDED_HEADERS_MAX_BYTES + 1)).unwrap(), + ); + assert!(engine_forwarded_headers(&oversized).is_err()); + + let mut opaque = HeaderMap::new(); + opaque.insert("user-agent", HeaderValue::from_bytes(&[0x80]).unwrap()); + assert!(engine_forwarded_headers(&opaque).is_err()); + } + + #[test] + fn correlation_ids_are_unique_and_bounded() { + let first = next_correlation_id().expect("correlation id"); + let second = next_correlation_id().expect("correlation id"); + assert_ne!(first, second); + assert_eq!(first.len(), 49); + assert_eq!(second.len(), 49); + } + + #[test] + fn clean_evidence_requires_exact_request_correlation() { + let clean = r#"{ + "transaction": { + "is_interrupted": false, + "request": {"method":"GET","uri":"/ok"}, + "response": {"http_code":200} + }, + "wardnet": {"correlation_id":"current"}, + "messages": [], + "engine": {"ruleset":"owasp-crs-test"} + }"#; + assert_eq!( + outcome_from_sidecar_response( + reqwest::StatusCode::OK, + clean, + "GET", + "/ok", + "current", + None, + "route:test" + ), + ProvenEngineOutcome::Clean + ); + assert!(matches!( + outcome_from_sidecar_response( + reqwest::StatusCode::OK, + clean, + "GET", + "/ok", + "stale", + None, + "route:test" + ), + ProvenEngineOutcome::Unavailable { .. } + )); + assert!(matches!( + outcome_from_sidecar_response( + reqwest::StatusCode::OK, + clean, + "GET", + "/different", + "current", + None, + "route:test" + ), + ProvenEngineOutcome::Unavailable { .. } + )); + } +} diff --git a/tests/coraza_exact_request_binding.rs b/tests/coraza_exact_request_binding.rs new file mode 100644 index 00000000..4ee6c6ba --- /dev/null +++ b/tests/coraza_exact_request_binding.rs @@ -0,0 +1,181 @@ +use std::sync::Arc; + +use axum::{ + Json, Router, + body::Body, + extract::State, + http::{Method, Request, StatusCode, header::CONTENT_TYPE}, + routing::post, +}; +use serde_json::Value; +use tokio::{net::TcpListener, sync::Mutex, task::JoinHandle}; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppState, ProvenEngineConfig, build_app}; + +async fn method_uri_only_clean(Json(payload): Json) -> Json { + let request = &payload["transaction"]["request"]; + let method = request["method"].as_str().unwrap_or("POST"); + let uri = request["uri"].as_str().unwrap_or("/bind"); + + Json(serde_json::json!({ + "transaction": { + "is_interrupted": false, + "request": {"method": method, "uri": uri}, + "response": {"http_code": 200} + }, + "messages": [], + "engine": {"name": "coraza", "ruleset": "owasp-crs-test-fixture"} + })) +} + +async fn spawn_method_uri_only_sidecar() -> (String, JoinHandle<()>) { + let app = Router::new().route("/evaluate", post(method_uri_only_clean)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}/evaluate"), task) +} + +type ReplayState = Arc>>; + +async fn replay_first_correlation( + State(first_correlation): State, + Json(payload): Json, +) -> Json { + let request = &payload["transaction"]["request"]; + let method = request["method"].as_str().unwrap_or("POST"); + let uri = request["uri"].as_str().unwrap_or("/bind"); + let current = payload["wardnet"]["correlation_id"] + .as_str() + .unwrap_or("") + .to_string(); + let returned = { + let mut first = first_correlation.lock().await; + first.get_or_insert(current).clone() + }; + + Json(serde_json::json!({ + "transaction": { + "is_interrupted": false, + "request": {"method": method, "uri": uri}, + "response": {"http_code": 200} + }, + "wardnet": {"correlation_id": returned}, + "messages": [], + "engine": {"name": "coraza", "ruleset": "owasp-crs-test-fixture"} + })) +} + +async fn spawn_replaying_sidecar() -> (String, JoinHandle<()>) { + let state = ReplayState::default(); + let app = Router::new() + .route("/evaluate", post(replay_first_correlation)) + .with_state(state); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}/evaluate"), task) +} + +async fn app_with_block_route(sidecar_url: &str) -> axum::Router { + let state = AppState::seeded(Some("secret".to_string())) + .with_proven_engine(ProvenEngineConfig::sidecar(sidecar_url).expect("loopback sidecar")); + let app = build_app(state); + let route = serde_json::json!({ + "id": "coraza-request-binding", + "path_prefix": "/bind", + "upstream": "mock://coraza-request-binding", + "mode": "block", + "enabled": true, + "block_threshold": null + }) + .to_string(); + + 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)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(created.status(), StatusCode::CREATED); + app +} + +#[tokio::test] +async fn block_route_rejects_clean_verdict_bound_only_to_method_and_uri() { + let (url, task) = spawn_method_uri_only_sidecar().await; + let app = app_with_block_route(&url).await; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/bind") + .header(CONTENT_TYPE, "application/json") + .header("user-agent", "() { :;}; /bin/bash -c 'id'") + .body(Body::from(r#"{"role":"admin","action":"transfer"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "method+URI-only Coraza evidence can be stale or misrouted across same-route requests; block mode must fail closed until the verdict is bound to this exact request" + ); + + task.abort(); +} + +#[tokio::test] +async fn block_route_rejects_replayed_correlation_from_prior_same_route_request() { + let (url, task) = spawn_replaying_sidecar().await; + let app = app_with_block_route(&url).await; + + let first = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/bind") + .header(CONTENT_TYPE, "application/json") + .header("user-agent", "wardnet-benign/1.0") + .body(Body::from(r#"{"profile":"viewer"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::OK); + + let replayed = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/bind") + .header(CONTENT_TYPE, "application/json") + .header("user-agent", "() { :;}; /bin/bash -c 'id'") + .body(Body::from(r#"{"profile":"changed"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + replayed.status(), + StatusCode::SERVICE_UNAVAILABLE, + "a clean verdict carrying the previous same-route request correlation must not authorize the current request" + ); + + task.abort(); +} diff --git a/tests/coraza_live_enforcement.rs b/tests/coraza_live_enforcement.rs new file mode 100644 index 00000000..c4847f3a --- /dev/null +++ b/tests/coraza_live_enforcement.rs @@ -0,0 +1,122 @@ +//! Hostile acceptance for issue #434 / parent #86. +//! +//! Protected main scores gateway path/query/body/IP but has no live proven-WAF +//! authority on the request path. A header-only exploit therefore must not be +//! silently forwarded by a block-mode route when Coraza/OWASP CRS is absent. +//! The minimum acceptable behavior before a proven engine evaluates the request +//! is fail-closed; the successor implementation will add the real engine port. +//! +//! With no proven engine configured, block mode must fail closed even for benign +//! headers because Wardnet has no authority to infer a clean verdict. The separate +//! configured-adapter test proves ordinary traffic passes after explicit clean evidence. + +use axum::{ + body::Body, + http::{Method, Request, StatusCode, header::CONTENT_TYPE}, +}; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppState, build_app}; + +async fn app_with_block_route(route_id: &str, path_prefix: &str) -> axum::Router { + let app = build_app(AppState::seeded(Some("secret".to_string()))); + let route = serde_json::json!({ + "id": route_id, + "path_prefix": path_prefix, + "upstream": format!("mock://{route_id}"), + "mode": "block", + "enabled": true, + "block_threshold": null + }) + .to_string(); + + 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)) + .expect("valid route request"), + ) + .await + .expect("router must answer route write"); + assert_eq!(created.status(), StatusCode::CREATED); + app +} + +#[tokio::test] +async fn block_route_fails_closed_for_header_only_attack_without_proven_waf() { + let app = app_with_block_route("coraza-header-red", "/header-red").await; + + // CRS detects Shellshock-style command injection in request headers. The + // path/query/body are intentionally benign so Wardnet's local scorer cannot + // accidentally satisfy this contract with an in-house signature. + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/header-red") + .header("user-agent", "() { :;}; /bin/bash -c 'cat /etc/passwd'") + .body(Body::empty()) + .expect("valid hostile request"), + ) + .await + .expect("gateway must answer hostile request"); + + assert!( + matches!( + response.status(), + StatusCode::FORBIDDEN | StatusCode::SERVICE_UNAVAILABLE + ), + "block-mode traffic must fail closed until a proven Coraza/CRS authority evaluates header-borne attacks; got {}", + response.status() + ); +} + +#[tokio::test] +async fn block_route_fails_closed_for_benign_headers_when_proven_waf_is_unconfigured() { + let app = app_with_block_route("coraza-header-benign", "/header-benign").await; + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/header-benign") + .header("user-agent", "wardnet-buyer-probe/1.0") + .header("accept", "application/json") + .body(Body::empty()) + .expect("valid benign request"), + ) + .await + .expect("gateway must answer benign request"); + + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "block mode must not infer a clean WAF verdict when no proven engine is configured; the adapter contract separately proves benign traffic passes after explicit clean evidence" + ); +} + +#[tokio::test] +async fn block_route_preserves_local_deny_before_unconfigured_proven_waf() { + let app = app_with_block_route("coraza-local-deny", "/local-deny").await; + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/local-deny?q=1%20UNION%20SELECT%201") + .body(Body::empty()) + .expect("valid hostile request"), + ) + .await + .expect("gateway must answer locally denied request"); + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "independent Wardnet threat scoring may deny before Coraza; the missing proven engine gates only traffic that would otherwise proceed upstream" + ); +} diff --git a/tests/coraza_loopback_proxy_boundary.rs b/tests/coraza_loopback_proxy_boundary.rs new file mode 100644 index 00000000..3cba7eb2 --- /dev/null +++ b/tests/coraza_loopback_proxy_boundary.rs @@ -0,0 +1,170 @@ +//! Hostile transport acceptance for the live Coraza authority boundary. +//! +//! Reqwest enables system/environment proxies by default. A loopback-only URL +//! check is therefore insufficient if the dedicated Coraza client can still +//! honor `HTTP_PROXY`: the bounded inspection envelope can leave the host and a +//! proxy can impersonate the proven engine. This regression makes that boundary +//! executable by installing an attacker-controlled ambient proxy and requiring +//! the Coraza request to reach the configured loopback sidecar directly. + +use std::{collections::HashMap, sync::Arc}; + +use axum::{ + Json, Router, + body::Body, + extract::State, + http::{Method, Request, StatusCode, header::CONTENT_TYPE}, + routing::post, +}; +use serde_json::Value; +use tokio::{net::TcpListener, sync::Mutex, task::JoinHandle}; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppState, ProvenEngineConfig, build_app}; + +type Calls = Arc>>; + +async fn clean_coraza(State(calls): State, Json(payload): Json) -> Json { + calls.lock().await.push(payload.clone()); + let request = &payload["transaction"]["request"]; + let correlation_id = payload["wardnet"]["correlation_id"].as_str().unwrap_or(""); + Json(serde_json::json!({ + "transaction": { + "is_interrupted": false, + "request": { + "method": request["method"].as_str().unwrap_or("GET"), + "uri": request["uri"].as_str().unwrap_or("/") + }, + "response": {"http_code": 200} + }, + "wardnet": {"correlation_id": correlation_id}, + "messages": [], + "engine": {"name": "coraza", "ruleset": "owasp-crs-proxy-boundary-fixture"} + })) +} + +async fn spawn_http_peer() -> (String, Calls, JoinHandle<()>) { + let calls = Calls::default(); + let app = Router::new() + .route("/evaluate", post(clean_coraza)) + .fallback(post(clean_coraza)) + .with_state(calls.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), calls, task) +} + +struct EnvRestore(HashMap<&'static str, Option>); + +impl EnvRestore { + fn capture(keys: &[&'static str]) -> Self { + Self( + keys.iter() + .map(|key| (*key, std::env::var(key).ok())) + .collect(), + ) + } +} + +impl Drop for EnvRestore { + fn drop(&mut self) { + for (key, value) in &self.0 { + // SAFETY: this integration-test binary contains only this test, so + // process-global proxy variables cannot race another test thread. + unsafe { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } +} + +#[tokio::test] +async fn coraza_loopback_transport_ignores_attacker_controlled_system_proxy() { + const PROXY_KEYS: [&str; 8] = [ + "HTTP_PROXY", + "http_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", + "HTTPS_PROXY", + "https_proxy", + ]; + let _restore = EnvRestore::capture(&PROXY_KEYS); + + let (sidecar_origin, sidecar_calls, sidecar_task) = spawn_http_peer().await; + let (proxy_origin, proxy_calls, proxy_task) = spawn_http_peer().await; + + // SAFETY: this file is one integration-test binary with one test. The + // environment is captured/restored above and no sibling test can observe it. + unsafe { + for key in ["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"] { + std::env::set_var(key, &proxy_origin); + } + for key in ["NO_PROXY", "no_proxy", "HTTPS_PROXY", "https_proxy"] { + std::env::remove_var(key); + } + } + + let state = AppState::seeded(Some("secret".to_string())).with_proven_engine( + ProvenEngineConfig::sidecar(format!("{sidecar_origin}/evaluate")) + .expect("loopback Coraza sidecar"), + ); + let app = build_app(state); + let route = serde_json::json!({ + "id": "coraza-proxy-boundary", + "path_prefix": "/proxy-boundary", + "upstream": "mock://proxy-boundary", + "mode": "block", + "enabled": true, + "block_threshold": null + }) + .to_string(); + + 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)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(created.status(), StatusCode::CREATED); + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/proxy-boundary") + .header("user-agent", "wardnet-proxy-boundary/1.0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + assert_eq!( + proxy_calls.lock().await.len(), + 0, + "an ambient proxy must never observe or impersonate Wardnet's loopback-only Coraza authority" + ); + assert_eq!( + sidecar_calls.lock().await.len(), + 1, + "the inspection envelope must reach the configured same-host Coraza sidecar exactly once" + ); + + proxy_task.abort(); + sidecar_task.abort(); +} diff --git a/tests/coraza_non_disruptive_detection.rs b/tests/coraza_non_disruptive_detection.rs new file mode 100644 index 00000000..9b5331de --- /dev/null +++ b/tests/coraza_non_disruptive_detection.rs @@ -0,0 +1,97 @@ +use axum::{ + Json, Router, + body::Body, + http::{Method, Request, StatusCode, header::CONTENT_TYPE}, + routing::post, +}; +use serde_json::Value; +use tokio::{net::TcpListener, task::JoinHandle}; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppState, ProvenEngineConfig, build_app}; + +async fn detection_only(Json(payload): Json) -> Json { + let request = &payload["transaction"]["request"]; + let correlation_id = payload["wardnet"]["correlation_id"].as_str().unwrap_or(""); + Json(serde_json::json!({ + "transaction": { + "client_ip": "203.0.113.44", + "is_interrupted": false, + "request": { + "method": request["method"].as_str().unwrap_or("GET"), + "uri": request["uri"].as_str().unwrap_or("/detect-only") + }, + "response": {"http_code": 200} + }, + "wardnet": {"correlation_id": correlation_id}, + "messages": [{ + "message": "Protocol Attack Detected", + "data": {"id": 921110, "severity": 2} + }], + "engine": {"name": "coraza", "ruleset": "owasp-crs-test-fixture"} + })) +} + +async fn spawn_coraza_sidecar() -> (String, JoinHandle<()>) { + let app = Router::new().route("/evaluate", post(detection_only)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}/evaluate"), task) +} + +async fn app_with_block_route(sidecar_url: &str) -> axum::Router { + let state = AppState::seeded(Some("secret".to_string())) + .with_proven_engine(ProvenEngineConfig::sidecar(sidecar_url).expect("loopback sidecar")); + let app = build_app(state); + let route = serde_json::json!({ + "id": "coraza-detect-only", + "path_prefix": "/detect-only", + "upstream": "mock://coraza-detect-only", + "mode": "block", + "enabled": true, + "block_threshold": null + }) + .to_string(); + + 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)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(created.status(), StatusCode::CREATED); + app +} + +#[tokio::test] +async fn block_route_does_not_escalate_non_disruptive_coraza_detection_to_block() { + let (url, task) = spawn_coraza_sidecar().await; + let app = app_with_block_route(&url).await; + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/detect-only") + .header("user-agent", "wardnet-detection-probe/1.0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::OK, + "non-interrupted HTTP 200 Coraza evidence with a CRS message must remain SOC detection evidence rather than implicit engine block authority" + ); + task.abort(); +} diff --git a/tests/coraza_non_utf8_body.rs b/tests/coraza_non_utf8_body.rs new file mode 100644 index 00000000..e3e4ec9e --- /dev/null +++ b/tests/coraza_non_utf8_body.rs @@ -0,0 +1,146 @@ +use std::sync::Arc; + +use axum::{ + Json, Router, + body::Body, + extract::State, + http::{Method, Request, StatusCode, header::CONTENT_TYPE}, + routing::post, +}; +use serde_json::Value; +use tokio::{net::TcpListener, sync::Mutex, task::JoinHandle}; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppState, ProvenEngineConfig, build_app}; + +type Calls = Arc>>; + +async fn clean_coraza(State(calls): State, Json(payload): Json) -> Json { + calls.lock().await.push(payload.clone()); + let request = &payload["transaction"]["request"]; + let correlation_id = payload["wardnet"]["correlation_id"].as_str().unwrap_or(""); + Json(serde_json::json!({ + "transaction": { + "is_interrupted": false, + "request": { + "method": request["method"].as_str().unwrap_or("POST"), + "uri": request["uri"].as_str().unwrap_or("/") + }, + "response": {"http_code": 200} + }, + "wardnet": {"correlation_id": correlation_id}, + "messages": [], + "engine": {"name":"coraza", "ruleset":"owasp-crs-test-fixture"} + })) +} + +async fn spawn_coraza_sidecar() -> (String, Calls, JoinHandle<()>) { + let calls = Calls::default(); + let app = Router::new() + .route("/evaluate", post(clean_coraza)) + .with_state(calls.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}/evaluate"), calls, task) +} + +async fn block_app(sidecar_url: &str) -> axum::Router { + let state = AppState::seeded(Some("secret".to_string())) + .with_proven_engine(ProvenEngineConfig::sidecar(sidecar_url).expect("loopback sidecar")); + let app = build_app(state); + let route = serde_json::json!({ + "id": "coraza-non-utf8-body", + "path_prefix": "/non-utf8-body", + "upstream": "mock://coraza-non-utf8-body", + "mode": "block", + "enabled": true, + "block_threshold": null + }) + .to_string(); + + 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)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(created.status(), StatusCode::CREATED); + app +} + +#[tokio::test] +async fn block_route_never_authorizes_a_lossy_request_body_projection() { + let (url, calls, task) = spawn_coraza_sidecar().await; + let app = block_app(&url).await; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/non-utf8-body") + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(vec![b'a', 0xff, b'b'])) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "block mode must fail closed when the v1 Coraza envelope cannot represent the exact request body" + ); + assert_eq!( + calls.lock().await.len(), + 0, + "Wardnet must not ask Coraza to authorize a lossy UTF-8 replacement of the buyer request body" + ); + task.abort(); +} + +#[tokio::test] +async fn block_route_preserves_valid_utf8_replacement_character() { + let (url, calls, task) = spawn_coraza_sidecar().await; + let app = block_app(&url).await; + let body = "a\u{FFFD}b"; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/non-utf8-body") + .header(CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::OK, + "valid UTF-8 containing U+FFFD must not be confused with a lossy replacement of invalid bytes" + ); + let calls = calls.lock().await; + assert_eq!( + calls.len(), + 1, + "valid UTF-8 must reach the proven engine exactly once" + ); + assert_eq!( + calls[0]["transaction"]["request"]["body"], + Value::String(body.to_string()), + "the proven engine must receive the exact valid UTF-8 body" + ); + drop(calls); + task.abort(); +} diff --git a/tests/coraza_proven_engine_adapter.rs b/tests/coraza_proven_engine_adapter.rs new file mode 100644 index 00000000..195f6134 --- /dev/null +++ b/tests/coraza_proven_engine_adapter.rs @@ -0,0 +1,264 @@ +use std::sync::Arc; + +use axum::{ + Json, Router, + body::Body, + extract::State, + http::{Method, Request, StatusCode, header::CONTENT_TYPE}, + routing::post, +}; +use serde_json::Value; +use tokio::{net::TcpListener, sync::Mutex, task::JoinHandle}; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppState, ProvenEngineConfig, build_app}; + +type Calls = Arc>>; + +async fn coraza_evaluate(State(calls): State, Json(payload): Json) -> Json { + calls.lock().await.push(payload.clone()); + let request = &payload["transaction"]["request"]; + let method = request["method"].as_str().unwrap_or("GET"); + let uri = request["uri"].as_str().unwrap_or("/"); + let correlation_id = payload["wardnet"]["correlation_id"].as_str().unwrap_or(""); + if uri == "/malformed" { + return Json(serde_json::json!({"unexpected":"shape"})); + } + if uri == "/header-red" || uri == "/header-monitor" { + return Json(serde_json::json!({ + "transaction": { + "client_ip": "203.0.113.44", + "is_interrupted": true, + "request": {"method": method, "uri": uri}, + "response": {"http_code": 403} + }, + "wardnet": {"correlation_id": correlation_id}, + "messages": [{ + "message": "Remote Command Execution: Shellshock", + "data": {"id": 932170, "severity": 2} + }], + "engine": {"name":"coraza", "ruleset":"owasp-crs-test-fixture"} + })); + } + Json(serde_json::json!({ + "transaction": { + "is_interrupted": false, + "request": {"method": method, "uri": uri}, + "response": {"http_code": 200} + }, + "wardnet": {"correlation_id": correlation_id}, + "messages": [], + "engine": {"name":"coraza", "ruleset":"owasp-crs-test-fixture"} + })) +} + +async fn spawn_coraza_sidecar() -> (String, Calls, JoinHandle<()>) { + let calls = Calls::default(); + let app = Router::new() + .route("/evaluate", post(coraza_evaluate)) + .with_state(calls.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}/evaluate"), calls, task) +} + +async fn app_with_route( + route_id: &str, + path_prefix: &str, + mode: &str, + sidecar_url: &str, +) -> axum::Router { + let state = AppState::seeded(Some("secret".to_string())) + .with_proven_engine(ProvenEngineConfig::sidecar(sidecar_url).expect("loopback sidecar")); + let app = build_app(state); + let route = serde_json::json!({ + "id": route_id, + "path_prefix": path_prefix, + "upstream": format!("mock://{route_id}"), + "mode": mode, + "enabled": true, + "block_threshold": null + }) + .to_string(); + + 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)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(created.status(), StatusCode::CREATED); + app +} + +#[tokio::test] +async fn block_route_uses_coraza_for_header_only_attack_and_minimizes_credentials() { + let (url, calls, task) = spawn_coraza_sidecar().await; + let app = app_with_route("coraza-header-red", "/header-red", "block", &url).await; + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/header-red") + .header("user-agent", "() { :;}; /bin/bash -c 'cat /etc/passwd'") + .header("authorization", "Bearer must-not-forward") + .header("cookie", "session=must-not-forward") + .header("x-admin-token", "must-not-forward") + .header("x-forwarded-for", "198.51.100.66") + .header("x-real-ip", "198.51.100.77") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let calls = calls.lock().await; + assert_eq!(calls.len(), 1); + assert!( + calls[0]["wardnet"]["correlation_id"] + .as_str() + .is_some_and(|value| value.len() == 49), + "every Coraza evaluation must carry one bounded Wardnet correlation id" + ); + let headers = calls[0]["transaction"]["request"]["headers"] + .as_array() + .unwrap(); + assert!(headers.iter().any(|header| { + header["name"] == "user-agent" + && header["value"] + .as_str() + .is_some_and(|value| value.contains("() { :;}")) + })); + for forbidden in [ + "authorization", + "cookie", + "x-admin-token", + "proxy-authorization", + "x-forwarded-for", + "x-real-ip", + ] { + assert!( + headers.iter().all(|header| header["name"] != forbidden), + "raw client-attribution header {forbidden} must not cross the Coraza sidecar boundary" + ); + } + drop(calls); + task.abort(); +} + +#[tokio::test] +async fn benign_header_traffic_remains_allowed_after_proven_engine_evaluation() { + let (url, calls, task) = spawn_coraza_sidecar().await; + let app = app_with_route("coraza-header-benign", "/header-benign", "block", &url).await; + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/header-benign") + .header("user-agent", "wardnet-buyer-probe/1.0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(calls.lock().await.len(), 1); + task.abort(); +} + +#[tokio::test] +async fn block_route_fails_closed_on_malformed_coraza_evidence() { + let (url, _calls, task) = spawn_coraza_sidecar().await; + let app = app_with_route("coraza-malformed", "/malformed", "block", &url).await; + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/malformed") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + task.abort(); +} + +#[tokio::test] +async fn block_route_fails_closed_when_configured_coraza_is_unreachable() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + let url = format!("http://{addr}/evaluate"); + let app = app_with_route("coraza-unavailable", "/unavailable", "block", &url).await; + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/unavailable") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn monitor_route_records_coraza_hit_without_enforcement() { + let (url, _calls, task) = spawn_coraza_sidecar().await; + let app = app_with_route("coraza-header-monitor", "/header-monitor", "monitor", &url).await; + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/header-monitor") + .header("user-agent", "() { :;}; /bin/bash -c 'id'") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + task.abort(); +} + +#[tokio::test] +async fn block_route_fails_closed_before_sidecar_on_incomplete_header_envelope() { + let (url, calls, task) = spawn_coraza_sidecar().await; + let app = app_with_route("coraza-header-envelope", "/header-envelope", "block", &url).await; + let oversized = "x".repeat(9_000); + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/header-envelope") + .header("user-agent", oversized.as_str()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "an incomplete allowlisted-header projection must fail closed instead of accepting a clean Coraza verdict for a request the sidecar did not inspect in full" + ); + assert_eq!( + calls.lock().await.len(), + 0, + "Wardnet must not ask Coraza to authorize a partial allowlisted-header envelope" + ); + task.abort(); +} 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(); +}