Files
urus/src/parser.rs
T
Claude (sandbox) 394e9b962a feat(parser): reject duplicate Content-Length (RFC 9112 §6.3)
The content-length arm ran content_length = Some(parse) per header, so a
second Content-Length silently overwrote the first with no conflict check
(CL.CL request smuggling; h1spec #21 -> 404 instead of 400). Count
occurrences and reject any duplicate post-loop, strictly (even equal
values), reusing BadContentLength (400). A single value is still required
to be one decimal integer, so a comma-list or non-numeric keeps failing at
parse as before. Tests: differing dup, equal dup, single-CL regression.
2026-08-09 07:59:25 +00:00

627 lines
23 KiB
Rust

//! HTTP/1.1 wire protocol — request parsing and response serialisation.
//!
//! Parsing uses `httparse` for the request line + headers (zero-alloc, zero-
//! copy, battle-tested). Body framing, keep-alive logic and response writing
//! are ours.
//!
//! Body framing:
//! - `Content-Length: N` — read exactly N bytes.
//! - No body header — empty body.
//! - `Transfer-Encoding: chunked` (HTTP/1.1) — flagged in `ParsedHead`;
//! the connection actor decodes incrementally (`read_chunked_body`).
//! Chunked + Content-Length together, or chunked on HTTP/1.0, is
//! Malformed (request-smuggling ambiguity; RFC 7230 §3.3.3).
use crate::conn::{Body, Conn, HeaderMap, HttpVersion, Method, RespBody};
// ---------------------------------------------------------------------------
// ParseError
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub enum ParseError {
/// Request bytes are well-formed but incomplete; need more from the
/// socket.
Incomplete,
/// Request is malformed; respond 400 and close.
Malformed,
/// Header count exceeded the configured max; respond 431 and close.
TooManyHeaders,
/// `Content-Length` header could not be parsed as an integer.
BadContentLength,
/// A wire feature we haven't implemented. Currently unconstructed
/// (chunked decoding landed in v0.3); kept for future unsupported
/// framings. Connection actor responds 411 + close.
Unsupported,
}
// ---------------------------------------------------------------------------
// ParsedHead — what `parse_head` returns on success.
// ---------------------------------------------------------------------------
//
// `head_len` is how many bytes from the start of the buffer the head
// occupied — the body starts at `&buf[head_len..]`.
pub struct ParsedHead {
pub head_len: usize,
pub method: Method,
pub path: String,
pub query: Option<String>,
pub version: HttpVersion,
pub headers: HeaderMap,
pub content_length: Option<usize>,
/// `Transfer-Encoding: chunked` — the body is chunked-framed and the
/// connection actor decodes it (`read_chunked_body`). Mutually
/// exclusive with `content_length` (rejected as Malformed).
pub chunked: bool,
pub keep_alive: bool,
pub expect_100: bool,
}
/// Try to parse a request head from `buf`. Returns `Incomplete` if more
/// bytes are needed; the caller should read more and retry with the *same*
/// buffer.
pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseError> {
// httparse needs a header array; size it from config. 64 is the spec
// default. Stack allocation; if a request has more than max_headers
// they get TooManyHeaders.
let mut header_buf = vec![httparse::EMPTY_HEADER; max_headers];
let mut req = httparse::Request::new(&mut header_buf);
let head_len = match req.parse(buf) {
Ok(httparse::Status::Complete(n)) => n,
Ok(httparse::Status::Partial) => return Err(ParseError::Incomplete),
Err(httparse::Error::TooManyHeaders) => return Err(ParseError::TooManyHeaders),
Err(_) => return Err(ParseError::Malformed),
};
let method_str = req.method.ok_or(ParseError::Malformed)?;
let raw_path = req.path.ok_or(ParseError::Malformed)?;
let v = req.version.ok_or(ParseError::Malformed)?;
let version = match v {
0 => HttpVersion::Http10,
1 => HttpVersion::Http11,
_ => return Err(ParseError::Malformed),
};
// Split path and query. We don't percent-decode the path here — the
// spec calls for it, but for v1 raw is fine; user code can decode if
// they need to. Same with the query.
let (path, query) = match raw_path.split_once('?') {
Some((p, q)) => (p.to_string(), Some(q.to_string())),
None => (raw_path.to_string(), None),
};
// Walk headers, building HeaderMap and pulling out the few we need
// ourselves (Content-Length, Connection, Transfer-Encoding, Expect).
let mut headers = HeaderMap::with_capacity(req.headers.len());
let mut content_length = None;
let mut connection_hdr = None;
let mut chunked = false;
let mut expect_100 = false;
let mut host_count = 0usize;
let mut host_ok = true;
let mut cl_count = 0usize;
for h in req.headers.iter() {
let name_lower = h.name.to_ascii_lowercase();
let value = std::str::from_utf8(h.value).map_err(|_| ParseError::Malformed)?;
match name_lower.as_str() {
"content-length" => {
// Count occurrences; duplicates (even equal) are rejected
// post-loop. A single value must be one decimal integer —
// a comma-list ("5, 5") or non-numeric fails parse here.
cl_count += 1;
content_length = Some(
value.trim()
.parse::<usize>()
.map_err(|_| ParseError::BadContentLength)?
);
}
"transfer-encoding" => {
// We only care whether it includes "chunked". Multiple codings
// can appear; chunked is the only one we'd need to decode.
if value.to_ascii_lowercase().split(',').any(|t| t.trim() == "chunked") {
chunked = true;
}
}
"connection" => {
connection_hdr = Some(value.to_ascii_lowercase());
}
"expect" if value.eq_ignore_ascii_case("100-continue") => {
expect_100 = true;
}
"host" => {
// Presence/uniqueness enforced post-loop; validity here.
host_count += 1;
if !valid_host(value) {
host_ok = false;
}
}
_ => {}
}
headers.append(&name_lower, value.to_string());
}
// Host (RFC 9112 §3.2): an HTTP/1.1 request MUST carry exactly one valid
// Host; a missing, duplicate, or malformed Host is a 400. HTTP/1.0 may
// omit Host, but a duplicate or invalid one is still rejected on any
// version (ambiguous / malformed authority).
if host_count > 1 || !host_ok {
return Err(ParseError::Malformed);
}
if version == HttpVersion::Http11 && host_count == 0 {
return Err(ParseError::Malformed);
}
// Content-Length (RFC 9112 §6.3): more than one Content-Length is an
// unrecoverable framing ambiguity (CL.CL request smuggling). We are
// strict — reject any duplicate, not only differing values.
if cl_count > 1 {
return Err(ParseError::BadContentLength);
}
if chunked {
// Transfer-Encoding is an HTTP/1.1 mechanism; a 1.0 request
// carrying it is malformed. And a request carrying BOTH a
// Content-Length and TE: chunked is the classic request-smuggling
// ambiguity — RFC 7230 §3.3.3 lets a server reject it, and we do.
if version == HttpVersion::Http10 || content_length.is_some() {
return Err(ParseError::Malformed);
}
}
// Keep-alive logic, RFC 7230 §6.3:
// HTTP/1.1: keep-alive by default; "Connection: close" overrides.
// HTTP/1.0: close by default; "Connection: keep-alive" overrides.
let keep_alive = match version {
HttpVersion::Http11 => connection_hdr.as_deref() != Some("close"),
HttpVersion::Http10 => connection_hdr.as_deref() == Some("keep-alive"),
};
Ok(ParsedHead {
head_len,
method: Method::parse(method_str),
path,
query,
version,
headers,
content_length,
chunked,
keep_alive,
expect_100,
})
}
/// Conservative RFC 3986 check for a `Host` field-value: non-empty and every
/// byte drawn from the `host[:port]` productions (reg-name / IP-literal
/// brackets / port colon). This is charset-level, not full structural
/// validation (no bracket matching, no pct-encoding well-formedness) — enough
/// to reject the smuggling-relevant garbage (whitespace, controls, `@`, `/`,
/// `?`, `#`) while accepting every legitimate host. Tighter structural checks
/// (bracketed IPv6, single port colon) are a possible follow-up.
fn valid_host(value: &str) -> bool {
!value.is_empty()
&& value.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
// unreserved punctuation
b'-' | b'.' | b'_' | b'~'
// sub-delims
| b'!' | b'$' | b'&' | b'\'' | b'(' | b')'
| b'*' | b'+' | b',' | b';' | b'='
// pct-encoded lead
| b'%'
// IP-literal brackets + port separator
| b'[' | b']' | b':'
)
})
}
// ---------------------------------------------------------------------------
// Conn assembly
// ---------------------------------------------------------------------------
pub fn build_conn(head: ParsedHead, body: Body) -> Conn {
let mut c = Conn::new();
c.method = head.method;
c.path = head.path;
c.query = head.query;
c.version = head.version;
c.headers = head.headers;
c.body = body;
c
}
// ---------------------------------------------------------------------------
// Response serialisation
// ---------------------------------------------------------------------------
//
// We emit:
// <version> <status> <reason>\r\n
// <header>: <value>\r\n
// ...
// \r\n
// <body bytes>
//
// Headers we always inject (unless the user already set them):
// - Content-Length: from resp_body.len_hint()
// - Connection: close (if keep-alive is off this request)
// - Date: skipped in v1; not required by the standard and adds complexity.
pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
// Pre-size: status line ~30 + headers ~50/each + body. Good enough.
let body_len = conn.resp_body.len_hint();
let is_stream = matches!(conn.resp_body, RespBody::Stream(_));
let mut out = Vec::with_capacity(64 + conn.resp_headers.len() * 40 + body_len);
let status = conn.status.unwrap_or(200);
let reason = reason_phrase(status);
// Status line.
out.extend_from_slice(conn.version.as_str().as_bytes());
out.push(b' ');
out.extend_from_slice(status.to_string().as_bytes());
out.push(b' ');
out.extend_from_slice(reason.as_bytes());
out.extend_from_slice(b"\r\n");
// User headers — written first so subsequent injection can skip them.
// For Stream bodies WE own the framing: a user `content-length` or
// `transfer-encoding` is dropped rather than emitted (the combination
// of content-length + chunked is a smuggling vector, and a stream has
// no length to promise anyway).
let mut wrote_content_length = false;
let mut wrote_connection = false;
for (name, value) in conn.resp_headers.iter() {
match name {
"content-length" if is_stream => continue,
"transfer-encoding" if is_stream => continue,
"content-length" => wrote_content_length = true,
"connection" => wrote_connection = true,
_ => {}
}
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(b": ");
out.extend_from_slice(value.as_bytes());
out.extend_from_slice(b"\r\n");
}
if is_stream {
// HTTP/1.1: chunked framing, connection reusable afterwards.
// HTTP/1.0: no chunked TE exists; the body is raw bytes delimited
// by EOF — the caller passes keep_alive = false and we emit
// `connection: close` below.
if conn.version == HttpVersion::Http11 {
out.extend_from_slice(b"transfer-encoding: chunked\r\n");
}
} else if !wrote_content_length && !(100..200).contains(&status) {
// 1xx responses have no body by definition (RFC 7230 §3.3.2) —
// injecting `content-length: 0` on the 101 upgrade response is a
// protocol violation some clients reject.
out.extend_from_slice(b"content-length: ");
out.extend_from_slice(body_len.to_string().as_bytes());
out.extend_from_slice(b"\r\n");
}
if !wrote_connection {
if !keep_alive {
out.extend_from_slice(b"connection: close\r\n");
} else if conn.version == HttpVersion::Http10 {
// HTTP/1.0 defaults to close: a connection we intend to keep
// open MUST be advertised back, or a spec-following client
// waits for an EOF that never comes (`ab -k` deadlocked on
// exactly this). 1.1 keep-alive is the default and stays
// implicit.
out.extend_from_slice(b"connection: keep-alive\r\n");
}
}
out.extend_from_slice(b"\r\n");
// Fixed bodies are written inline with the head; a Stream body is
// pumped by the connection actor after this head goes on the wire.
match &conn.resp_body {
RespBody::Empty => {}
RespBody::Bytes(b) => out.extend_from_slice(b),
RespBody::Stream(_) => {}
}
out
}
/// HTTP/1.1 reason phrases. Required by RFC 7230 §3.1.2 — clients may
/// display them.
pub fn reason_phrase(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
202 => "Accepted",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
303 => "See Other",
304 => "Not Modified",
307 => "Temporary Redirect",
308 => "Permanent Redirect",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
409 => "Conflict",
411 => "Length Required",
413 => "Payload Too Large",
414 => "URI Too Long",
415 => "Unsupported Media Type",
422 => "Unprocessable Entity",
429 => "Too Many Requests",
431 => "Request Header Fields Too Large",
500 => "Internal Server Error",
501 => "Not Implemented",
502 => "Bad Gateway",
503 => "Service Unavailable",
_ => "OK", // best-effort fallback; spec allows any phrase
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_get() {
let req = b"GET /hello HTTP/1.1\r\nHost: x\r\n\r\n";
let head = parse_head(req, 64).unwrap();
assert_eq!(head.method, Method::Get);
assert_eq!(head.path, "/hello");
assert_eq!(head.version, HttpVersion::Http11);
assert!(head.keep_alive);
assert_eq!(head.content_length, None);
}
#[test]
fn parse_with_query() {
let req = b"GET /users?id=42&active=true HTTP/1.1\r\nHost: x\r\n\r\n";
let head = parse_head(req, 64).unwrap();
assert_eq!(head.path, "/users");
assert_eq!(head.query.as_deref(), Some("id=42&active=true"));
}
#[test]
fn parse_post_with_content_length() {
let req = b"POST /a HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello";
let head = parse_head(req, 64).unwrap();
assert_eq!(head.content_length, Some(5));
// head_len is bytes before the body; the remainder is the body.
assert_eq!(&req[head.head_len..], b"hello");
}
#[test]
fn parse_connection_close() {
let req = b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n";
let head = parse_head(req, 64).unwrap();
assert!(!head.keep_alive);
}
#[test]
fn parse_http10_default_close() {
let req = b"GET / HTTP/1.0\r\nHost: x\r\n\r\n";
let head = parse_head(req, 64).unwrap();
assert!(!head.keep_alive);
}
#[test]
fn parse_http10_keepalive_opt_in() {
let req = b"GET / HTTP/1.0\r\nHost: x\r\nConnection: keep-alive\r\n\r\n";
let head = parse_head(req, 64).unwrap();
assert!(head.keep_alive);
}
#[test]
fn parse_partial() {
let req = b"GET /hello HTTP/1.1\r\nHost: x\r\n"; // no terminator yet
match parse_head(req, 64) {
Err(ParseError::Incomplete) => {}
_ => panic!("expected Incomplete"),
}
}
#[test]
fn parse_chunked_is_flagged() {
let req = b"POST /a HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n";
let head = parse_head(req, 64).unwrap();
assert!(head.chunked);
assert_eq!(head.content_length, None);
}
#[test]
fn parse_chunked_plus_content_length_is_malformed() {
let req = b"POST /a HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n";
match parse_head(req, 64) {
Err(ParseError::Malformed) => {}
_ => panic!("expected Malformed for CL + chunked"),
}
}
#[test]
fn parse_chunked_on_http10_is_malformed() {
let req = b"POST /a HTTP/1.0\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n";
match parse_head(req, 64) {
Err(ParseError::Malformed) => {}
_ => panic!("expected Malformed for chunked on 1.0"),
}
}
// --- Host (RFC 9112 §3.2) -------------------------------------------
#[test]
fn parse_missing_host_http11_is_malformed() {
let req = b"GET / HTTP/1.1\r\n\r\n";
match parse_head(req, 64) {
Err(ParseError::Malformed) => {}
_ => panic!("expected Malformed for missing Host on 1.1"),
}
}
#[test]
fn parse_missing_host_http10_is_allowed() {
// Host is optional in HTTP/1.0.
let req = b"GET / HTTP/1.0\r\n\r\n";
assert!(parse_head(req, 64).is_ok(), "1.0 may omit Host");
}
#[test]
fn parse_duplicate_host_is_malformed() {
let req = b"GET / HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n";
match parse_head(req, 64) {
Err(ParseError::Malformed) => {}
_ => panic!("expected Malformed for duplicate Host"),
}
}
#[test]
fn parse_invalid_host_value_is_malformed() {
// Embedded whitespace — invalid in an RFC 3986 authority.
let req = b"GET / HTTP/1.1\r\nHost: bad host\r\n\r\n";
match parse_head(req, 64) {
Err(ParseError::Malformed) => {}
_ => panic!("expected Malformed for invalid Host"),
}
}
#[test]
fn parse_valid_hosts_accepted() {
// Positive controls: reg-name, reg-name:port, and IPv6-literal:port.
for req in [
b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n".as_slice(),
b"GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n".as_slice(),
b"GET / HTTP/1.1\r\nHost: [::1]:443\r\n\r\n".as_slice(),
] {
assert!(parse_head(req, 64).is_ok(), "should accept a valid Host");
}
}
// --- Content-Length (RFC 9112 §6.3) ---------------------------------
#[test]
fn parse_conflicting_content_length_is_rejected() {
// Two differing Content-Length values — classic CL.CL smuggling.
let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 7\r\n\r\nhello!!";
match parse_head(req, 64) {
Err(ParseError::BadContentLength) => {}
_ => panic!("expected BadContentLength for conflicting CL"),
}
}
#[test]
fn parse_duplicate_equal_content_length_is_rejected() {
// Strict: even identical duplicates are rejected.
let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\nhello";
match parse_head(req, 64) {
Err(ParseError::BadContentLength) => {}
_ => panic!("expected BadContentLength for duplicate CL"),
}
}
#[test]
fn parse_single_content_length_still_ok() {
// Regression: the ordinary single-CL path is unchanged.
let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello";
let head = parse_head(req, 64).unwrap();
assert_eq!(head.content_length, Some(5));
}
#[test]
fn serialise_basic_200() {
let conn = Conn::new().put_status(200).put_body("hi");
let bytes = serialise_response(&conn, true);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(s.contains("content-length: 2"));
assert!(s.ends_with("\r\n\r\nhi"));
}
#[test]
fn serialise_connection_close_when_not_keepalive() {
let conn = Conn::new().put_status(204);
let bytes = serialise_response(&conn, false);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.contains("connection: close"));
}
#[test]
fn serialise_stream_http11_is_chunked_no_content_length() {
let (_tx, rx) = smarm::channel::<Vec<u8>>();
let conn = Conn::new().put_status(200).put_body(RespBody::from(rx));
let bytes = serialise_response(&conn, true);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.contains("transfer-encoding: chunked"));
assert!(!s.contains("content-length"));
assert!(s.ends_with("\r\n\r\n")); // head only, no body bytes
}
#[test]
fn serialise_stream_strips_user_framing_headers() {
let (_tx, rx) = smarm::channel::<Vec<u8>>();
let conn = Conn::new().put_status(200)
.put_header("content-length", "999")
.put_header("transfer-encoding", "gzip")
.put_body(RespBody::from(rx));
let bytes = serialise_response(&conn, true);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(!s.contains("content-length"));
assert!(!s.contains("gzip"));
assert!(s.contains("transfer-encoding: chunked"));
}
#[test]
fn serialise_stream_http10_no_te_and_closes() {
let (_tx, rx) = smarm::channel::<Vec<u8>>();
let mut conn = Conn::new().put_status(200).put_body(RespBody::from(rx));
conn.version = HttpVersion::Http10;
// The conn actor forces keep_alive=false for a 1.0 stream.
let bytes = serialise_response(&conn, false);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(!s.contains("transfer-encoding"));
assert!(!s.contains("content-length"));
assert!(s.contains("connection: close"));
}
#[test]
fn serialise_http10_keepalive_is_echoed() {
let mut conn = Conn::new().put_status(200).put_body("hi");
conn.version = HttpVersion::Http10;
let bytes = serialise_response(&conn, true);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.contains("connection: keep-alive"), "head: {s}");
}
#[test]
fn serialise_http11_keepalive_stays_implicit() {
let conn = Conn::new().put_status(200).put_body("hi");
let bytes = serialise_response(&conn, true);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(!s.contains("connection:"), "head: {s}");
}
#[test]
fn serialise_user_content_length_is_respected() {
let conn = Conn::new().put_status(200)
.put_header("content-length", "999")
.put_body("hi"); // mismatch on purpose — user wins
let bytes = serialise_response(&conn, true);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.contains("content-length: 999"));
assert!(!s.contains("content-length: 2"));
}
}