feat(parser,conn): chunked request bodies (v0.3 chunk 2)
parser: Transfer-Encoding: chunked no longer 411s — it sets ParsedHead.chunked and the conn actor decodes. Two hard rejections at parse time, both Malformed/400: chunked together with Content-Length (the request-smuggling ambiguity; RFC 7230 §3.3.3 permits rejection) and chunked on HTTP/1.0 (TE is a 1.1 mechanism). ParseError::Unsupported is now unconstructed; kept for future framings. conn_actor: read_chunked_body decodes incrementally from buf[head_len..], reading on the SAME request deadline the head came in under (a stalled chunked body dies at request_timeout exactly like a stalled CL body). Chunk extensions are ignored; trailers are consumed and discarded. Bounds: decoded size capped at max_body_bytes -> 413 the moment the cap would be crossed (the CL pre-check can't see a chunked body's size up front); size lines capped at 128 bytes and the trailer section at 8 KiB -> 400, so framing spam can't grow the read buffer unboundedly. Returns (decoded, raw_consumed_past_head): the keep-alive drain at the loop bottom now drops head_len + RAW framing length (not decoded length), so pipelined requests behind a chunked body land exactly — covered by the trailers+pipelining test. Tests: 3 parser unit (flagging, CL+TE reject, 1.0 reject; the old chunked-is-Unsupported test replaced) + 6 integration (decode with extensions, trailers + pipelined next request, 413 over decoded cap, 400 malformed size line, 400 CL+TE on the wire, stalled chunked body killed at request_timeout with silent close).
This commit is contained in:
+169
-8
@@ -133,6 +133,8 @@ pub fn run_connection(
|
||||
let _ = registry.cast(Cast::ConnBusy(me));
|
||||
|
||||
// ----- 2. Read body. -----
|
||||
// Content-Length pre-check only applies to fixed bodies; a chunked
|
||||
// body is bounded incrementally by the decoder.
|
||||
let body_len = parsed.content_length.unwrap_or(0);
|
||||
if body_len > limits.max_body_bytes {
|
||||
let _ = write_all(
|
||||
@@ -152,11 +154,41 @@ pub fn run_connection(
|
||||
}
|
||||
}
|
||||
|
||||
let body = match read_body(raw, &mut buf, parsed.head_len, body_len, request_deadline) {
|
||||
Ok(b) => b,
|
||||
// Timeout mid-body (and any other body io error) -> just close;
|
||||
// there's no point talking HTTP to a client this far gone.
|
||||
Err(_) => return,
|
||||
// `consumed_past_head`: how many RAW bytes of `buf` past the head
|
||||
// this request's body occupied — for chunked bodies that is framing
|
||||
// included, NOT the decoded length. The keep-alive drain at the
|
||||
// bottom of the loop must drop exactly this much to land on the
|
||||
// next pipelined request.
|
||||
let (body, consumed_past_head) = if parsed.chunked {
|
||||
match read_chunked_body(raw, &mut buf, parsed.head_len, &limits, request_deadline) {
|
||||
Ok(ok) => ok,
|
||||
Err(ChunkedBodyErr::TooLarge) => {
|
||||
let _ = write_all(
|
||||
raw,
|
||||
b"HTTP/1.1 413 Payload Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||
Instant::now() + limits.write_timeout,
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(ChunkedBodyErr::Malformed) => {
|
||||
let _ = write_all(
|
||||
raw,
|
||||
b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||
Instant::now() + limits.write_timeout,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Timeout mid-body (and any other io error) -> just close.
|
||||
Err(ChunkedBodyErr::Io(_)) => return,
|
||||
}
|
||||
} else {
|
||||
match read_body(raw, &mut buf, parsed.head_len, body_len, request_deadline) {
|
||||
Ok(b) => (b, body_len),
|
||||
// Timeout mid-body (and any other body io error) -> just
|
||||
// close; there's no point talking HTTP to a client this far
|
||||
// gone.
|
||||
Err(_) => return,
|
||||
}
|
||||
};
|
||||
|
||||
let keep_alive = parsed.keep_alive;
|
||||
@@ -236,9 +268,9 @@ pub fn run_connection(
|
||||
// catch us, which is acceptable: drain means no new work.)
|
||||
let _ = registry.cast(Cast::ConnIdle(me));
|
||||
|
||||
// Drop the request bytes (head + body) from `buf`; anything past
|
||||
// them is the start of the next pipelined request.
|
||||
let consumed = head_len + body_len;
|
||||
// Drop the request bytes (head + raw body framing) from `buf`;
|
||||
// anything past them is the start of the next pipelined request.
|
||||
let consumed = head_len + consumed_past_head;
|
||||
buf.drain(..consumed);
|
||||
}
|
||||
}
|
||||
@@ -364,6 +396,135 @@ fn read_body(
|
||||
Ok(buf[head_len..head_len + body_len].to_vec())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// read_chunked_body — incremental chunked transfer-decoding (request side).
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Decodes `Transfer-Encoding: chunked` from `buf[head_len..]`, reading more
|
||||
// from the socket as needed on the SAME request deadline the head was read
|
||||
// under. Returns (decoded_body, raw_bytes_consumed_past_head) — the raw
|
||||
// count includes all framing and the trailer section, so the caller's
|
||||
// keep-alive drain lands exactly on the next pipelined request.
|
||||
//
|
||||
// Bounds: the DECODED size is capped at max_body_bytes (-> TooLarge/413);
|
||||
// a single size line (incl. chunk extensions, which are ignored) is capped
|
||||
// at MAX_SIZE_LINE and the trailer section at MAX_TRAILER_BYTES (->
|
||||
// Malformed/400) so framing spam can't grow `buf` unboundedly. Trailers
|
||||
// are consumed and discarded — nothing in the pipeline wants them yet.
|
||||
|
||||
const MAX_SIZE_LINE: usize = 128;
|
||||
const MAX_TRAILER_BYTES: usize = 8 * 1024;
|
||||
|
||||
#[allow(dead_code)] // io::Error is captured for future logging
|
||||
enum ChunkedBodyErr {
|
||||
Io(io::Error),
|
||||
Malformed,
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
fn read_chunked_body(
|
||||
fd: RawFd,
|
||||
buf: &mut Vec<u8>,
|
||||
head_len: usize,
|
||||
limits: &ConnLimits,
|
||||
deadline: Instant,
|
||||
) -> Result<(Vec<u8>, usize), ChunkedBodyErr> {
|
||||
// Ensure `buf` holds at least `until` bytes, reading on the request
|
||||
// deadline. Io(TimedOut) on expiry, UnexpectedEof on early close.
|
||||
fn fill_to(
|
||||
fd: RawFd,
|
||||
buf: &mut Vec<u8>,
|
||||
until: usize,
|
||||
deadline: Instant,
|
||||
) -> Result<(), ChunkedBodyErr> {
|
||||
while buf.len() < until {
|
||||
match read_some(fd, buf, 8 * 1024, deadline) {
|
||||
Ok(0) => {
|
||||
return Err(ChunkedBodyErr::Io(io::Error::new(
|
||||
ErrorKind::UnexpectedEof,
|
||||
"client closed during chunked body",
|
||||
)))
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => return Err(ChunkedBodyErr::Io(e)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Find "\r\n" in buf[from..], reading more as needed; the line may be
|
||||
// at most `max_line` bytes (terminator excluded). Returns the index of
|
||||
// the '\r'.
|
||||
fn find_crlf(
|
||||
fd: RawFd,
|
||||
buf: &mut Vec<u8>,
|
||||
from: usize,
|
||||
max_line: usize,
|
||||
deadline: Instant,
|
||||
) -> Result<usize, ChunkedBodyErr> {
|
||||
let mut scan = from;
|
||||
loop {
|
||||
while scan + 1 < buf.len() {
|
||||
if buf[scan] == b'\r' && buf[scan + 1] == b'\n' {
|
||||
return Ok(scan);
|
||||
}
|
||||
scan += 1;
|
||||
if scan - from > max_line {
|
||||
return Err(ChunkedBodyErr::Malformed);
|
||||
}
|
||||
}
|
||||
fill_to(fd, buf, buf.len() + 1, deadline)?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut pos = head_len;
|
||||
let mut decoded: Vec<u8> = Vec::new();
|
||||
|
||||
loop {
|
||||
// ----- size line: HEX[;extensions]\r\n -----
|
||||
let line_end = find_crlf(fd, buf, pos, MAX_SIZE_LINE, deadline)?;
|
||||
let line = &buf[pos..line_end];
|
||||
let size_str = match line.iter().position(|&b| b == b';') {
|
||||
Some(i) => &line[..i], // chunk extensions: ignored
|
||||
None => line,
|
||||
};
|
||||
let size_str = std::str::from_utf8(size_str)
|
||||
.map_err(|_| ChunkedBodyErr::Malformed)?
|
||||
.trim();
|
||||
let size = usize::from_str_radix(size_str, 16)
|
||||
.map_err(|_| ChunkedBodyErr::Malformed)?;
|
||||
pos = line_end + 2;
|
||||
|
||||
if size == 0 {
|
||||
// ----- trailer section: zero or more header lines, then CRLF -----
|
||||
let trailer_start = pos;
|
||||
loop {
|
||||
let t_end = find_crlf(fd, buf, pos, MAX_SIZE_LINE.max(1024), deadline)?;
|
||||
let empty = t_end == pos;
|
||||
pos = t_end + 2;
|
||||
if empty {
|
||||
return Ok((decoded, pos - head_len));
|
||||
}
|
||||
if pos - trailer_start > MAX_TRAILER_BYTES {
|
||||
return Err(ChunkedBodyErr::Malformed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if decoded.len() + size > limits.max_body_bytes {
|
||||
return Err(ChunkedBodyErr::TooLarge);
|
||||
}
|
||||
|
||||
// ----- chunk payload + trailing CRLF -----
|
||||
fill_to(fd, buf, pos + size + 2, deadline)?;
|
||||
decoded.extend_from_slice(&buf[pos..pos + size]);
|
||||
if &buf[pos + size..pos + size + 2] != b"\r\n" {
|
||||
return Err(ChunkedBodyErr::Malformed);
|
||||
}
|
||||
pos += size + 2;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// read_some — single epoll-park + read loop, bounded by a deadline.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user