import Std.Http import Std.Async open Std.Async open Std Http Internal Test /-! # Limit-enforcement fuzzing for the HTTP/1.1 server. Tests that every configurable limit in `H1.Config` and `Server.Config` is correctly enforced under randomized inputs. Inspired by hyper's fuzzing of size and count limits. -/ def closeChannelIdempotent {α : Type} (ch : Std.CloseableChannel α) : IO Unit := do match ← EIO.toBaseIO ch.close with | .ok _ => pure () | .error .alreadyClosed => pure () | .error err => throw <| IO.userError (toString err) def sendRaw (client : Mock.Client) (server : Mock.Server) (raw : ByteArray) (handler : TestHandler) (config : Config) : IO ByteArray := Async.block do client.send raw Std.Http.Server.serveConnection server handler config |>.run let res ← client.recv? pure (res.getD .empty) def sendRawAndClose (client : Mock.Client) (server : Mock.Server) (raw : ByteArray) (handler : TestHandler) (config : Config) : IO ByteArray := Async.block do client.send raw closeChannelIdempotent client.getSendChan Std.Http.Server.serveConnection server handler config |>.run let res ← client.recv? pure (res.getD .empty) def runWithTimeout {α : Type} (name : String) (timeoutMs : Nat := 20000) (action : IO α) : IO α := do let task ← IO.asTask action let ticks := (timeoutMs + 9) / 10 let rec loop (remaining : Nat) : IO α := do if (← IO.getTaskState task) == .finished then match (← IO.wait task) with | .ok x => pure x | .error err => throw err else match remaining with | 0 => IO.cancel task; throw <| IO.userError s!"Test '{name}' timed out" | n + 1 => IO.sleep 10; loop n loop ticks -- PRNG def nextSeed (seed : Nat) : Nat := (1664525 * seed + 1013904223) % 4294967296 def randBelow (seed : Nat) (n : Nat) : Nat × Nat := let s := nextSeed seed (if n == 0 then 0 else s % n, s) def randIn (seed : Nat) (lo hi : Nat) : Nat × Nat := if hi < lo then (lo, seed) else let (r, s) := randBelow seed (hi - lo + 1) (lo + r, s) def randomTokenBytes (seed : Nat) (len : Nat) : ByteArray × Nat := Id.run do let mut s := seed; let mut out := ByteArray.empty for _ in [0:len] do let (r, s') := randBelow s 36; s := s' let code := if r < 26 then 97 + r else 48 + (r - 26) out := out.push (UInt8.ofNat code) (out, s) def randomAsciiBytes (seed : Nat) (len : Nat) : ByteArray × Nat := Id.run do let mut s := seed; let mut out := ByteArray.empty for _ in [0:len] do let (r, s') := randBelow s 26; s := s' out := out.push (UInt8.ofNat (97 + r)) (out, s) private def toHexAux : Nat → Nat → String → String | 0, _, acc => acc | fuel + 1, n, acc => if n == 0 then acc else let d := n % 16 let c : Char := if d < 10 then Char.ofNat (48 + d) else Char.ofNat (87 + d) toHexAux fuel (n / 16) (String.ofList [c] ++ acc) def natToHex (n : Nat) : String := if n == 0 then "0" else toHexAux 16 n "" def assertStatusPrefix (name : String) (response : ByteArray) (pfx : String) : IO Unit := do let text := String.fromUTF8! response unless text.startsWith pfx do throw <| IO.userError s!"Test '{name}' failed:\nExpected {pfx.quote}\nGot:\n{text.quote}" def countOccurrences (s needle : String) : Nat := if needle.isEmpty then 0 else (s.splitOn needle).length - 1 def bad400 : String := "HTTP/1.1 400 Bad Request\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n" def echoBodyHandler : TestHandler := fun req => do let body : String ← req.body.readAll Response.ok |>.text body -- ============================================================================ -- maxBodySize — Content-Length framing -- ============================================================================ -- Property: Content-Length body exactly at or below maxBodySize → 200. -- Content-Length body above maxBodySize → 413 (no body bytes needed). def fuzzBodySizeLimitContentLength (iterations : Nat) (seed0 : Nat) : IO Unit := do let limit : Nat := 64 let config : Config := { lingeringTimeout := 1000, generateDate := false, maxBodySize := limit } let mut seed := seed0 for i in [0:iterations] do let caseSeed := seed let (bodySize, s1) := randIn seed 0 (limit + 20); seed := s1 let (bodyBytes, s2) := randomAsciiBytes seed bodySize; seed := s2 let raw := s!"POST /bl-cl-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: {bodySize}\x0d\nConnection: close\x0d\n\x0d\n".toUTF8 ++ bodyBytes let (client, server) ← Mock.new let response ← sendRaw client server raw (fun req => do let _body : String ← req.body.readAll; Response.ok |>.text "ok") config if bodySize ≤ limit then assertStatusPrefix s!"fuzzBodySizeLimitCL iter={i} seed={caseSeed} size={bodySize}" response "HTTP/1.1 200" else assertStatusPrefix s!"fuzzBodySizeLimitCL iter={i} seed={caseSeed} size={bodySize}" response "HTTP/1.1 413" -- ============================================================================ -- maxBodySize — chunked framing -- ============================================================================ -- Property: chunked bodies with total bytes at or below maxBodySize → 200. -- Chunked bodies exceeding maxBodySize → 413. def fuzzBodySizeLimitChunked (iterations : Nat) (seed0 : Nat) : IO Unit := do let limit : Nat := 64 let config : Config := { lingeringTimeout := 1000, generateDate := false, maxBodySize := limit } let mut seed := seed0 for i in [0:iterations] do let caseSeed := seed -- Total body size across 1-4 chunks let (totalSize, s1) := randIn seed 0 (limit + 16); seed := s1 let (numChunks, s2) := randIn seed 1 4; seed := s2 -- Build chunks that sum to totalSize let chunkSize := (totalSize + numChunks - 1) / numChunks let mut chunkedBody := ByteArray.empty let mut remaining := totalSize for _ in [0:numChunks] do if remaining == 0 then break let thisChunk := Nat.min chunkSize remaining let (chunkBytes, s3) := randomAsciiBytes seed thisChunk; seed := s3 chunkedBody := chunkedBody ++ s!"{natToHex thisChunk}\x0d\n".toUTF8 ++ chunkBytes ++ "\x0d\n".toUTF8 remaining := remaining - thisChunk chunkedBody := chunkedBody ++ "0\x0d\n\x0d\n".toUTF8 let raw := s!"POST /bl-ch-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\nConnection: close\x0d\n\x0d\n".toUTF8 ++ chunkedBody let (client, server) ← Mock.new let response ← sendRaw client server raw (fun req => do let _body : String ← req.body.readAll; Response.ok |>.text "ok") config if totalSize ≤ limit then assertStatusPrefix s!"fuzzBodySizeLimitChunked iter={i} seed={caseSeed} total={totalSize}" response "HTTP/1.1 200" else assertStatusPrefix s!"fuzzBodySizeLimitChunked iter={i} seed={caseSeed} total={totalSize}" response "HTTP/1.1 413" -- ============================================================================ -- maxHeaders — header count limit -- ============================================================================ -- Property: header count at or below maxHeaders → 200. -- header count above maxHeaders → 400. def fuzzHeaderCountLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do let limit : Nat := 5 let config : Config := { lingeringTimeout := 1000, generateDate := false, maxHeaders := limit } let mut seed := seed0 for i in [0:iterations] do let caseSeed := seed -- Host counts as 1 header, Connection as 1, so extra headers = headerCount - 2 let (headerCount, s1) := randIn seed 2 (limit + 4); seed := s1 let extraCount := headerCount - 2 -- we always add Host + Connection let mut extraHeaders := "" let mut s := s1 for j in [0:extraCount] do let (nameLen, s2) := randIn s 1 8; s := s2 let (nameBytes, s3) := randomTokenBytes s nameLen; s := s3 let name := String.fromUTF8! nameBytes extraHeaders := extraHeaders ++ s!"X-Extra-{j}-{name}: value\x0d\n" seed := s let raw := s!"GET /hc-{i} HTTP/1.1\x0d\nHost: example.com\x0d\n{extraHeaders}Connection: close\x0d\n\x0d\n".toUTF8 let (client, server) ← Mock.new let response ← sendRawAndClose client server raw okHandler config -- headerCount includes Host and Connection (always present) if headerCount ≤ limit then assertStatusPrefix s!"fuzzHeaderCount iter={i} seed={caseSeed} count={headerCount}" response "HTTP/1.1 200" else assertStatusPrefix s!"fuzzHeaderCount iter={i} seed={caseSeed} count={headerCount}" response "HTTP/1.1 431" -- ============================================================================ -- maxHeaderBytes — total header bytes limit -- ============================================================================ -- Property: headers whose aggregate bytes (name + ": " + value + "\r\n") are at or -- below maxHeaderBytes are accepted; above it they are rejected with 400. def fuzzHeaderTotalBytesLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do -- Fixed baseline: "Host: example.com\r\n" + "Connection: close\r\n" = 20 + 20 = 40 bytes. -- We'll add a single large X-Payload header to cross the boundary. let limit : Nat := 200 let config : Config := { lingeringTimeout := 1000 generateDate := false maxHeaderBytes := limit maxHeaderValueLength := limit + 100 -- allow value longer than total limit for testing } let baseline := ("Host: example.com\x0d\nConnection: close\x0d\n".toUTF8).size let mut seed := seed0 for i in [0:iterations] do let caseSeed := seed -- Pick a value size that puts total bytes just under or over the limit -- Each "X-Payload: " header = name(9) + ": "(2) + value + "\r\n"(2) = value + 13 let overhead := baseline + 13 -- "X-Payload" (9) + ": " (2) + "\r\n" (2) + baseline -- We want value sizes that land on both sides of (limit - overhead) let boundary := if limit > overhead then limit - overhead else 0 let (valueSize, s1) := randIn seed 0 (boundary + 20); seed := s1 let (valueBytes, s2) := randomAsciiBytes seed valueSize; seed := s2 let value := String.fromUTF8! valueBytes let raw := s!"GET /hb-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nX-Payload: {value}\x0d\nConnection: close\x0d\n\x0d\n".toUTF8 let (client, server) ← Mock.new let response ← sendRawAndClose client server raw okHandler config -- Total bytes = baseline + "X-Payload: " + value + "\r\n" let totalBytes := baseline + 9 + 2 + valueSize + 2 if totalBytes ≤ limit then assertStatusPrefix s!"fuzzHeaderBytes iter={i} seed={caseSeed} total={totalBytes}" response "HTTP/1.1 200" else assertStatusPrefix s!"fuzzHeaderBytes iter={i} seed={caseSeed} total={totalBytes}" response "HTTP/1.1 431" -- ============================================================================ -- maxMessages — requests per connection -- ============================================================================ -- Property: after maxMessages requests on a single connection, the server -- closes the connection (disables keep-alive). All maxMessages -- requests receive a valid response. def fuzzMaxMessagesPerConnection (iterations : Nat) (seed0 : Nat) : IO Unit := do let limit : Nat := 3 let config : Config := { lingeringTimeout := 1000 generateDate := false maxRequests := limit } let mut seed := seed0 for i in [0:iterations] do let caseSeed := seed let (reqCount, s1) := randIn seed 1 (limit + 3); seed := s1 -- Build reqCount keep-alive requests followed by close let mut raw := ByteArray.empty for j in [0:reqCount] do let connHeader := if j + 1 == reqCount then "Connection: close\x0d\n" else "Connection: keep-alive\x0d\n" raw := raw ++ s!"GET /msg-{i}-{j} HTTP/1.1\x0d\nHost: example.com\x0d\n{connHeader}\x0d\n".toUTF8 let (client, server) ← Mock.new let response ← sendRawAndClose client server raw okHandler config let text := String.fromUTF8! response let seen := countOccurrences text "HTTP/1.1 200" let expected := Nat.min reqCount limit if seen != expected then throw <| IO.userError s!"fuzzMaxMessages iter={i} seed={caseSeed} reqCount={reqCount}: expected {expected} responses, got {seen}\n{text.quote}" -- ============================================================================ -- maxLeadingEmptyLines — leading CRLF before request-line -- ============================================================================ -- Property: at most maxLeadingEmptyLines bare CRLFs before the request-line are tolerated. -- More than that → 400. def fuzzLeadingEmptyLinesLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do let limit : Nat := 4 let config : Config := { lingeringTimeout := 1000, generateDate := false, maxLeadingEmptyLines := limit } let mut seed := seed0 for i in [0:iterations] do let caseSeed := seed let (lineCount, s1) := randIn seed 0 (limit + 4); seed := s1 let leadingCRLF := (List.replicate lineCount "\x0d\n").foldl (· ++ ·) "" let raw := (leadingCRLF ++ s!"GET /le-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n").toUTF8 let (client, server) ← Mock.new let response ← sendRawAndClose client server raw okHandler config if lineCount ≤ limit then assertStatusPrefix s!"fuzzLeadingEmptyLines iter={i} seed={caseSeed} count={lineCount}" response "HTTP/1.1 200" else assertStatusPrefix s!"fuzzLeadingEmptyLines iter={i} seed={caseSeed} count={lineCount}" response "HTTP/1.1 400" -- ============================================================================ -- maxSpaceSequence — OWS (optional whitespace) limit -- ============================================================================ -- Property: OWS sequences at or below maxSpaceSequence are accepted. -- OWS sequences exceeding the limit → 400. def fuzzOWSSequenceLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do let limit : Nat := 4 let config : Config := { lingeringTimeout := 1000, generateDate := false, maxSpaceSequence := limit } let mut seed := seed0 for i in [0:iterations] do let caseSeed := seed let (spaceCount, s1) := randIn seed 0 (limit + 4); seed := s1 let spaces := String.ofList (List.replicate spaceCount ' ') -- OWS appears between ':' and value in header fields let raw := s!"GET /ows-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nX-OWS:{spaces}value\x0d\nConnection: close\x0d\n\x0d\n".toUTF8 let (client, server) ← Mock.new let response ← sendRawAndClose client server raw okHandler config if spaceCount ≤ limit then assertStatusPrefix s!"fuzzOWSLimit iter={i} seed={caseSeed} spaces={spaceCount}" response "HTTP/1.1 200" else assertStatusPrefix s!"fuzzOWSLimit iter={i} seed={caseSeed} spaces={spaceCount}" response "HTTP/1.1 400" -- ============================================================================ -- maxStartLineLength — request-line length limit -- ============================================================================ -- Property: request lines at or below maxStartLineLength → 200. -- Request lines above maxStartLineLength → 414 (URI too long) or 400. def fuzzStartLineLengthLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do let limit : Nat := 64 let config : Config := { lingeringTimeout := 1000 generateDate := false maxStartLineLength := limit maxUriLength := limit } let mut seed := seed0 for i in [0:iterations] do let caseSeed := seed -- "GET " (4) + path + " HTTP/1.1\r\n" (12) → path can be up to (limit - 16) let pathBudget := if limit > 16 then limit - 16 else 1 let (pathLen, s1) := randIn seed 1 (pathBudget + 10); seed := s1 let path := "/" ++ String.ofList (List.replicate (pathLen - 1) 'a') let raw := s!"GET {path} HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8 let (client, server) ← Mock.new let response ← sendRawAndClose client server raw okHandler config -- start-line = "GET " + path + " HTTP/1.1\r\n" let lineLen := 4 + pathLen + 11 if lineLen ≤ limit then assertStatusPrefix s!"fuzzStartLineLen iter={i} seed={caseSeed} len={lineLen}" response "HTTP/1.1 200" else -- Either 414 (URI too long) or 400 let text := String.fromUTF8! response unless text.startsWith "HTTP/1.1 414" || text.startsWith "HTTP/1.1 400" do throw <| IO.userError s!"fuzzStartLineLen iter={i} seed={caseSeed} len={lineLen}: expected 414 or 400, got {text.quote}" -- ============================================================================ -- maxHeaderNameLength and maxHeaderValueLength -- ============================================================================ -- Property: header names exceeding maxHeaderNameLength → 400. def fuzzHeaderNameLengthLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do let limit : Nat := 16 let config : Config := { lingeringTimeout := 1000, generateDate := false, maxHeaderNameLength := limit } let mut seed := seed0 for i in [0:iterations] do let caseSeed := seed let (nameLen, s1) := randIn seed 1 (limit + 8); seed := s1 let (nameBytes, s2) := randomTokenBytes seed nameLen; seed := s2 let name := String.fromUTF8! nameBytes let raw := s!"GET /hnl-{i} HTTP/1.1\x0d\nHost: example.com\x0d\n{name}: value\x0d\nConnection: close\x0d\n\x0d\n".toUTF8 let (client, server) ← Mock.new let response ← sendRawAndClose client server raw okHandler config if nameLen ≤ limit then assertStatusPrefix s!"fuzzHeaderNameLen iter={i} seed={caseSeed} len={nameLen}" response "HTTP/1.1 200" else assertStatusPrefix s!"fuzzHeaderNameLen iter={i} seed={caseSeed} len={nameLen}" response "HTTP/1.1 400" -- Property: header values exceeding maxHeaderValueLength → 400. def fuzzHeaderValueLengthLimit (iterations : Nat) (seed0 : Nat) : IO Unit := do let limit : Nat := 32 let config : Config := { lingeringTimeout := 1000 generateDate := false maxHeaderValueLength := limit maxHeaderBytes := 65536 -- don't let total bytes limit interfere } let mut seed := seed0 for i in [0:iterations] do let caseSeed := seed let (valueLen, s1) := randIn seed 0 (limit + 8); seed := s1 let (valueBytes, s2) := randomAsciiBytes seed valueLen; seed := s2 let value := String.fromUTF8! valueBytes let raw := s!"GET /hvl-{i} HTTP/1.1\x0d\nHost: example.com\x0d\nX-Long: {value}\x0d\nConnection: close\x0d\n\x0d\n".toUTF8 let (client, server) ← Mock.new let response ← sendRawAndClose client server raw okHandler config if valueLen ≤ limit then assertStatusPrefix s!"fuzzHeaderValueLen iter={i} seed={caseSeed} len={valueLen}" response "HTTP/1.1 200" else assertStatusPrefix s!"fuzzHeaderValueLen iter={i} seed={caseSeed} len={valueLen}" response "HTTP/1.1 400" -- ============================================================================ -- Run all properties -- ============================================================================ -- Property: maxBodySize enforced for Content-Length framing. #eval runWithTimeout "fuzz_body_limit_content_length" 30000 do fuzzBodySizeLimitContentLength 50 0x00B0DEC0 -- Property: maxBodySize enforced for chunked framing. #eval runWithTimeout "fuzz_body_limit_chunked" 30000 do fuzzBodySizeLimitChunked 40 0x00C8BE11 -- Property: maxHeaders count limit is enforced. #eval runWithTimeout "fuzz_header_count_limit" 30000 do fuzzHeaderCountLimit 50 0x00AA55AA -- Property: maxHeaderBytes aggregate limit is enforced. #eval runWithTimeout "fuzz_header_bytes_limit" 30000 do fuzzHeaderTotalBytesLimit 40 0x00FACE77 -- Property: maxMessages per connection closes keep-alive after the configured count. #eval runWithTimeout "fuzz_max_messages_per_connection" 30000 do fuzzMaxMessagesPerConnection 30 0x00123ABC -- Property: maxLeadingEmptyLines limit is enforced. #eval runWithTimeout "fuzz_leading_empty_lines_limit" 30000 do fuzzLeadingEmptyLinesLimit 50 0x00EEF00D -- Property: maxSpaceSequence (OWS) limit is enforced. #eval runWithTimeout "fuzz_ows_sequence_limit" 30000 do fuzzOWSSequenceLimit 50 0x00ABE5AB -- Property: maxStartLineLength / maxUriLength is enforced, returning 414 or 400. #eval runWithTimeout "fuzz_start_line_length_limit" 30000 do fuzzStartLineLengthLimit 50 0x00C0FFEE -- Property: maxHeaderNameLength is enforced. #eval runWithTimeout "fuzz_header_name_length_limit" 30000 do fuzzHeaderNameLengthLimit 50 0x00DEAD01 -- Property: maxHeaderValueLength is enforced. #eval runWithTimeout "fuzz_header_value_length_limit" 30000 do fuzzHeaderValueLengthLimit 50 0x00BEEF02