Skip to content

Commit 9d8d063

Browse files
committed
fix(h2): downgrade h2 requests to origin-form + Host for h1 backends
An h2 request carries scheme+authority in its URI and `:authority` (not `Host`); forwarded as-is, hyper's h1 client emits an absolute-form target that strict backends (e.g. bun) reject with 400. Rewrite to origin-form path + inject Host before the backend round-trip. The integration test now asserts the backend sees a Host header so this can't regress. Verified live: app/console serve 200/303 over h2, passthrough unchanged.
1 parent 6430c14 commit 9d8d063

5 files changed

Lines changed: 85 additions & 24 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ members = [
88
]
99

1010
[workspace.package]
11-
version = "0.1.0"
11+
version = "0.1.1"
1212
edition = "2024"
1313
license = "MIT OR Apache-2.0"
1414
rust-version = "1.85"

crates/tuyau-server/src/h2proxy.rs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,8 @@ pub(crate) async fn serve_h2<S>(
8181
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
8282
{
8383
let io = TokioIo::new(tls_stream);
84-
let service = service_fn(move |req| {
85-
proxy(req, routes.clone(), sni.clone(), peer, error_502.clone())
86-
});
84+
let service =
85+
service_fn(move |req| proxy(req, routes.clone(), sni.clone(), peer, error_502.clone()));
8786
let mut builder = hyper::server::conn::http2::Builder::new(TokioExecutor::new());
8887
builder
8988
.timer(TokioTimer::new()) // required by keep-alive below
@@ -181,8 +180,10 @@ async fn h1_roundtrip<S>(
181180
where
182181
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
183182
{
183+
let req = downgrade_to_h1(req, sni);
184184
let handshake = hyper::client::conn::http1::handshake(TokioIo::new(io));
185-
let (mut sender, conn) = match tokio::time::timeout(BACKEND_HANDSHAKE_TIMEOUT, handshake).await {
185+
let (mut sender, conn) = match tokio::time::timeout(BACKEND_HANDSHAKE_TIMEOUT, handshake).await
186+
{
186187
Ok(Ok(pair)) => pair,
187188
Ok(Err(e)) => {
188189
tracing::warn!(peer = %peer, sni = %sni, error = %e, "backend h1 handshake failed");
@@ -208,6 +209,38 @@ where
208209
}
209210
}
210211

212+
/// Rewrite an h2 request into the shape an h1 backend expects: an origin-form
213+
/// request-target (just path-and-query) plus a `Host` header. An h2 request
214+
/// carries scheme+authority in its URI and `:authority` (not `Host`), which a
215+
/// strict h1 server rejects as an absolute-form target (→ 400). We take the
216+
/// authority for `Host` before stripping it from the URI; SNI is the fallback.
217+
fn downgrade_to_h1(mut req: Request<Incoming>, sni: &str) -> Request<Incoming> {
218+
let authority = req
219+
.uri()
220+
.authority()
221+
.map(|a| a.as_str().to_string())
222+
.or_else(|| {
223+
req.headers()
224+
.get(HOST)
225+
.and_then(|h| h.to_str().ok())
226+
.map(str::to_string)
227+
})
228+
.unwrap_or_else(|| sni.to_string());
229+
230+
let origin = req
231+
.uri()
232+
.path_and_query()
233+
.map(|pq| pq.as_str())
234+
.unwrap_or("/");
235+
if let Ok(uri) = origin.parse::<hyper::Uri>() {
236+
*req.uri_mut() = uri;
237+
}
238+
if let Ok(host) = authority.parse() {
239+
req.headers_mut().insert(HOST, host);
240+
}
241+
req
242+
}
243+
211244
/// Translate a WebSocket-over-h2 (Extended CONNECT) stream into an HTTP/1.1
212245
/// `Upgrade: websocket` handshake to the backend, then splice the two byte
213246
/// streams. Returns the `200` that completes the h2 side (browsers expect 200,
@@ -266,7 +299,8 @@ where
266299
};
267300

268301
let handshake = hyper::client::conn::http1::handshake(TokioIo::new(backend));
269-
let (mut sender, conn) = match tokio::time::timeout(BACKEND_HANDSHAKE_TIMEOUT, handshake).await {
302+
let (mut sender, conn) = match tokio::time::timeout(BACKEND_HANDSHAKE_TIMEOUT, handshake).await
303+
{
270304
Ok(Ok(pair)) => pair,
271305
Ok(Err(e)) => {
272306
tracing::warn!(peer = %peer, sni = %sni, error = %e, "backend ws handshake failed");
@@ -330,7 +364,9 @@ where
330364

331365
/// An empty boxed body for the WebSocket `200` response.
332366
fn empty_body() -> ProxyBody {
333-
Empty::<Bytes>::new().map_err(|never| match never {}).boxed()
367+
Empty::<Bytes>::new()
368+
.map_err(|never| match never {})
369+
.boxed()
334370
}
335371

336372
/// A 502 response carrying the (branded) error page, mirroring the byte-pipe's

crates/tuyau-server/src/public.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,14 @@ async fn handle_public(
307307
// and reverse-proxy each request to the backend as h1.
308308
if alpn == Some(b"h2".as_slice()) {
309309
tracing::info!(peer = %peer, sni = %sni, "public connection routed (terminated, h2→h1)");
310-
crate::h2proxy::serve_h2(tls_stream, routes.clone(), sni.clone(), peer, error_502.clone()).await;
310+
crate::h2proxy::serve_h2(
311+
tls_stream,
312+
routes.clone(),
313+
sni.clone(),
314+
peer,
315+
error_502.clone(),
316+
)
317+
.await;
311318
return Ok(());
312319
}
313320

@@ -374,7 +381,11 @@ where
374381
}
375382

376383
/// Dial a local upstream over TCP, with a timeout. `None` on failure (logged).
377-
pub(crate) async fn connect_tcp(addr: SocketAddr, sni: &str, peer: SocketAddr) -> Option<TcpStream> {
384+
pub(crate) async fn connect_tcp(
385+
addr: SocketAddr,
386+
sni: &str,
387+
peer: SocketAddr,
388+
) -> Option<TcpStream> {
378389
match tokio::time::timeout(UPSTREAM_CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
379390
Ok(Ok(s)) => Some(s),
380391
Ok(Err(e)) => {

crates/tuyau-server/tests/h2.rs

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,20 @@ async fn spawn_h1_backend() -> std::net::SocketAddr {
2828
};
2929
tokio::spawn(async move {
3030
let service = service_fn(|req: Request<hyper::body::Incoming>| async move {
31-
let body = format!("hello from backend: {} {}", req.method(), req.uri().path());
31+
// Echo method, path and Host so the test can assert the h2
32+
// request was downgraded to a well-formed h1 one (origin-form
33+
// target + Host header, not absolute-form / :authority).
34+
let host = req
35+
.headers()
36+
.get(hyper::header::HOST)
37+
.and_then(|h| h.to_str().ok())
38+
.unwrap_or("<none>");
39+
let body = format!(
40+
"hello from backend: {} {} host={}",
41+
req.method(),
42+
req.uri().path(),
43+
host
44+
);
3245
Ok::<_, hyper::Error>(Response::new(body))
3346
});
3447
let _ = hyper::server::conn::http1::Builder::new()
@@ -184,12 +197,10 @@ async fn h2_browser_reaches_h1_backend() {
184197
);
185198

186199
// Speak HTTP/2 to tuyau.
187-
let (mut sender, conn) = hyper::client::conn::http2::handshake(
188-
TokioExecutor::new(),
189-
TokioIo::new(tls_stream),
190-
)
191-
.await
192-
.unwrap();
200+
let (mut sender, conn) =
201+
hyper::client::conn::http2::handshake(TokioExecutor::new(), TokioIo::new(tls_stream))
202+
.await
203+
.unwrap();
193204
tokio::spawn(async move {
194205
let _ = conn.await;
195206
});
@@ -203,16 +214,18 @@ async fn h2_browser_reaches_h1_backend() {
203214
let body = resp.into_body().collect().await.unwrap().to_bytes();
204215
let text = String::from_utf8_lossy(&body);
205216
assert_eq!(
206-
text, "hello from backend: GET /hello",
207-
"backend saw the request as h1 GET, proxied from the h2 client"
217+
text, "hello from backend: GET /hello host=front.example.com",
218+
"backend saw a well-formed h1 GET (origin-form + Host), proxied from h2"
208219
);
209220

210221
server.shutdown().await;
211222
}
212223

213224
/// Start a terminated public listener whose only route is a local upstream to
214225
/// `backend`. Returns the running server and its public address.
215-
async fn start_terminated_to(backend: std::net::SocketAddr) -> (TunnelServer, std::net::SocketAddr) {
226+
async fn start_terminated_to(
227+
backend: std::net::SocketAddr,
228+
) -> (TunnelServer, std::net::SocketAddr) {
216229
let dir = tempfile::TempDir::new().unwrap();
217230
let cfg = ServerConfig {
218231
listen_addr: "127.0.0.1:0".parse().unwrap(),
@@ -276,7 +289,8 @@ async fn websocket_over_h2_bridges_to_h1_upgrade() {
276289
.uri("https://front.example.com/ws")
277290
.body(Empty::<Bytes>::new())
278291
.unwrap();
279-
req.extensions_mut().insert(Protocol::from_static("websocket"));
292+
req.extensions_mut()
293+
.insert(Protocol::from_static("websocket"));
280294

281295
let resp = sender.send_request(req).await.unwrap();
282296
assert_eq!(resp.status(), 200, "RFC 8441 success is 200, not 101");

0 commit comments

Comments
 (0)