Merge branch 'linear/nap-257-web-proxy-body-caps'
ci / rust (push) Successful in 5m54s

This commit is contained in:
2026-05-31 21:12:23 +00:00
+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);
}
}