Watch
0
0
Fork
You've already forked hyperhive
0

hive-agent: extra web proxies forward every repeated response header

Both proxy paths copied upstream response headers with HeaderMap::insert,
which replaces earlier values of the same name, so an upstream sending two
Set-Cookie (or Link, Vary, ...) headers reached the browser with only the
last one. Use append in both loops. Tests cover the http and unix: paths.
This commit is contained in:
atlas 2026-10-10 20:37:07 +02:00 • committed by mara
commit a200935216
2 changed files with 71 additions and 3 deletions

View file

@ -380,7 +380,8 @@ shaped).
`{"<name>": "<upstream_url>"}`). Each entry mounts a transparent `{"<name>": "<upstream_url>"}`). Each entry mounts a transparent
reverse-proxy at `/extra/<name>/` that forwards every request (method, reverse-proxy at `/extra/<name>/` that forwards every request (method,
headers, body) to the configured upstream, strips hop-by-hop headers headers, body) to the configured upstream, strips hop-by-hop headers
on both sides, and buffers the full response body (MVP — SSE on both sides (repeated response headers such as two `Set-Cookie` lines
are all forwarded), and buffers the full response body (MVP — SSE
connections will appear as one large response rather than streaming). connections will appear as one large response rather than streaming).
The `/extra/` namespace ensures user-declared proxies can never The `/extra/` namespace ensures user-declared proxies can never
conflict with native agent endpoints. Upstream values are either an conflict with native agent endpoints. Upstream values are either an

View file

@ -169,7 +169,7 @@ async fn proxy_handler(
HeaderName::from_bytes(name.as_str().as_bytes()), HeaderName::from_bytes(name.as_str().as_bytes()),
HeaderValue::from_bytes(value.as_bytes()), HeaderValue::from_bytes(value.as_bytes()),
) { ) {
resp_headers.insert(n, v); resp_headers.append(n, v);
} }
} }
match upstream_resp.bytes().await { match upstream_resp.bytes().await {
@ -269,8 +269,75 @@ async fn forward_over_unix_socket(
if HOP_BY_HOP.contains(&name.as_str()) { if HOP_BY_HOP.contains(&name.as_str()) {
continue; continue;
} }
resp_headers.insert(name.clone(), value.clone()); resp_headers.append(name.clone(), value.clone());
} }
let body_bytes = upstream_resp.into_body().collect().await?.to_bytes(); let body_bytes = upstream_resp.into_body().collect().await?.to_bytes();
Ok((status, resp_headers, body_bytes).into_response()) Ok((status, resp_headers, body_bytes).into_response())
} }
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// Raw HTTP/1.1 answer with two `Set-Cookie` headers and a body.
const TWO_COOKIES: &[u8] = b"HTTP/1.1 200 OK\r\n\
Set-Cookie: session=abc\r\n\
Set-Cookie: csrf=def\r\n\
Content-Length: 2\r\n\
Connection: close\r\n\r\nok";
/// Read the request head, then answer with [`TWO_COOKIES`].
async fn answer_once<S: AsyncReadExt + AsyncWriteExt + Unpin>(mut stream: S) {
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf).await;
stream.write_all(TWO_COOKIES).await.unwrap();
stream.shutdown().await.unwrap();
}
fn cookies(resp: &Response) -> Vec<&str> {
resp.headers()
.get_all("set-cookie")
.iter()
.map(|v| v.to_str().unwrap())
.collect()
}
#[tokio::test]
async fn unix_upstream_keeps_repeated_response_headers() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("up.sock");
let listener = tokio::net::UnixListener::bind(&sock).unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
answer_once(stream).await;
});
let resp =
forward_over_unix_socket(&sock, Method::GET, "/", HeaderMap::new(), Bytes::new())
.await
.unwrap();
assert_eq!(cookies(&resp), ["session=abc", "csrf=def"]);
}
#[tokio::test]
async fn http_upstream_keeps_repeated_response_headers() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
answer_once(stream).await;
});
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let resp = proxy_handler(
State((Arc::new(client), Arc::new(base))),
Method::GET,
Uri::from_static("/"),
HeaderMap::new(),
Bytes::new(),
)
.await;
assert_eq!(cookies(&resp), ["session=abc", "csrf=def"]);
}
}