Parse one HTTP/1.1 request from the stream. Reads one byte at a time to stop exactly at the `\r\n\r\n` header terminator. A multi-byte read could consume bytes belonging to a subsequent pipelined request, and those overflow bytes would be forwarded upstream without L7 policy evaluation -- a request smuggling vulnerability. Byte-at-a-time overhead is negligible for the typical 200-800 byte heade
(
client: &mut C,
canonicalize_options: &crate::l7::path::CanonicalizeOptions,
)
| 136 | /// smuggling vulnerability. Byte-at-a-time overhead is negligible for |
| 137 | /// the typical 200-800 byte headers on L7-inspected REST endpoints. |
| 138 | async fn parse_http_request<C: AsyncRead + Unpin>( |
| 139 | client: &mut C, |
| 140 | canonicalize_options: &crate::l7::path::CanonicalizeOptions, |
| 141 | ) -> Result<Option<L7Request>> { |
| 142 | let mut buf = Vec::with_capacity(4096); |
| 143 | |
| 144 | loop { |
| 145 | if buf.len() > MAX_HEADER_BYTES { |
| 146 | return Err(miette!( |
| 147 | "HTTP request headers exceed {MAX_HEADER_BYTES} bytes" |
| 148 | )); |
| 149 | } |
| 150 | |
| 151 | let byte = match client.read_u8().await { |
| 152 | Ok(b) => b, |
| 153 | Err(e) if buf.is_empty() && is_benign_close(&e) => return Ok(None), |
| 154 | Err(e) if buf.is_empty() && e.kind() == std::io::ErrorKind::UnexpectedEof => { |
| 155 | return Ok(None); // Clean close before any data |
| 156 | } |
| 157 | Err(e) => return Err(miette::miette!("{e}")), |
| 158 | }; |
| 159 | buf.push(byte); |
| 160 | |
| 161 | // Check for end of headers -- `ends_with` is sufficient because |
| 162 | // we append exactly one byte per iteration. |
| 163 | if buf.ends_with(b"\r\n\r\n") { |
| 164 | break; |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // Parse request line |
| 169 | let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; |
| 170 | |
| 171 | // Reject bare LF in headers (must use \r\n line endings per RFC 7230). |
| 172 | // Bare LF can cause parsing discrepancies between this proxy and upstream |
| 173 | // servers, enabling request smuggling via header injection. |
| 174 | for i in 0..header_end { |
| 175 | if buf[i] == b'\n' && (i == 0 || buf[i - 1] != b'\r') { |
| 176 | return Err(miette!( |
| 177 | "HTTP headers contain bare LF (line feed without carriage return)" |
| 178 | )); |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | // Strict UTF-8 validation. from_utf8_lossy would silently replace invalid |
| 183 | // bytes with U+FFFD, creating an interpretation gap between this proxy |
| 184 | // (which parses the lossy string) and upstream servers (which receive the |
| 185 | // raw bytes). This gap enables request smuggling via mutated header names. |
| 186 | let header_str = std::str::from_utf8(&buf[..header_end]) |
| 187 | .map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; |
| 188 | |
| 189 | let request_line = header_str |
| 190 | .lines() |
| 191 | .next() |
| 192 | .ok_or_else(|| miette!("Empty HTTP request"))?; |
| 193 | |
| 194 | let mut parts = request_line.split_whitespace(); |
| 195 | let method = parts |
no test coverage detected