fix(maestro): bound web proxy request bodies + stream responses (NAP-257)
ci / rust (push) Successful in 6m56s

`proxy_http` buffered request bodies with `to_bytes(.., usize::MAX)` and
materialized full upstream responses with `.bytes()`. Anyone reachable on the
bind address could POST a multi-GB body (the cookie middleware admits any
signed cookie) and OOM the process; a large upstream reply did the same.

Cap buffered request bodies at 8 MiB and return 413 on overflow; stream the
upstream response straight through via `bytes_stream()` → `Body::from_stream`
so it's never fully held in RAM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 21:12:17 +00:00
co-authored by Claude Opus 4.8
parent 599f3ed178
commit b0774934e8
+60 -7
View File
@@ -66,14 +66,22 @@ impl ProxyState {
// HTTP proxy
// ---------------------------------------------------------------------------
/// Cap on the request body we'll buffer before forwarding upstream. The proxied
/// daemon endpoints (`/api/sessions` CRUD, etc.) all carry small JSON; 8 MiB is
/// far above anything legitimate. Without a cap, anyone reachable on the bind
/// address could POST a multi-GB body and OOM the process (NAP-257).
pub(crate) const MAX_REQUEST_BODY: usize = 8 * 1024 * 1024;
/// Forward an HTTP request to the upstream daemon and stream the response back.
pub async fn proxy_http(State(state): State<ProxyState>, req: Request) -> Response {
let (parts, body) = req.into_parts();
let upstream_uri = build_uri(&state.upstream.daemon_http_base, &parts.uri);
let body_bytes = match axum::body::to_bytes(body, usize::MAX).await {
// Bound the buffered request body. `to_bytes` returns `Err` once the body
// exceeds the limit; treat that as 413 rather than buffering unboundedly.
let body_bytes = match axum::body::to_bytes(body, MAX_REQUEST_BODY).await {
Ok(b) => b,
Err(e) => return bad_gateway(&format!("read body: {e}")),
Err(_) => return payload_too_large(),
};
let mut upstream_req = state
@@ -92,16 +100,14 @@ pub async fn proxy_http(State(state): State<ProxyState>, req: Request) -> Respon
let status = upstream_resp.status();
let headers = filter_response_headers(upstream_resp.headers());
let body = match upstream_resp.bytes().await {
Ok(b) => b,
Err(e) => return bad_gateway(&format!("upstream body: {e}")),
};
// Stream the response body straight through instead of materializing it —
// a large upstream reply never gets fully buffered in RAM (NAP-257).
let mut resp = Response::builder().status(status);
for (name, value) in headers.iter() {
resp = resp.header(name, value);
}
resp.body(Body::from(body))
resp.body(Body::from_stream(upstream_resp.bytes_stream()))
.unwrap_or_else(|e| bad_gateway(&format!("build response: {e}")))
}
@@ -176,6 +182,19 @@ fn bad_gateway(msg: &str) -> Response {
.into_response()
}
fn payload_too_large() -> Response {
tracing::warn!(limit = MAX_REQUEST_BODY, "proxy: request body exceeds cap");
let body = format!(
r#"{{"error":{{"code":"payload_too_large","message":"request body exceeds {MAX_REQUEST_BODY} bytes"}}}}"#
);
(
StatusCode::PAYLOAD_TOO_LARGE,
[(header::CONTENT_TYPE, "application/json")],
body,
)
.into_response()
}
// ---------------------------------------------------------------------------
// WebSocket proxy
// ---------------------------------------------------------------------------
@@ -283,4 +302,38 @@ mod tests {
let u = UpstreamConfig::from_daemon_url("https://daemon.example/");
assert_eq!(&*u.daemon_ws_base, "wss://daemon.example");
}
/// NAP-257: a request body over the cap is rejected with 413 before we ever
/// dial upstream — so an oversized POST can't OOM the proxy. The upstream is
/// a bogus unreachable address; a 413 (not a 502) proves we bailed early.
#[tokio::test]
async fn oversized_request_body_is_rejected_with_413() {
let state = ProxyState::new(UpstreamConfig::from_daemon_url("http://127.0.0.1:9"));
let big = vec![0u8; MAX_REQUEST_BODY + 1];
let req = Request::builder()
.method("POST")
.uri("/api/sessions")
.body(Body::from(big))
.unwrap();
let resp = proxy_http(State(state), req).await;
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
/// A body at the cap still proceeds to the upstream send (which fails here
/// with 502 since nothing is listening) — i.e. the cap is exclusive of the
/// limit, not off-by-one against legitimate traffic.
#[tokio::test]
async fn body_at_cap_is_not_rejected_by_the_size_check() {
let state = ProxyState::new(UpstreamConfig::from_daemon_url("http://127.0.0.1:9"));
let body = vec![0u8; MAX_REQUEST_BODY];
let req = Request::builder()
.method("POST")
.uri("/api/sessions")
.body(Body::from(body))
.unwrap();
let resp = proxy_http(State(state), req).await;
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
}