From b9896cb91824a713b21f9310086de89cc1cf0e75 Mon Sep 17 00:00:00 2001 From: Nikkuss Date: Fri, 21 Aug 2026 23:43:24 +0400 Subject: [PATCH] replace manual header parse with lib --- Cargo.lock | 1 + Cargo.toml | 1 + src/server/endpoints/http.rs | 115 +++++++++++++++++++++++++++-------- 3 files changed, 92 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5761c0..9ad06c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,7 @@ dependencies = [ "dirs", "http", "http-body-util", + "httparse", "hyper", "hyper-util", "inquire", diff --git a/Cargo.toml b/Cargo.toml index da31647..617bb34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ dashmap = "6" bytes = "1" tokio-util = { version = "0.7", features = ["io"] } http = "1" +httparse = "1" http-body-util = "0.1" rustls-pemfile = "2" ring = "0.17" diff --git a/src/server/endpoints/http.rs b/src/server/endpoints/http.rs index 3c51d2d..34547a0 100644 --- a/src/server/endpoints/http.rs +++ b/src/server/endpoints/http.rs @@ -2,6 +2,8 @@ use std::net::SocketAddr; use std::sync::Arc; use color_eyre::Result; +use color_eyre::eyre::{bail, eyre}; +use tokio::io::AsyncReadExt; use tokio::net::{TcpListener, TcpStream}; use tokio_util::sync::CancellationToken; use tracing::{info, warn}; @@ -41,26 +43,73 @@ pub async fn run( Ok(()) } -/// Extract the Host header from raw HTTP bytes without consuming them. -/// Returns (subdomain, peer_display_string). -fn extract_host_from_headers(buf: &[u8], base_domain: &str) -> Option { - let header_str = std::str::from_utf8(buf).ok()?; +/// Largest request head we will buffer before giving up. +const MAX_HEAD: usize = 64 * 1024; +const MAX_HEADERS: usize = 96; - // Find Host header (case-insensitive) - for line in header_str.split("\r\n").skip(1) { - if line.is_empty() { - break; - } - if let Some(value) = line.strip_prefix("Host:").or_else(|| line.strip_prefix("host:")) { - let host = value.trim(); - // Strip port if present - let host = host.split(':').next().unwrap_or(host); - let suffix = format!(".{base_domain}"); - let subdomain = host.strip_suffix(&suffix).filter(|s| !s.is_empty())?; - return Some(subdomain.to_string()); - } +/// Extract the routing subdomain from a (possibly incomplete) request head. +/// +/// `Ok(None)` means the head has not fully arrived yet and more bytes are +/// needed. Parsing is done over raw bytes by `httparse`, so a body that spills +/// into the buffer — binary or otherwise — is never part of the parse. +fn parse_subdomain(buf: &[u8], base_domain: &str) -> Result> { + let mut headers = [httparse::EMPTY_HEADER; MAX_HEADERS]; + let mut req = httparse::Request::new(&mut headers); + + if req.parse(buf)?.is_partial() { + return Ok(None); + } + + let host = req + .headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("host")) + .ok_or_else(|| eyre!("HTTP request has no Host header"))?; + + let host = std::str::from_utf8(host.value)?.trim(); + // Strip port if present + let host = host.split(':').next().unwrap_or(host); + + host.strip_suffix(&format!(".{base_domain}")) + .filter(|s| !s.is_empty()) + .map(|s| Some(s.to_string())) + .ok_or_else(|| eyre!("Host {host:?} is not a {base_domain} subdomain")) +} + +#[cfg(test)] +mod tests { + use super::parse_subdomain; + + const HEAD: &[u8] = + b"POST /upload HTTP/1.1\r\nHOST: 78466f33.fwds.scug.io:443\r\nContent-Length: 4\r\n\r\n"; + + /// The upload-502 regression: a buffer that spills into a binary body must + /// still route. + #[test] + fn binary_body_still_routes() { + let mut buf = HEAD.to_vec(); + buf.extend_from_slice(&[0xff, 0xfe, 0xff, 0xfe]); + + let got = parse_subdomain(&buf, "fwds.scug.io").unwrap(); + assert_eq!(got.as_deref(), Some("78466f33")); + } + + /// A head split across segments asks for more bytes instead of failing. + #[test] + fn partial_head_is_not_an_error() { + let cut = HEAD.len() - 20; + assert_eq!(parse_subdomain(&HEAD[..cut], "fwds.scug.io").unwrap(), None); + assert_eq!( + parse_subdomain(HEAD, "fwds.scug.io").unwrap().as_deref(), + Some("78466f33") + ); + } + + #[test] + fn foreign_host_is_rejected() { + let buf = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"; + assert!(parse_subdomain(buf, "fwds.scug.io").is_err()); } - None } async fn handle_connection( @@ -68,14 +117,27 @@ async fn handle_connection( peer: SocketAddr, state: Arc, ) -> Result<()> { - // Peek at the beginning of the HTTP request to extract the Host header. - // We read into a buffer but then send ALL of it through the tunnel. - let mut buf = vec![0u8; 8192]; - let n = stream.peek(&mut buf).await?; - let buf = &buf[..n]; + // Consume the request head so a head split across several segments still + // parses — peeking only ever returns whatever happens to be buffered right + // now. Everything read here is replayed into the tunnel verbatim below. + let mut stream = stream; + let mut head = Vec::with_capacity(8192); + let mut chunk = [0u8; 4096]; - let subdomain = extract_host_from_headers(buf, &state.base_domain) - .ok_or_else(|| color_eyre::eyre::eyre!("no matching Host header in HTTP request"))?; + let subdomain = loop { + let n = stream.read(&mut chunk).await?; + if n == 0 { + bail!("connection closed before the HTTP request head was complete"); + } + head.extend_from_slice(&chunk[..n]); + + if let Some(subdomain) = parse_subdomain(&head, &state.base_domain)? { + break subdomain; + } + if head.len() > MAX_HEAD { + bail!("HTTP request head exceeds {MAX_HEAD} bytes"); + } + }; // Look up the tunnel let tunnel_id = *state @@ -102,6 +164,9 @@ async fn handle_connection( ) .await?; + // Replay the head we consumed, then hand the rest to the raw relay. + quic_send.write_all(&head).await?; + // Raw bidirectional relay — TCP stream carries the full HTTP conversation // including upgrades (WebSocket), SSE, chunked responses, etc. let quic_stream = QuicBiStream {