@@ -68,6 +68,20 @@ pub struct UpstreamSpec<'a> {
6868 pub tls_connector : & ' a TlsConnector ,
6969}
7070
71+ /// A response-body rewriter for OAuth-capture routes.
72+ ///
73+ /// When passed to [`forward_request`], it switches the response path from
74+ /// chunk-by-chunk streaming to buffer-the-whole-response: the closure is
75+ /// invoked on the body bytes (chunked transfer decoded first), and:
76+ /// - `Some(new_body)` → forward rebuilt headers (Content-Length replaced;
77+ /// Transfer-Encoding / Content-Encoding dropped) + the new body.
78+ /// - `None` → forward the original response unchanged (pass-through-on-error
79+ /// — body wasn't JSON, no token fields, etc.).
80+ ///
81+ /// Pass `None` at the call site to keep the historical streaming behaviour
82+ /// for non-capture routes.
83+ pub type ResponseBodyRewriter < ' a > = Box < dyn FnOnce ( & [ u8 ] ) -> Option < Vec < u8 > > + Send + ' a > ;
84+
7185/// Audit-emission context.
7286pub struct AuditCtx < ' a > {
7387 pub log : Option < & ' a audit:: SharedAuditLog > ,
@@ -92,6 +106,7 @@ pub async fn forward_request<S>(
92106 body : & [ u8 ] ,
93107 upstream : UpstreamSpec < ' _ > ,
94108 audit : AuditCtx < ' _ > ,
109+ response_hook : Option < ResponseBodyRewriter < ' _ > > ,
95110) -> Result < u16 >
96111where
97112 S : AsyncRead + AsyncWrite + Unpin ,
@@ -100,12 +115,12 @@ where
100115 UpstreamScheme :: Https => {
101116 let mut tls_stream = open_https_upstream ( & upstream) . await ?;
102117 write_request ( & mut tls_stream, request_bytes, body) . await ?;
103- stream_response ( & mut tls_stream, inbound) . await ?
118+ stream_response ( & mut tls_stream, inbound, response_hook ) . await ?
104119 }
105120 UpstreamScheme :: Http => {
106121 let mut tcp_stream = open_http_upstream ( & upstream) . await ?;
107122 write_request ( & mut tcp_stream, request_bytes, body) . await ?;
108- stream_response ( & mut tcp_stream, inbound) . await ?
123+ stream_response ( & mut tcp_stream, inbound, response_hook ) . await ?
109124 }
110125 } ;
111126
@@ -234,7 +249,24 @@ where
234249/// Returns the HTTP status code parsed from the first chunk. Streams
235250/// chunked / SSE / HTTP-streaming bodies transparently because we never
236251/// buffer the body — each upstream read is mirrored to the inbound write.
237- async fn stream_response < U , I > ( upstream : & mut U , inbound : & mut I ) -> Result < u16 >
252+ async fn stream_response < U , I > (
253+ upstream : & mut U ,
254+ inbound : & mut I ,
255+ response_hook : Option < ResponseBodyRewriter < ' _ > > ,
256+ ) -> Result < u16 >
257+ where
258+ U : AsyncRead + AsyncWrite + Unpin ,
259+ I : AsyncWrite + Unpin ,
260+ {
261+ match response_hook {
262+ None => stream_response_passthrough ( upstream, inbound) . await ,
263+ Some ( rewriter) => stream_response_buffered ( upstream, inbound, rewriter) . await ,
264+ }
265+ }
266+
267+ /// Historical chunk-by-chunk pass-through (no buffering). Used for every
268+ /// non-OAuth-capture route, preserving streaming/SSE/gRPC behaviour.
269+ async fn stream_response_passthrough < U , I > ( upstream : & mut U , inbound : & mut I ) -> Result < u16 >
238270where
239271 U : AsyncRead + AsyncWrite + Unpin ,
240272 I : AsyncWrite + Unpin ,
@@ -265,6 +297,154 @@ where
265297 Ok ( status_code)
266298}
267299
300+ /// Buffer the full upstream response, hand the body to `rewriter`, and — if it
301+ /// returns `Some(new_body)` — rebuild framing with the new Content-Length
302+ /// (dropping Transfer-Encoding / Content-Encoding, since we now hold plaintext
303+ /// rewritten bytes). On any framing-parse failure or a `None` from the
304+ /// rewriter, the original bytes are forwarded unchanged (pass-through-on-error
305+ /// preserves `/login` even when the body isn't what we expected). Used only by
306+ /// OAuth-capture routes, whose token endpoint returns a small, non-streaming
307+ /// JSON body.
308+ async fn stream_response_buffered < U , I > (
309+ upstream : & mut U ,
310+ inbound : & mut I ,
311+ rewriter : ResponseBodyRewriter < ' _ > ,
312+ ) -> Result < u16 >
313+ where
314+ U : AsyncRead + AsyncWrite + Unpin ,
315+ I : AsyncWrite + Unpin ,
316+ {
317+ let mut raw = Vec :: new ( ) ;
318+ if let Err ( e) = upstream. read_to_end ( & mut raw) . await {
319+ debug ! ( "Upstream read error while buffering for rewriter: {}" , e) ;
320+ // Forward whatever we managed to read.
321+ }
322+ let status_code = parse_response_status ( & raw ) ;
323+
324+ // Locate the header/body split. Tolerate a lone-LF separator.
325+ let header_end = raw
326+ . windows ( 4 )
327+ . position ( |w| w == b"\r \n \r \n " )
328+ . map ( |i| i + 4 )
329+ . or_else ( || raw. windows ( 2 ) . position ( |w| w == b"\n \n " ) . map ( |i| i + 2 ) ) ;
330+
331+ let Some ( body_start) = header_end else {
332+ debug ! ( "response-hook: no header/body separator; forwarding unchanged" ) ;
333+ inbound. write_all ( & raw ) . await ?;
334+ inbound. flush ( ) . await ?;
335+ return Ok ( status_code) ;
336+ } ;
337+
338+ let header_bytes = & raw [ ..body_start] ;
339+ let body_bytes = & raw [ body_start..] ;
340+
341+ // Decode chunked transfer encoding before invoking the rewriter, so it
342+ // sees plaintext JSON rather than `<hex>\r\n{...}\r\n0\r\n\r\n`. On a
343+ // malformed chunked body, fall back to the raw bytes (the rewriter will
344+ // just decline if they aren't sensible JSON).
345+ let decoded_body_owned;
346+ let body_for_rewriter: & [ u8 ] = if has_chunked_transfer_encoding ( header_bytes) {
347+ match decode_chunked_body ( body_bytes) {
348+ Some ( decoded) => {
349+ decoded_body_owned = decoded;
350+ & decoded_body_owned
351+ }
352+ None => {
353+ debug ! ( "response-hook: chunked decode failed; passing raw body to rewriter" ) ;
354+ body_bytes
355+ }
356+ }
357+ } else {
358+ body_bytes
359+ } ;
360+
361+ let Some ( new_body) = rewriter ( body_for_rewriter) else {
362+ debug ! ( "response-hook: rewriter returned None; forwarding unchanged" ) ;
363+ inbound. write_all ( & raw ) . await ?;
364+ inbound. flush ( ) . await ?;
365+ return Ok ( status_code) ;
366+ } ;
367+
368+ // Rebuild headers: drop Content-Length / Transfer-Encoding /
369+ // Content-Encoding, then append the correct Content-Length + terminator.
370+ let header_str = std:: str:: from_utf8 ( header_bytes) . unwrap_or ( "" ) ;
371+ let mut rebuilt = String :: with_capacity ( header_bytes. len ( ) + 32 ) ;
372+ for line in header_str. split ( "\r \n " ) {
373+ if line. is_empty ( ) {
374+ continue ;
375+ }
376+ let lower = line. to_ascii_lowercase ( ) ;
377+ if lower. starts_with ( "content-length:" )
378+ || lower. starts_with ( "transfer-encoding:" )
379+ || lower. starts_with ( "content-encoding:" )
380+ {
381+ continue ;
382+ }
383+ rebuilt. push_str ( line) ;
384+ rebuilt. push_str ( "\r \n " ) ;
385+ }
386+ rebuilt. push_str ( & format ! ( "Content-Length: {}\r \n \r \n " , new_body. len( ) ) ) ;
387+
388+ inbound. write_all ( rebuilt. as_bytes ( ) ) . await ?;
389+ inbound. write_all ( & new_body) . await ?;
390+ inbound. flush ( ) . await ?;
391+ Ok ( status_code)
392+ }
393+
394+ /// Return true if the response headers declare `Transfer-Encoding: chunked`
395+ /// (case-insensitive; comma-lists like `gzip, chunked` are split per RFC 7230).
396+ fn has_chunked_transfer_encoding ( header_bytes : & [ u8 ] ) -> bool {
397+ let Ok ( header_str) = std:: str:: from_utf8 ( header_bytes) else {
398+ return false ;
399+ } ;
400+ header_str. split ( "\r \n " ) . any ( |line| {
401+ let lower = line. to_ascii_lowercase ( ) ;
402+ if let Some ( value) = lower. strip_prefix ( "transfer-encoding:" ) {
403+ value. split ( ',' ) . any ( |token| token. trim ( ) == "chunked" )
404+ } else {
405+ false
406+ }
407+ } )
408+ }
409+
410+ /// Decode an HTTP/1.1 chunked-transfer-encoded body. Returns `None` on any
411+ /// malformation (bad hex size, truncated chunk) so callers can pass-through
412+ /// the original bytes. Chunk extensions are accepted and ignored; trailers
413+ /// after the 0-size chunk are dropped (the body is what the rewriter cares
414+ /// about, and the rebuilt response drops Transfer-Encoding anyway).
415+ fn decode_chunked_body ( body : & [ u8 ] ) -> Option < Vec < u8 > > {
416+ let mut decoded = Vec :: with_capacity ( body. len ( ) ) ;
417+ let mut pos: usize = 0 ;
418+ loop {
419+ let rest = body. get ( pos..) ?;
420+ let line_end = rest. iter ( ) . position ( |& b| b == b'\n' ) ?;
421+ let line_end_abs = pos. checked_add ( line_end) ?;
422+ let raw_line = body. get ( pos..line_end_abs) ?;
423+ let line = raw_line. strip_suffix ( b"\r " ) . unwrap_or ( raw_line) ;
424+ let line_str = std:: str:: from_utf8 ( line) . ok ( ) ?;
425+ let size_str = line_str. split ( ';' ) . next ( ) ?. trim ( ) ;
426+ let size = usize:: from_str_radix ( size_str, 16 ) . ok ( ) ?;
427+
428+ pos = line_end_abs. checked_add ( 1 ) ?;
429+
430+ if size == 0 {
431+ return Some ( decoded) ;
432+ }
433+
434+ let chunk_end = pos. checked_add ( size) ?;
435+ let chunk = body. get ( pos..chunk_end) ?;
436+ decoded. extend_from_slice ( chunk) ;
437+ pos = chunk_end;
438+
439+ if body. get ( pos) == Some ( & b'\r' ) {
440+ pos = pos. checked_add ( 1 ) ?;
441+ }
442+ if body. get ( pos) == Some ( & b'\n' ) {
443+ pos = pos. checked_add ( 1 ) ?;
444+ }
445+ }
446+ }
447+
268448/// Parse HTTP status code from the first response chunk.
269449///
270450/// Returns 502 when the response doesn't contain a valid status line.
@@ -306,4 +486,141 @@ mod tests {
306486 assert_eq ! ( parse_response_status( b"garbage" ) , 502 ) ;
307487 assert_eq ! ( parse_response_status( b"NOT-HTTP 200 OK" ) , 502 ) ;
308488 }
489+
490+ // --- buffered response-rewrite path ---
491+
492+ /// Run `stream_response_buffered` against an in-memory upstream that yields
493+ /// `payload` then EOF, returning the bytes written to the inbound sink.
494+ async fn run_buffered ( payload : & [ u8 ] , rewriter : ResponseBodyRewriter < ' _ > ) -> String {
495+ let ( mut up_w, mut up_r) = tokio:: io:: duplex ( payload. len ( ) + 64 ) ;
496+ up_w. write_all ( payload) . await . unwrap ( ) ;
497+ drop ( up_w) ; // signal EOF to the reader
498+ let mut inbound: Vec < u8 > = Vec :: new ( ) ;
499+ stream_response_buffered ( & mut up_r, & mut inbound, rewriter)
500+ . await
501+ . unwrap ( ) ;
502+ String :: from_utf8_lossy ( & inbound) . into_owned ( )
503+ }
504+
505+ #[ tokio:: test]
506+ async fn buffered_replaces_body_and_rewrites_content_length ( ) {
507+ let payload =
508+ b"HTTP/1.1 200 OK\r \n Content-Length: 5\r \n Content-Type: text/plain\r \n \r \n hello" ;
509+ let rewriter: ResponseBodyRewriter < ' _ > =
510+ Box :: new ( |_body| Some ( b"REWRITTEN-BODY!" . to_vec ( ) ) ) ; // 15 bytes
511+ let out = run_buffered ( payload, rewriter) . await ;
512+ assert ! ( out. contains( "Content-Length: 15\r \n " ) , "new CL: {out:?}" ) ;
513+ assert ! (
514+ !out. contains( "Content-Length: 5\r \n " ) ,
515+ "old CL dropped: {out:?}"
516+ ) ;
517+ assert ! ( out. ends_with( "REWRITTEN-BODY!" ) , "new body: {out:?}" ) ;
518+ assert ! (
519+ out. contains( "Content-Type: text/plain" ) ,
520+ "other headers kept"
521+ ) ;
522+ }
523+
524+ #[ tokio:: test]
525+ async fn buffered_forwards_unchanged_when_rewriter_returns_none ( ) {
526+ let payload = b"HTTP/1.1 200 OK\r \n Content-Length: 5\r \n \r \n hello" ;
527+ let rewriter: ResponseBodyRewriter < ' _ > = Box :: new ( |_| None ) ;
528+ let out = run_buffered ( payload, rewriter) . await ;
529+ assert_eq ! ( out, String :: from_utf8_lossy( payload) ) ;
530+ }
531+
532+ #[ tokio:: test]
533+ async fn buffered_strips_transfer_and_content_encoding ( ) {
534+ // Chunked + gzip headers must be dropped once we hold rewritten bytes.
535+ let payload = b"HTTP/1.1 200 OK\r \n Transfer-Encoding: chunked\r \n Content-Encoding: gzip\r \n \r \n 5\r \n hello\r \n 0\r \n \r \n " ;
536+ let rewriter: ResponseBodyRewriter < ' _ > =
537+ Box :: new ( |_| Some ( b"plaintext-json-here!" . to_vec ( ) ) ) ;
538+ let out = run_buffered ( payload, rewriter) . await ;
539+ assert ! ( out. contains( "Content-Length: 20" ) , "CL added: {out:?}" ) ;
540+ assert ! (
541+ !out. to_ascii_lowercase( ) . contains( "transfer-encoding" ) ,
542+ "TE dropped"
543+ ) ;
544+ assert ! (
545+ !out. to_ascii_lowercase( ) . contains( "content-encoding" ) ,
546+ "CE dropped"
547+ ) ;
548+ }
549+
550+ #[ tokio:: test]
551+ async fn buffered_forwards_unchanged_on_missing_header_separator ( ) {
552+ let payload = b"HTTP/1.1 200 OK no separator here" ;
553+ let rewriter: ResponseBodyRewriter < ' _ > = Box :: new ( |_| Some ( b"x" . to_vec ( ) ) ) ;
554+ let out = run_buffered ( payload, rewriter) . await ;
555+ assert_eq ! (
556+ out,
557+ String :: from_utf8_lossy( payload) ,
558+ "no split → unchanged"
559+ ) ;
560+ }
561+
562+ #[ tokio:: test]
563+ async fn buffered_decodes_chunked_body_before_rewriter ( ) {
564+ // The rewriter must see decoded JSON, not the chunk framing.
565+ let payload = b"HTTP/1.1 200 OK\r \n Content-Type: application/json\r \n Transfer-Encoding: chunked\r \n \r \n 1a\r \n {\" access_token\" :\" REAL!!\" }\r \n 0\r \n \r \n " ;
566+ let rewriter: ResponseBodyRewriter < ' _ > = Box :: new ( |body| {
567+ let s = std:: str:: from_utf8 ( body) . unwrap ( ) ;
568+ assert ! (
569+ s. starts_with( "{\" access_token\" " ) ,
570+ "decoded JSON, got: {s:?}"
571+ ) ;
572+ assert ! ( !s. contains( "1a\r \n " ) , "chunk framing must be stripped" ) ;
573+ Some ( b"{\" access_token\" :\" nono_x\" }" . to_vec ( ) )
574+ } ) ;
575+ let out = run_buffered ( payload, rewriter) . await ;
576+ assert ! ( out. contains( "nono_x" ) , "rewritten: {out:?}" ) ;
577+ }
578+
579+ #[ tokio:: test]
580+ async fn buffered_passes_raw_body_when_chunked_decode_fails ( ) {
581+ // Malformed chunk size ("ZZZ") → decode None → raw body to rewriter.
582+ let payload = b"HTTP/1.1 200 OK\r \n Transfer-Encoding: chunked\r \n \r \n ZZZ\r \n bad\r \n " ;
583+ let rewriter: ResponseBodyRewriter < ' _ > = Box :: new ( |body| {
584+ assert ! (
585+ body. starts_with( b"ZZZ" ) ,
586+ "raw body passed through on decode fail"
587+ ) ;
588+ None
589+ } ) ;
590+ let out = run_buffered ( payload, rewriter) . await ;
591+ assert_eq ! ( out, String :: from_utf8_lossy( payload) ) ;
592+ }
593+
594+ #[ test]
595+ fn has_chunked_transfer_encoding_detects_token ( ) {
596+ assert ! ( has_chunked_transfer_encoding(
597+ b"HTTP/1.1 200 OK\r \n Transfer-Encoding: chunked\r \n \r \n "
598+ ) ) ;
599+ assert ! ( has_chunked_transfer_encoding(
600+ b"HTTP/1.1 200 OK\r \n transfer-encoding: Chunked\r \n \r \n "
601+ ) ) ;
602+ assert ! ( has_chunked_transfer_encoding(
603+ b"HTTP/1.1 200 OK\r \n Transfer-Encoding: gzip, chunked\r \n \r \n "
604+ ) ) ;
605+ assert ! ( !has_chunked_transfer_encoding(
606+ b"HTTP/1.1 200 OK\r \n Content-Length: 5\r \n \r \n "
607+ ) ) ;
608+ }
609+
610+ #[ test]
611+ fn decode_chunked_body_roundtrips_and_rejects_malformed ( ) {
612+ assert_eq ! (
613+ decode_chunked_body( b"5\r \n hello\r \n 6\r \n world\r \n 0\r \n \r \n " ) ,
614+ Some ( b"hello world" . to_vec( ) )
615+ ) ;
616+ // Chunk extensions accepted and ignored.
617+ assert_eq ! (
618+ decode_chunked_body( b"5;ext=1\r \n hello\r \n 0\r \n \r \n " ) ,
619+ Some ( b"hello" . to_vec( ) )
620+ ) ;
621+ // Malformed / truncated → None.
622+ assert_eq ! ( decode_chunked_body( b"ZZZ\r \n bad\r \n " ) , None ) ;
623+ assert_eq ! ( decode_chunked_body( b"5\r \n hel" ) , None ) ;
624+ assert_eq ! ( decode_chunked_body( b"" ) , None ) ;
625+ }
309626}
0 commit comments