Skip to content

Commit 6f19565

Browse files
feat(proxy): OAuth response rewrite on the H1 intercept path
Claude Code falls back to HTTP/1.1 when connecting through nono's transparent CONNECT proxy — Node's HTTP client won't negotiate h2 over a CONNECT tunnel. Live testing confirmed every intercepted request took the H1 path (`handle.rs`, logged as `tls_intercept: inner request`); zero requests reached `h2_forward`. So H1 capture is the path that actually fires for claude-under-nono. `forward.rs`: - `ResponseBodyRewriter<'a>`: `Box<dyn FnOnce(&[u8]) -> Option<Vec<u8>>>` hook threaded through `forward_request`. - `stream_response_buffered`: reads full upstream response, decodes chunked transfer encoding (Anthropic's token endpoint returns chunked JSON), hands body to the rewriter, rebuilds framing with a fresh Content-Length. - `stream_response_passthrough`: the historical chunk-by-chunk mirror, preserved and used when no rewriter is set. `handle.rs`: - `oauth_capture_active`: true when the route has `oauth_capture_match`, the request path matches `token_url_match`, and `nonce_resolver.oauth_capture()` is `Some`. - Strips `accept-encoding` from forwarded request headers so Anthropic returns plaintext JSON (not gzip/br) that the rewriter can parse. - Builds a `ResponseBodyRewriter` closure that calls `rewrite_oauth_json_body`, logs the substitution count on success, and passes the hook to `forward_request`. Both H1 and H2 paths share `oauth_rewrite::rewrite_oauth_json_body` and the `OauthCaptureResolver` trait, so they cannot diverge in rewrite semantics. Part of #1265. Signed-off-by: Christine Le <christine.le@datadoghq.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3df6d59 commit 6f19565

2 files changed

Lines changed: 371 additions & 3 deletions

File tree

crates/nono-proxy/src/forward.rs

Lines changed: 320 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
7286
pub 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>
96111
where
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>
238270
where
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\nContent-Length: 5\r\nContent-Type: text/plain\r\n\r\nhello";
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\nContent-Length: 5\r\n\r\nhello";
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\nTransfer-Encoding: chunked\r\nContent-Encoding: gzip\r\n\r\n5\r\nhello\r\n0\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\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n1a\r\n{\"access_token\":\"REAL!!\"}\r\n0\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\nTransfer-Encoding: chunked\r\n\r\nZZZ\r\nbad\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\nTransfer-Encoding: chunked\r\n\r\n"
598+
));
599+
assert!(has_chunked_transfer_encoding(
600+
b"HTTP/1.1 200 OK\r\ntransfer-encoding: Chunked\r\n\r\n"
601+
));
602+
assert!(has_chunked_transfer_encoding(
603+
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n\r\n"
604+
));
605+
assert!(!has_chunked_transfer_encoding(
606+
b"HTTP/1.1 200 OK\r\nContent-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\nhello\r\n6\r\n world\r\n0\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\nhello\r\n0\r\n\r\n"),
619+
Some(b"hello".to_vec())
620+
);
621+
// Malformed / truncated → None.
622+
assert_eq!(decode_chunked_body(b"ZZZ\r\nbad\r\n"), None);
623+
assert_eq!(decode_chunked_body(b"5\r\nhel"), None);
624+
assert_eq!(decode_chunked_body(b""), None);
625+
}
309626
}

0 commit comments

Comments
 (0)