dwara_core/dataplane/transforms.rs
1//! Request/response transforms and security-header injection (DW-028,
2//! feature analysis 4.12 + the 5-Security header-injection row).
3//!
4//! ## The streaming split (the issue's done-when)
5//!
6//! "Streaming is preserved unless a transform explicitly buffers." The
7//! implementation splits exactly there:
8//!
9//! - [`apply_header_ops`], [`apply_query_ops`], and
10//! [`apply_security_headers`] never touch a body. A route carrying
11//! only these forwards and streams byte-identically to a route with
12//! no transforms block at all.
13//! - [`transform_request_body`] / [`transform_response_body`] are the
14//! ONE explicitly buffering path: they apply only when the route
15//! configured a JSON body transform AND the body declares itself
16//! JSON AND it is non-empty, and they buffer at most
17//! `max_bytes` — enforced against a declared `Content-Length` up
18//! front and against the live stream frame by frame. Over-cap fails
19//! CLOSED (413 request-side, 502 response-side): passing the body
20//! through untransformed would be fail-open in exactly the masking
21//! direction DW-029 builds on this machinery for.
22//!
23//! ## Where each piece runs (the documented order)
24//!
25//! Request side, inside the proxy action on the FORWARD path: DW-010
26//! path rewrite, then [`apply_query_ops`] (the rewrite re-attaches the
27//! original query verbatim, this block is the only thing that may
28//! change it), then hop-by-hop stripping and the gateway's trusted
29//! headers (`X-Forwarded-*`, `X-Consumer-*`), then [`apply_header_ops`]
30//! — ops see the near-final forwarded request, which is also why they
31//! MAY remove the trust headers (the operator owns the upstream's
32//! contract; see `config::transforms`), and finally the body transform
33//! BEFORE retry buffering, so a retried attempt replays the
34//! TRANSFORMED bytes.
35//!
36//! Response side, in the decoration tail: FIELD MASKING first (DW-029,
37//! [`mask_response_body`] — before anything else can so much as read
38//! the body: once the sentinel replaces a secret, the original bytes
39//! exist nowhere in the gateway, so no later stage — operator
40//! transforms, the compression codec — can resurrect or re-emit them),
41//! then the body transform (compression then encodes the transformed
42//! bytes and its eligibility check sees the final content type), then
43//! header ops, then the existing compression -> versioning -> CORS
44//! stages, then [`apply_security_headers`] (last policy stamp before
45//! rate headers), then rate headers.
46//!
47//! Response HEADER and BODY transforms apply to action responses only;
48//! security headers apply to every route-matched response including
49//! gateway short-circuits (see `config::transforms::SecurityHeaders`
50//! for the asymmetry with deprecation stamps). MASKING applies to
51//! PROXY action responses only: the leak surface it guards is the
52//! upstream's output — gateway-authored bodies (redirect, respond) are
53//! operator config bytes with no upstream data to redact, and bodiless
54//! statuses (1xx/101/204/304) have nothing to leak; both pass.
55//!
56//! ## Masking's inverted gate posture (DW-029)
57//!
58//! The DW-028 body transform PASSES THROUGH what it cannot handle
59//! (encoded or non-JSON bodies) — a convenience transform skipping
60//! itself is harmless. Masking inverts every one of those gates into a
61//! 502 REFUSAL: the gateway cannot prove the configured fields absent
62//! from bytes it cannot parse, and for a redaction policy a skipped
63//! pass IS the leak (the module docs of `config::transforms` carry the
64//! full argument). A route that configures masking pins its proxied
65//! responses to the contract "identity-encoded JSON within the cap,
66//! with every configured pointer present" — an upstream that violates
67//! it answers 502 with a generic envelope, and one `dwara::policy`
68//! warn event names the refusal class server-side. Every successful
69//! mask emits one `dwara::policy` info event (the audit trail the
70//! issue's done-when asks for: route, consumer, count, request-id).
71//!
72//! ## Why header/query ops are lenient where JSON pointers are strict
73//!
74//! A header may legitimately be absent from a given request; renaming
75//! or removing an absent header is a no-op, not an error. A JSON
76//! pointer that does not resolve is a DATA-SHAPE violation against a
77//! contract the operator pinned — and in the remove direction a
78//! silent miss is the leak the policy exists to prevent. Leniency and
79//! strictness each sit where their failure mode says they belong.
80//!
81//! ## Framing after a body transform
82//!
83//! Both body transforms rewrite `Content-Length` to the transformed
84//! body's exact length (the buffered bytes ARE the body; any stale
85//! declared length would misframe the hop). Framing headers are
86//! meanwhile rejected from header ops by validation
87//! (`config::transforms::is_forbidden_*_header`), so exactly one
88//! component — this module — ever writes them.
89
90use bytes::Bytes;
91use http_body_util::Full;
92use hyper::header::{
93 HeaderMap, HeaderName, HeaderValue, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_SECURITY_POLICY,
94 CONTENT_TYPE, STRICT_TRANSPORT_SECURITY, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
95};
96use hyper::{Response, Uri};
97
98use crate::config::transforms as t;
99use crate::config::transforms::{CompiledJsonTransform, HeaderOps, QueryOps, SecurityHeaders};
100
101use crate::dataplane::proxy::ProxyBody;
102
103// ---------------------------------------------------------------------------
104// Header ops
105// ---------------------------------------------------------------------------
106
107/// Apply header manipulation ops in the frozen order (DW-028): `set`
108/// (replace-all), `add` (append), `rename` (relabel every value), then
109/// `remove` — see [`HeaderOps`] for the rationale. Every name/value
110/// was validated representable at config publish; the `if let Ok`
111/// skips are generation-tear backstops (never a panic, never a 500),
112/// the same posture as the respond-action headers.
113pub fn apply_header_ops(headers: &mut HeaderMap, ops: &HeaderOps) {
114 for (name, value) in &ops.set {
115 if let (Ok(n), Ok(v)) = (
116 HeaderName::from_bytes(name.as_bytes()),
117 HeaderValue::from_str(value),
118 ) {
119 headers.insert(n, v);
120 }
121 }
122 for (name, value) in &ops.add {
123 if let (Ok(n), Ok(v)) = (
124 HeaderName::from_bytes(name.as_bytes()),
125 HeaderValue::from_str(value),
126 ) {
127 headers.append(n, v);
128 }
129 }
130 // Rename: move EVERY value of `from` onto `to` (appending after any
131 // values `to` already carries); an absent `from` is a no-op (a
132 // header may simply not be present on this request).
133 for (from, to) in &ops.rename {
134 if let (Ok(f), Ok(to_n)) = (
135 HeaderName::from_bytes(from.as_bytes()),
136 HeaderName::from_bytes(to.as_bytes()),
137 ) {
138 let values: Vec<HeaderValue> = headers.get_all(&f).iter().cloned().collect();
139 headers.remove(&f);
140 for v in values {
141 headers.append(&to_n, v);
142 }
143 }
144 }
145 for name in &ops.remove {
146 if let Ok(n) = HeaderName::from_bytes(name.as_bytes()) {
147 headers.remove(&n);
148 }
149 }
150}
151
152// ---------------------------------------------------------------------------
153// Query ops
154// ---------------------------------------------------------------------------
155
156/// Apply query manipulation ops to a request URI (DW-028). Returns the
157/// rebuilt URI, or `None` when nothing changed (the caller keeps the
158/// original — its bytes are already correct). Original pairs are
159/// carried VERBATIM (no decode/re-encode round trip: a client's exact
160/// percent-encoding spelling survives); only pairs a named op touches
161/// are encoded, by the shared percent-encoder below.
162///
163/// Per-pair application order (deterministic, documented): a key named
164/// by `remove` or owned by `set` is dropped from its original position
165/// (`set` re-emits its pair at the end; `remove` just drops), then
166/// `rename` re-labels surviving pairs in place (value bytes verbatim),
167/// and `add` pairs append at the end (after `set`, both in sorted key
168/// order). Key matching is on RAW bytes — neither the config key nor
169/// the request key is percent-decoded first, so `set: {a: "1"}`
170/// matches the literal `a=1`, never `%61=1`.
171pub fn apply_query_ops(uri: &Uri, ops: &QueryOps) -> Option<Uri> {
172 let mut emitted: Vec<String> = Vec::new();
173 for (k, v) in ops.set.iter().chain(ops.add.iter()) {
174 emitted.push(format!(
175 "{}={}",
176 encode_query_component(k),
177 encode_query_component(v)
178 ));
179 }
180 let Some(original) = uri.query() else {
181 if emitted.is_empty() {
182 return None;
183 }
184 // No query yet: set/add build one from scratch.
185 return rebuild(uri, &emitted.join("&"));
186 };
187
188 let mut carried: Vec<String> = Vec::new();
189 for raw_pair in original.split('&') {
190 if raw_pair.is_empty() {
191 continue; // a trailing/doubled '&' contributes nothing
192 }
193 let key = raw_pair.split('=').next().unwrap_or("");
194 // Set-owned and removed keys do not carry (set re-emits at the
195 // end; remove is gone for good).
196 if ops.remove.iter().any(|r| r == key) || ops.set.contains_key(key) {
197 continue;
198 }
199 let key_out = match ops.rename.get(key) {
200 Some(to) => encode_query_component(to).into_owned(),
201 None => key.to_string(),
202 };
203 let piece = match raw_pair.split_once('=') {
204 Some((_, value)) => format!("{key_out}={value}"),
205 None => key_out,
206 };
207 carried.push(piece);
208 }
209 let mut all = carried;
210 all.extend(emitted);
211 if all.is_empty() {
212 // Everything removed, nothing emitted: the query disappears.
213 return rebuild(uri, "");
214 }
215 let joined = all.join("&");
216 if joined == original {
217 return None;
218 }
219 rebuild(uri, &joined)
220}
221
222/// Percent-encode one query component for emission (DW-028): encode
223/// every byte outside the RFC 3986 unreserved set and the query-safe
224/// sub-delims, ALWAYS encoding the three structural bytes (`&`, `=`,
225/// `#`) and space. Validation rejects control/non-ASCII input; the
226/// encoder stays defensive for any byte.
227fn encode_query_component(s: &str) -> std::borrow::Cow<'_, str> {
228 fn safe(b: u8) -> bool {
229 matches!(b,
230 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
231 | b'-' | b'.' | b'_' | b'~'
232 | b'!' | b'$' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b':' | b'@' | b'/' | b'?')
233 }
234 if s.bytes().all(safe) {
235 return std::borrow::Cow::Borrowed(s);
236 }
237 let mut out = String::with_capacity(s.len());
238 for b in s.bytes() {
239 if safe(b) {
240 out.push(b as char);
241 } else {
242 out.push_str(&format!("%{b:02X}"));
243 }
244 }
245 std::borrow::Cow::Owned(out)
246}
247
248/// Rebuild `uri` with a new query (`""` = no query). `None` on an
249/// unbuildable result — validation keeps ops' emissions in the safe
250/// alphabet, so this is a never-fail backstop that keeps the ORIGINAL
251/// URI rather than erroring the request.
252fn rebuild(uri: &Uri, query: &str) -> Option<Uri> {
253 let pq = if query.is_empty() {
254 uri.path().to_string()
255 } else {
256 format!("{}?{}", uri.path(), query)
257 };
258 let Ok(pq) = pq.parse::<hyper::http::uri::PathAndQuery>() else {
259 return None;
260 };
261 let mut parts = hyper::http::uri::Parts::from(uri.clone());
262 parts.path_and_query = Some(pq);
263 Uri::from_parts(parts).ok()
264}
265
266// ---------------------------------------------------------------------------
267// Security headers
268// ---------------------------------------------------------------------------
269
270/// Stamp a route's security-header policy onto a response (DW-028).
271/// Every present field REPLACES any upstream-sent value: the gateway
272/// is the source of truth for its edge policy (the same rule as the
273/// deprecation and `X-RateLimit-*` headers). Values are static or
274/// validation-checked representable; the `if let Ok` skips are the
275/// same generation-tear backstop posture as everywhere in the tail.
276pub fn apply_security_headers(headers: &mut HeaderMap, sh: &SecurityHeaders) {
277 if let Some(max_age) = sh.hsts_max_age_secs {
278 let mut value = format!("max-age={max_age}");
279 if sh.hsts_include_subdomains {
280 value.push_str("; includeSubDomains");
281 }
282 if sh.hsts_preload {
283 value.push_str("; preload");
284 }
285 // A u64 with the two literal directives is always representable;
286 // the parse keeps the shape honest.
287 if let Ok(v) = HeaderValue::from_str(&value) {
288 headers.insert(STRICT_TRANSPORT_SECURITY, v);
289 }
290 }
291 if sh.nosniff {
292 headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
293 }
294 if let Some(csp) = &sh.content_security_policy {
295 if let Ok(v) = HeaderValue::from_str(csp) {
296 headers.insert(CONTENT_SECURITY_POLICY, v);
297 }
298 }
299 if let Some(fo) = sh.frame_options {
300 headers.insert(X_FRAME_OPTIONS, HeaderValue::from_static(fo.header_value()));
301 }
302}
303
304// ---------------------------------------------------------------------------
305// Body transforms
306// ---------------------------------------------------------------------------
307
308/// The lowercase media type of a `Content-Type` header (parameters
309/// stripped), when the header is present and textual.
310pub fn media_type(headers: &HeaderMap) -> Option<String> {
311 headers
312 .get(&CONTENT_TYPE)?
313 .to_str()
314 .ok()
315 .map(|v| {
316 v.split(';')
317 .next()
318 .unwrap_or("")
319 .trim()
320 .to_ascii_lowercase()
321 })
322 .filter(|mt| !mt.is_empty())
323}
324
325/// Does this body's `Content-Type` make it a JSON body-transform
326/// candidate (DW-028)? (`application/json` and the `application/
327/// <*+json>` family, parameters ignored — see
328/// `config::transforms::is_json_media_type`.)
329pub fn is_json_body(headers: &HeaderMap) -> bool {
330 media_type(headers).is_some_and(|mt| t::is_json_media_type(&mt))
331}
332
333/// Why a REQUEST body transform refused or failed (DW-028). The proxy
334/// maps each arm to its client answer: `RouteLimit` keeps the route
335/// limit's 413 envelope (the route's cap, not the transform's, bound
336/// the body); `SignatureMismatch` keeps the HMAC family's 401 (the
337/// client's own signature bound a digest the body did not match);
338/// `TooLarge`/`InvalidJson`/`Unresolved` answer 413/400/400 in the
339/// transform's own envelope.
340#[derive(Debug)]
341pub enum RequestBodyTransformError {
342 /// The route's own streaming body limit aborted the read (the
343 /// limit wrapper inside this body stack tripped mid-buffer).
344 RouteLimit,
345 /// The signed-body digest did not match (DW-036): the request's
346 /// HMAC signature bound a different body.
347 SignatureMismatch,
348 /// The body exceeded the transform's `max_bytes`.
349 TooLarge { cap: u64 },
350 /// The body claims JSON but does not parse.
351 InvalidJson,
352 /// A pointer did not resolve (the strict-failure rule; see the
353 /// module docs).
354 Unresolved { path: String },
355 /// The body stream errored while buffering (client abort, framing
356 /// failure).
357 Body(String),
358}
359
360/// The outcome of the request body-transform step (DW-028).
361pub enum RequestBodyOutcome<B> {
362 /// No transform applied: the ORIGINAL body streams on untouched —
363 /// the streaming guarantee. (No policy, non-JSON or encoded
364 /// content type, or a declared-empty body.)
365 Original(B),
366 /// Replacement bytes: the caller rewrites `Content-Length` and
367 /// treats the body as replayable (retry attempts re-send these
368 /// bytes, never the pre-transform stream).
369 Replaced(Bytes),
370 /// The transform failed; the caller maps the error to its
371 /// 413/401/400 answer.
372 Failed(RequestBodyTransformError),
373}
374
375/// The REQUEST body transform (DW-028): gate, buffer (capped), parse,
376/// apply ops, serialize. Gates in order, cheapest first — no compiled
377/// policy (the caller's `None` check), then here: non-JSON content
378/// type (bodies of other types stream through untouched), an
379/// already-encoded body (the transform does not decode), a declared
380/// empty body (nothing to transform — bodiless methods pass), and a
381/// declared length over the cap (413 without reading a byte). Only
382/// then does anything buffer.
383///
384/// Reading the body here preserves every wrapper beneath it: the
385/// route's limit counting guard and the HMAC digest fold still see
386/// the ORIGINAL client bytes (the digest binds what the client
387/// signed; the transform then shapes what the upstream receives —
388/// enforcement and policy are separable by design).
389pub async fn transform_request_body<B>(
390 body: B,
391 compiled: &CompiledJsonTransform,
392 headers: &HeaderMap,
393) -> RequestBodyOutcome<B>
394where
395 B: hyper::body::Body<Data = Bytes> + Send + 'static,
396 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
397{
398 if !is_json_body(headers) || headers.contains_key(&CONTENT_ENCODING) {
399 return RequestBodyOutcome::Original(body);
400 }
401 let declared = body.size_hint().exact();
402 if declared == Some(0) {
403 return RequestBodyOutcome::Original(body);
404 }
405 let cap = compiled.max_bytes();
406 if declared.is_some_and(|d| d > cap) {
407 return RequestBodyOutcome::Failed(RequestBodyTransformError::TooLarge { cap });
408 }
409 let bytes = match buffer_capped(body, cap).await {
410 Ok(bytes) => bytes,
411 Err(e) => return RequestBodyOutcome::Failed(e),
412 };
413 if bytes.is_empty() {
414 // An UNDECLARED empty stream (e.g. a bodiless method without
415 // Content-Length): nothing to transform. The empty buffered
416 // body is byte-identical to what arrived.
417 return RequestBodyOutcome::Replaced(bytes);
418 }
419 match run_json_transform(&bytes, compiled) {
420 Ok(out) => RequestBodyOutcome::Replaced(out),
421 Err(e) => RequestBodyOutcome::Failed(e),
422 }
423}
424
425/// Why a RESPONSE body transform failed (DW-028); every arm answers
426/// 502 — the upstream violated the contract the transform pinned (the
427/// client never sees upstream detail).
428#[derive(Debug)]
429pub enum ResponseBodyTransformError {
430 /// Over the transform's `max_bytes` (declared or streamed).
431 TooLarge { cap: u64 },
432 /// Not valid JSON.
433 InvalidJson,
434 /// A pointer did not resolve.
435 Unresolved { path: String },
436 /// The upstream stream died mid-body while buffering.
437 Upstream(String),
438}
439
440/// The RESPONSE body transform (DW-028), the decoration tail's first
441/// stage: gate, buffer (capped), parse, apply ops, serialize. Gates:
442/// informational/101/204/304 statuses (no body to shape), non-JSON
443/// content type, and an already-encoded body all pass through
444/// UNTOUCHED — SSE and streamed downloads on a transformed route keep
445/// streaming, the core guarantee of this feature. A declared length
446/// over the cap fails 502 without reading.
447///
448/// Two properties fall out of buffering before headers reach the
449/// client: an upstream stream death mid-body answers a CLEAN 502
450/// envelope instead of a torn stream, and upstream trailers are
451/// dropped (they described the pre-transform body — forwarding a
452/// stale checksum trailer alongside replaced bytes would be a lie).
453pub async fn transform_response_body(
454 resp: Response<ProxyBody>,
455 compiled: &CompiledJsonTransform,
456 rid: &str,
457) -> Response<ProxyBody> {
458 use hyper::body::Body as _;
459
460 let status = resp.status();
461 if status.is_informational()
462 || status == hyper::StatusCode::SWITCHING_PROTOCOLS
463 || status == hyper::StatusCode::NO_CONTENT
464 || status == hyper::StatusCode::NOT_MODIFIED
465 {
466 return resp;
467 }
468 if !is_json_body(resp.headers()) || resp.headers().contains_key(&CONTENT_ENCODING) {
469 return resp;
470 }
471 let cap = compiled.max_bytes();
472 if resp.body().size_hint().exact().is_some_and(|d| d > cap) {
473 return response_transform_failure(rid);
474 }
475
476 let (mut parts, body) = resp.into_parts();
477 let bytes = match collect_capped(body, cap).await {
478 Ok(bytes) => bytes,
479 Err(e) => {
480 tracing::warn!(
481 code = "response_transform_failed",
482 request_id = %rid,
483 error = ?e,
484 "upstream response failed the route's body transform"
485 );
486 return response_transform_failure(rid);
487 }
488 };
489 // An empty JSON-typed body has nothing to transform; forwarding
490 // the collected emptiness is byte-identical to passthrough.
491 let out = if bytes.is_empty() {
492 Ok(bytes)
493 } else {
494 run_json_transform(&bytes, compiled)
495 };
496 match out {
497 Ok(out) => {
498 if let Ok(v) = HeaderValue::from_str(&out.len().to_string()) {
499 parts.headers.insert(CONTENT_LENGTH, v);
500 }
501 Response::from_parts(parts, ProxyBody::Full(Full::new(out)))
502 }
503 Err(e) => {
504 tracing::warn!(
505 code = "response_transform_failed",
506 request_id = %rid,
507 error = ?e,
508 "upstream response failed the route's body transform"
509 );
510 response_transform_failure(rid)
511 }
512 }
513}
514
515/// Buffer a response body up to `cap` bytes (the shared DW-028/DW-029
516/// response-side capped collector). Trailers are dropped: they
517/// described the pre-transform body, and forwarding a stale checksum
518/// trailer alongside replaced bytes would be a lie. Over-cap and
519/// mid-body stream death surface as the two failure arms; both callers
520/// answer 502 (buffering before headers reach the client is what makes
521/// that a clean envelope instead of a torn stream).
522async fn collect_capped<B>(body: B, cap: u64) -> Result<Bytes, ResponseBodyTransformError>
523where
524 B: hyper::body::Body<Data = Bytes>,
525 B::Error: std::fmt::Display,
526{
527 use http_body_util::BodyExt as _;
528 let mut body = std::pin::pin!(body);
529 let mut buf: Vec<u8> = Vec::new();
530 loop {
531 match body.as_mut().frame().await {
532 Some(Ok(frame)) => {
533 let Ok(data) = frame.into_data() else {
534 continue; // trailer: dropped (see the doc comment)
535 };
536 if buf.len() as u64 + data.len() as u64 > cap {
537 return Err(ResponseBodyTransformError::TooLarge { cap });
538 }
539 buf.extend_from_slice(&data);
540 }
541 Some(Err(e)) => {
542 return Err(ResponseBodyTransformError::Upstream(e.to_string()));
543 }
544 None => return Ok(Bytes::from(buf)),
545 }
546 }
547}
548
549/// The 502 for a failed response transform: generic message (no
550/// upstream internals leak), uniform JSON envelope.
551fn response_transform_failure(rid: &str) -> Response<ProxyBody> {
552 Response::builder()
553 .status(hyper::StatusCode::BAD_GATEWAY)
554 .header(CONTENT_TYPE, "application/json")
555 .body(ProxyBody::Full(Full::new(
556 crate::observability::envelope_body(
557 "response_transform_failed",
558 "upstream response failed the route's transform policy",
559 rid,
560 ),
561 )))
562 .expect("static 502 response is valid")
563}
564
565// ---------------------------------------------------------------------------
566// Response field masking (DW-029)
567// ---------------------------------------------------------------------------
568
569/// Why masking refused a response (DW-029). Every arm answers the same
570/// 502 envelope; the distinction exists for the server-side
571/// `dwara::policy` warn event (which refusal class fired).
572#[derive(Debug)]
573enum MaskRefusal {
574 /// The upstream sent a content-encoded body: the gateway does not
575 /// decode, and cannot prove anything about bytes it cannot read.
576 Encoded,
577 /// The body is not JSON-media-typed: pointers cannot apply to it,
578 /// so its contents cannot be proven clean.
579 NotJson,
580 /// Over the masking cap (declared or streamed).
581 TooLarge { cap: u64 },
582 /// JSON-typed but unparseable.
583 InvalidJson,
584 /// A configured pointer did not resolve (schema drift; the strict
585 /// miss-is-the-leak rule).
586 Unresolved { path: String },
587 /// The upstream stream died mid-body while buffering.
588 Upstream(String),
589}
590
591impl std::fmt::Display for MaskRefusal {
592 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
593 match self {
594 MaskRefusal::Encoded => {
595 write!(f, "response is content-encoded; masking cannot read it")
596 }
597 MaskRefusal::NotJson => {
598 write!(
599 f,
600 "response is not JSON; masking cannot prove fields absent"
601 )
602 }
603 MaskRefusal::TooLarge { cap } => {
604 write!(f, "response exceeds the masking cap of {cap} bytes")
605 }
606 MaskRefusal::InvalidJson => write!(f, "response claims JSON but does not parse"),
607 MaskRefusal::Unresolved { path } => {
608 write!(f, "masking pointer '{path}' does not resolve")
609 }
610 MaskRefusal::Upstream(e) => write!(f, "upstream stream failed mid-body: {e}"),
611 }
612 }
613}
614
615/// The RESPONSE field masking pass (DW-029), the decoration tail's
616/// first stage and the security floor of the response path. Gates, in
617/// order — every one of them FAILS CLOSED (the inverted posture; see
618/// the module docs): bodiless statuses pass (nothing to leak), then a
619/// content-encoded body, a non-JSON content type, a declared length
620/// over the cap, an over-cap or dying stream, unparseable JSON, and a
621/// pointer miss each answer 502 with a generic envelope plus one
622/// `dwara::policy` warn event naming the refusal class server-side.
623/// The gateway's OWN compression (DW-027) runs later in the tail, so
624/// it can never trip the encoding gate — only upstream-pre-encoded
625/// responses do.
626///
627/// On success every effective pointer (route floor plus the consumer's
628/// groups — the union rule, `config::transforms::Masking`) is replaced
629/// with the fixed sentinel, `Content-Length` is rewritten, and one
630/// `dwara::policy` info event records route, consumer, masked count,
631/// and request-id: the audit trail the issue's done-when requires.
632pub async fn mask_response_body(
633 resp: Response<ProxyBody>,
634 compiled: &crate::config::transforms::CompiledMasking,
635 consumer_groups: &[String],
636 route: &str,
637 consumer: Option<&str>,
638 rid: &str,
639) -> Response<ProxyBody> {
640 use hyper::body::Body as _;
641
642 let status = resp.status();
643 if status.is_informational()
644 || status == hyper::StatusCode::SWITCHING_PROTOCOLS
645 || status == hyper::StatusCode::NO_CONTENT
646 || status == hyper::StatusCode::NOT_MODIFIED
647 {
648 return resp;
649 }
650 if resp.headers().contains_key(&CONTENT_ENCODING) {
651 return masking_refusal(rid, route, consumer, &MaskRefusal::Encoded);
652 }
653 if !is_json_body(resp.headers()) {
654 return masking_refusal(rid, route, consumer, &MaskRefusal::NotJson);
655 }
656 let cap = compiled.max_bytes();
657 if resp.body().size_hint().exact().is_some_and(|d| d > cap) {
658 return masking_refusal(rid, route, consumer, &MaskRefusal::TooLarge { cap });
659 }
660 let (mut parts, body) = resp.into_parts();
661 let bytes = match collect_capped(body, cap).await {
662 Ok(bytes) => bytes,
663 Err(e) => {
664 let refusal = match e {
665 ResponseBodyTransformError::TooLarge { cap } => MaskRefusal::TooLarge { cap },
666 // collect_capped cannot produce the parse/pointer arms;
667 // the debug-formatted fallback keeps the match total with
668 // no unreachable panic on the request path (server-side
669 // log only, like every refusal reason).
670 other => MaskRefusal::Upstream(format!("{other:?}")),
671 };
672 return masking_refusal(rid, route, consumer, &refusal);
673 }
674 };
675 // An empty body has nothing to mask — byte-identical passthrough
676 // (a proxied HEAD lands here, among others).
677 if bytes.is_empty() {
678 if let Ok(v) = HeaderValue::from_str("0") {
679 parts.headers.insert(CONTENT_LENGTH, v);
680 }
681 return Response::from_parts(parts, ProxyBody::Full(Full::new(bytes)));
682 }
683 let mut doc: serde_json::Value = match serde_json::from_slice(&bytes) {
684 Ok(doc) => doc,
685 Err(_) => return masking_refusal(rid, route, consumer, &MaskRefusal::InvalidJson),
686 };
687 let masked = match compiled.apply(&mut doc, consumer_groups) {
688 Ok(masked) => masked,
689 Err(t::JsonTransformError::Unresolved { path }) => {
690 return masking_refusal(rid, route, consumer, &MaskRefusal::Unresolved { path })
691 }
692 // Validation rejects the root pointer for masking; this arm is
693 // the same generation-tear backstop mapping the request-side
694 // transform uses (RemoveRoot -> empty-path Unresolved).
695 Err(t::JsonTransformError::RemoveRoot) => {
696 return masking_refusal(
697 rid,
698 route,
699 consumer,
700 &MaskRefusal::Unresolved {
701 path: String::new(),
702 },
703 )
704 }
705 };
706 let out = match serde_json::to_vec(&doc) {
707 Ok(out) => Bytes::from(out),
708 Err(_) => return masking_refusal(rid, route, consumer, &MaskRefusal::InvalidJson),
709 };
710 tracing::info!(
711 target: "dwara::policy",
712 code = "response_masked",
713 route = route,
714 consumer = consumer.unwrap_or("anonymous"),
715 masked = masked,
716 request_id = rid,
717 "masked response fields (DW-029 audit trail)"
718 );
719 if let Ok(v) = HeaderValue::from_str(&out.len().to_string()) {
720 parts.headers.insert(CONTENT_LENGTH, v);
721 }
722 Response::from_parts(parts, ProxyBody::Full(Full::new(out)))
723}
724
725/// The fail-closed 502 for a response masking refused: generic client
726/// message (no pointer paths, no upstream detail), uniform JSON
727/// envelope, and the server-side `dwara::policy` warn event that names
728/// the refusal class — the correlation key is the request-id.
729fn masking_refusal(
730 rid: &str,
731 route: &str,
732 consumer: Option<&str>,
733 refusal: &MaskRefusal,
734) -> Response<ProxyBody> {
735 tracing::warn!(
736 target: "dwara::policy",
737 code = "response_mask_failed",
738 route = route,
739 consumer = consumer.unwrap_or("anonymous"),
740 request_id = rid,
741 reason = %refusal,
742 "response refused by the route's masking policy (fail-closed)"
743 );
744 Response::builder()
745 .status(hyper::StatusCode::BAD_GATEWAY)
746 .header(CONTENT_TYPE, "application/json")
747 .body(ProxyBody::Full(Full::new(
748 crate::observability::envelope_body(
749 "response_mask_failed",
750 "response failed the route's masking policy",
751 rid,
752 ),
753 )))
754 .expect("static 502 response is valid")
755}
756
757/// Buffer a body up to `cap` bytes (the DW-028 transform cap).
758/// Distinguishes the two marker errors the gateway's own body
759/// wrappers produce (route limit, signature digest) from a generic
760/// body failure, so the caller answers with the right status family.
761async fn buffer_capped<B>(body: B, cap: u64) -> Result<Bytes, RequestBodyTransformError>
762where
763 B: hyper::body::Body<Data = Bytes> + Send + 'static,
764 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
765{
766 use http_body_util::BodyExt as _;
767 let mut body = Box::pin(body);
768 let mut buf: Vec<u8> = Vec::new();
769 loop {
770 match body.frame().await {
771 Some(Ok(frame)) => {
772 let Ok(data) = frame.into_data() else {
773 continue; // trailers are not body bytes
774 };
775 if buf.len() as u64 + data.len() as u64 > cap {
776 return Err(RequestBodyTransformError::TooLarge { cap });
777 }
778 buf.extend_from_slice(&data);
779 }
780 Some(Err(e)) => {
781 let boxed: Box<dyn std::error::Error + Send + Sync> = e.into();
782 return Err(classify_body_error(boxed));
783 }
784 None => return Ok(Bytes::from(buf)),
785 }
786 }
787}
788
789/// Map a body-wrapper error to its transform failure class: the route
790/// limit's marker keeps the limit's 413, the signature digest marker
791/// keeps the HMAC family's 401, everything else is a generic body
792/// failure. Same marker types (and the same walking rationale) as
793/// `proxy::request_limit_exceeded` / `proxy::signature_body_mismatch`.
794fn classify_body_error(e: Box<dyn std::error::Error + Send + Sync>) -> RequestBodyTransformError {
795 use crate::dataplane::hardening::{LimitedBodyError, SignatureBodyError};
796 let mut src: Option<&(dyn std::error::Error + 'static)> = Some(e.as_ref());
797 while let Some(s) = src {
798 if let Some(limit) = s.downcast_ref::<LimitedBodyError>() {
799 match limit {
800 LimitedBodyError::OverLimit { .. } => return RequestBodyTransformError::RouteLimit,
801 LimitedBodyError::Inner(inner) => {
802 if inner
803 .downcast_ref::<SignatureBodyError>()
804 .is_some_and(|se| matches!(se, SignatureBodyError::DigestMismatch))
805 {
806 return RequestBodyTransformError::SignatureMismatch;
807 }
808 }
809 }
810 }
811 if s.downcast_ref::<SignatureBodyError>()
812 .is_some_and(|se| matches!(se, SignatureBodyError::DigestMismatch))
813 {
814 return RequestBodyTransformError::SignatureMismatch;
815 }
816 src = s.source();
817 }
818 RequestBodyTransformError::Body(e.to_string())
819}
820
821/// Parse, apply ops, serialize (shared by both body transforms). The
822/// request side reuses the error enum; the response side maps the same
823/// failure set to its 502 family.
824fn run_json_transform(
825 bytes: &Bytes,
826 compiled: &CompiledJsonTransform,
827) -> Result<Bytes, RequestBodyTransformError> {
828 let mut doc: serde_json::Value =
829 serde_json::from_slice(bytes).map_err(|_| RequestBodyTransformError::InvalidJson)?;
830 compiled.apply(&mut doc).map_err(|e| match e {
831 t::JsonTransformError::Unresolved { path } => {
832 RequestBodyTransformError::Unresolved { path }
833 }
834 t::JsonTransformError::RemoveRoot => RequestBodyTransformError::Unresolved {
835 path: String::new(),
836 },
837 })?;
838 serde_json::to_vec(&doc)
839 .map(Bytes::from)
840 .map_err(|_| RequestBodyTransformError::InvalidJson)
841}