My aggregated monorepo of OCaml code, automaintained
at doc-fixes 789 lines 29 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** HTTP response parsing using Eio.Buf_read combinators 7 8 This module provides efficient HTTP/1.1 response parsing using Eio's 9 buffered read API with parser combinators for clean, composable parsing. *) 10 11let src = Logs.Src.create "requests.http_read" ~doc:"HTTP response parsing" 12module Log = (val Logs.src_log src : Logs.LOG) 13 14module Read = Eio.Buf_read 15 16(** Import limits from Response_limits module. *) 17type limits = Response_limits.t 18 19(** {1 Character Predicates} *) 20 21(** HTTP version characters: letters, digits, slash, dot *) 22let is_version_char = function 23 | 'A'..'Z' | 'a'..'z' | '0'..'9' | '/' | '.' -> true 24 | _ -> false 25 26(** HTTP status code digits *) 27let is_digit = function 28 | '0'..'9' -> true 29 | _ -> false 30 31(** RFC 9110 token characters for header names *) 32let is_token_char = function 33 | 'A'..'Z' | 'a'..'z' | '0'..'9' -> true 34 | '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '.' -> true 35 | '^' | '_' | '`' | '|' | '~' -> true 36 | _ -> false 37 38(** Hex digits for chunk size *) 39let is_hex_digit = function 40 | '0'..'9' | 'a'..'f' | 'A'..'F' -> true 41 | _ -> false 42 43(** Optional whitespace *) 44let is_ows = function 45 | ' ' | '\t' -> true 46 | _ -> false 47 48(** {1 Security Validation} 49 50 Per RFC 9112 Section 2.2: bare CR MUST be rejected to prevent 51 HTTP request smuggling attacks. *) 52 53(** Maximum chunk size hex digits (16 hex digits = 64-bit max) *) 54let max_chunk_size_hex_digits = 16 55 56(** Validate that a string contains no bare CR characters. 57 A bare CR is a CR not followed by LF, which can be used for 58 HTTP request smuggling attacks. 59 @raise Error.t if bare CR is found. *) 60let validate_no_bare_cr ~context s = 61 let len = String.length s in 62 for i = 0 to len - 1 do 63 if s.[i] = '\r' then begin 64 if i + 1 >= len || s.[i + 1] <> '\n' then 65 raise (Error.invalid_requestf "Bare CR in %s (potential HTTP smuggling attack)" context) 66 end 67 done 68 69(** {1 Low-level Parsers} *) 70 71let sp = Read.char ' ' 72 73let http_version r = 74 Read.take_while is_version_char r 75 76let status_code r = 77 let code_str = Read.take_while is_digit r in 78 if String.length code_str <> 3 then 79 raise (Error.invalid_requestf "Invalid status code: %s" code_str); 80 try int_of_string code_str 81 with _ -> 82 raise (Error.invalid_requestf "Invalid status code: %s" code_str) 83 84let reason_phrase r = 85 Read.line r 86 87(** {1 HTTP Version Type} 88 89 Per Recommendation #26: Expose HTTP version used for the response *) 90 91type http_version = 92 | HTTP_1_0 93 | HTTP_1_1 94 95let http_version_of_string = function 96 | "HTTP/1.0" -> HTTP_1_0 97 | "HTTP/1.1" -> HTTP_1_1 98 | v -> raise (Error.invalid_requestf "Invalid HTTP version: %s" v) 99 100let http_version_to_string = function 101 | HTTP_1_0 -> "HTTP/1.0" 102 | HTTP_1_1 -> "HTTP/1.1" 103 104(** {1 Status Line Parser} *) 105 106let status_line r = 107 let version_str = http_version r in 108 (* Parse and validate HTTP version *) 109 let version = http_version_of_string version_str in 110 sp r; 111 let code = status_code r in 112 sp r; 113 let reason = reason_phrase r in 114 (* RFC 9112 Section 2.2: Validate no bare CR in reason phrase *) 115 validate_no_bare_cr ~context:"reason phrase" reason; 116 Log.debug (fun m -> m "Parsed status line: %s %d" version_str code); 117 (version, code) 118 119(** {1 Header Parsing} *) 120 121(** Parse a single header line. Returns ("", "") for empty line (end of headers). 122 Handles obs-fold (RFC 9112 Section 5.2): continuation lines starting with 123 whitespace are merged into the previous header value with a single space. 124 Per RFC 9112 Section 2.2: validates that no bare CR characters are present. *) 125let header_line r = 126 let name = Read.take_while is_token_char r in 127 if name = "" then begin 128 (* Empty line - end of headers. Consume the CRLF. *) 129 let line = Read.line r in 130 if line <> "" then 131 raise (Error.err (Error.Invalid_request { 132 reason = "Expected empty line but got: " ^ line 133 })); 134 ("", "") 135 end else begin 136 Read.char ':' r; 137 Read.skip_while is_ows r; 138 let value = Read.line r in 139 (* RFC 9112 Section 2.2: Validate no bare CR in header value *) 140 validate_no_bare_cr ~context:"header value" value; 141 (* RFC 9112 Section 5.2: Handle obs-fold (obsolete line folding) 142 A recipient of an obs-fold MUST replace each obs-fold with one or more 143 SP octets prior to interpreting the field value. *) 144 let rec collect_obs_fold acc = 145 match Read.peek_char r with 146 | Some (' ' | '\t') -> 147 (* obs-fold: continuation line starts with whitespace *) 148 Log.debug (fun m -> m "Handling obs-fold continuation for header %s" name); 149 Read.skip_while is_ows r; 150 let continuation = Read.line r in 151 (* Validate continuation for bare CR *) 152 validate_no_bare_cr ~context:"header continuation" continuation; 153 (* Replace obs-fold with single space and continue *) 154 collect_obs_fold (acc ^ " " ^ String.trim continuation) 155 | _ -> acc 156 in 157 let full_value = collect_obs_fold value in 158 (String.lowercase_ascii name, String.trim full_value) 159 end 160 161(** Parse all headers with size and count limits *) 162let headers ~limits r = 163 let max_count = Response_limits.max_header_count limits in 164 let max_size = Response_limits.max_header_size limits in 165 let rec loop acc count = 166 (* Check header count limit *) 167 if count >= max_count then 168 raise (Error.err (Error.Headers_too_large { 169 limit = max_count; 170 actual = count + 1 171 })); 172 173 let (name, value) = header_line r in 174 175 if name = "" then begin 176 (* End of headers *) 177 Log.debug (fun m -> m "Parsed %d headers" count); 178 Headers.of_list (List.rev acc) 179 end else begin 180 (* Check header line size limit *) 181 let line_len = String.length name + String.length value + 2 in 182 if line_len > max_size then 183 raise (Error.err (Error.Headers_too_large { 184 limit = max_size; 185 actual = line_len 186 })); 187 188 loop ((name, value) :: acc) (count + 1) 189 end 190 in 191 loop [] 0 192 193(** {1 Body Parsing} *) 194 195(** Read body until connection close (close-delimited message). 196 Per RFC 9112 Section 6.3 item 8: When no Transfer-Encoding or Content-Length 197 is present, the body length is determined by reading until connection close. *) 198let close_delimited_body ~limits r = 199 let max_body = Response_limits.max_response_body_size limits in 200 Log.debug (fun m -> m "Reading close-delimited body (until EOF)"); 201 202 let buf = Buffer.create 8192 in 203 let bytes_read = ref 0L in 204 205 let rec read_until_eof () = 206 (* Check size limit *) 207 if !bytes_read > max_body then 208 raise (Error.err (Error.Body_too_large { 209 limit = max_body; 210 actual = Some !bytes_read 211 })); 212 213 (* Try to read a chunk - at_end_of_input returns true when EOF reached *) 214 if Read.at_end_of_input r then 215 Buffer.contents buf 216 else begin 217 (* Read up to 8KB at a time *) 218 let chunk = Read.take_while (fun _ -> true) r in 219 let chunk_len = String.length chunk in 220 if chunk_len > 0 then begin 221 Buffer.add_string buf chunk; 222 bytes_read := Int64.add !bytes_read (Int64.of_int chunk_len); 223 read_until_eof () 224 end else 225 (* No more data available *) 226 Buffer.contents buf 227 end 228 in 229 read_until_eof () 230 231(** Read a fixed-length body with size limit checking *) 232let fixed_body ~limits ~length r = 233 let max_body = Response_limits.max_response_body_size limits in 234 (* Check size limit before allocating *) 235 if length > max_body then 236 raise (Error.err (Error.Body_too_large { 237 limit = max_body; 238 actual = Some length 239 })); 240 241 Log.debug (fun m -> m "Reading fixed-length body: %Ld bytes" length); 242 243 let len_int = Int64.to_int length in 244 let buf = Buffer.create len_int in 245 let bytes_read = ref 0L in 246 247 let rec read_n remaining = 248 if remaining > 0L then begin 249 let to_read = min 8192 (Int64.to_int remaining) in 250 let chunk = Read.take to_read r in 251 let chunk_len = String.length chunk in 252 253 if chunk_len = 0 then 254 (* Connection closed prematurely - Content-Length mismatch *) 255 raise (Error.err (Error.Content_length_mismatch { 256 expected = length; 257 actual = !bytes_read 258 })) 259 else begin 260 Buffer.add_string buf chunk; 261 bytes_read := Int64.add !bytes_read (Int64.of_int chunk_len); 262 read_n (Int64.sub remaining (Int64.of_int chunk_len)) 263 end 264 end 265 in 266 read_n length; 267 Buffer.contents buf 268 269(** Parse chunk size line (hex size with optional extensions). 270 Per RFC 9112 Section 7.1: protect against chunk size overflow attacks. *) 271let chunk_size r = 272 let hex_str = Read.take_while is_hex_digit r in 273 if hex_str = "" then 274 raise (Error.err (Error.Invalid_request { 275 reason = "Empty chunk size" 276 })); 277 (* Protect against overflow: limit hex digits to prevent parsing huge numbers. 278 16 hex digits = 64-bit max, which is way more than any reasonable chunk. *) 279 if String.length hex_str > max_chunk_size_hex_digits then 280 raise (Error.invalid_requestf "Chunk size too large (%d hex digits, max %d)" 281 (String.length hex_str) max_chunk_size_hex_digits); 282 (* Skip any chunk extensions (after semicolon) - validate for bare CR *) 283 let extensions = Read.take_while (fun c -> c <> '\r' && c <> '\n') r in 284 validate_no_bare_cr ~context:"chunk extension" extensions; 285 let _ = Read.line r in (* Consume CRLF *) 286 try int_of_string ("0x" ^ hex_str) 287 with _ -> 288 raise (Error.invalid_requestf "Invalid chunk size: %s" hex_str) 289 290(** {1 Trailer Header Parsing} 291 292 Per RFC 9112 Section 7.1.2: Trailer section can contain headers after the 293 final chunk. Certain headers MUST NOT be in trailers (hop-by-hop, content-*, etc.). *) 294 295(** Headers that MUST NOT appear in trailers per RFC 9110 Section 6.5.1 *) 296let forbidden_trailer_headers = [ 297 "transfer-encoding"; "content-length"; "host"; "content-encoding"; 298 "content-type"; "content-range"; "trailer" 299] 300 301(** Parse trailer headers after final chunk. 302 Returns parsed headers. Forbidden trailer headers are logged and ignored. *) 303let parse_trailers ~limits r = 304 let max_count = Response_limits.max_header_count limits in 305 let max_size = Response_limits.max_header_size limits in 306 let rec loop acc count = 307 if count >= max_count then begin 308 Log.warn (fun m -> m "Trailer count limit reached (%d), skipping remaining" max_count); 309 Headers.of_list (List.rev acc) 310 end else begin 311 let line = Read.line r in 312 if line = "" then 313 (* End of trailers *) 314 Headers.of_list (List.rev acc) 315 else 316 (* Parse trailer line *) 317 match String.index_opt line ':' with 318 | None -> 319 Log.warn (fun m -> m "Invalid trailer line (no colon): %s" line); 320 loop acc count 321 | Some colon_idx -> 322 let name = String.sub line 0 colon_idx |> String.trim |> String.lowercase_ascii in 323 let value = String.sub line (colon_idx + 1) (String.length line - colon_idx - 1) |> String.trim in 324 (* Check header size *) 325 let line_len = String.length name + String.length value + 2 in 326 if line_len > max_size then begin 327 Log.warn (fun m -> m "Trailer header too large (%d > %d), skipping: %s" line_len max_size name); 328 loop acc count 329 end else if List.mem name forbidden_trailer_headers then begin 330 Log.warn (fun m -> m "Forbidden header in trailers, ignoring: %s" name); 331 loop acc count 332 end else 333 loop ((name, value) :: acc) (count + 1) 334 end 335 in 336 loop [] 0 337 338(** Skip trailer headers after final chunk (legacy compatibility) *) 339let skip_trailers r = 340 let rec loop () = 341 let line = Read.line r in 342 if line <> "" then loop () 343 in 344 loop () 345 346(** Read a chunked transfer-encoded body with size limit checking *) 347let chunked_body ~limits r = 348 Log.debug (fun m -> m "Reading chunked body"); 349 let max_body = Response_limits.max_response_body_size limits in 350 let buf = Buffer.create 4096 in 351 let total_size = ref 0L in 352 353 let rec read_chunks () = 354 let size = chunk_size r in 355 356 if size = 0 then begin 357 (* Final chunk - skip trailers *) 358 skip_trailers r; 359 Log.debug (fun m -> m "Chunked body complete: %Ld bytes" !total_size); 360 Buffer.contents buf 361 end else begin 362 (* Check size limit before reading chunk *) 363 let new_total = Int64.add !total_size (Int64.of_int size) in 364 if new_total > max_body then 365 raise (Error.err (Error.Body_too_large { 366 limit = max_body; 367 actual = Some new_total 368 })); 369 370 let chunk = Read.take size r in 371 Buffer.add_string buf chunk; 372 total_size := new_total; 373 let _ = Read.line r in (* Consume trailing CRLF *) 374 read_chunks () 375 end 376 in 377 read_chunks () 378 379(** {1 Streaming Body Sources} *) 380 381(** A flow source that reads from a Buf_read with a fixed length limit *) 382module Fixed_body_source = struct 383 type t = { 384 buf_read : Read.t; 385 mutable remaining : int64; 386 } 387 388 let single_read t dst = 389 if t.remaining <= 0L then raise End_of_file; 390 391 let to_read = min (Cstruct.length dst) (Int64.to_int (min t.remaining 8192L)) in 392 393 (* Ensure data is available *) 394 Read.ensure t.buf_read to_read; 395 let src = Read.peek t.buf_read in 396 let actual = min to_read (Cstruct.length src) in 397 398 Cstruct.blit src 0 dst 0 actual; 399 Read.consume t.buf_read actual; 400 t.remaining <- Int64.sub t.remaining (Int64.of_int actual); 401 actual 402 403 let read_methods = [] 404end 405 406let fixed_body_stream ~limits ~length buf_read = 407 let max_body = Response_limits.max_response_body_size limits in 408 (* Check size limit *) 409 if length > max_body then 410 raise (Error.err (Error.Body_too_large { 411 limit = max_body; 412 actual = Some length 413 })); 414 415 let t = { Fixed_body_source.buf_read; remaining = length } in 416 let ops = Eio.Flow.Pi.source (module Fixed_body_source) in 417 Eio.Resource.T (t, ops) 418 419(** A flow source that reads chunked transfer encoding from a Buf_read *) 420module Chunked_body_source = struct 421 type state = 422 | Reading_size 423 | Reading_chunk of int 424 | Reading_chunk_end 425 | Done 426 427 type t = { 428 buf_read : Read.t; 429 mutable state : state; 430 mutable total_read : int64; 431 max_body_size : int64; 432 } 433 434 let read_chunk_size t = 435 let hex_str = Read.take_while is_hex_digit t.buf_read in 436 if hex_str = "" then 0 437 else begin 438 (* Protect against overflow: limit hex digits *) 439 if String.length hex_str > max_chunk_size_hex_digits then 440 raise (Error.err (Error.Invalid_request { 441 reason = Printf.sprintf "Chunk size too large (%d hex digits)" 442 (String.length hex_str) 443 })); 444 (* Skip extensions and CRLF - validate for bare CR *) 445 let extensions = Read.take_while (fun c -> c <> '\r' && c <> '\n') t.buf_read in 446 validate_no_bare_cr ~context:"chunk extension" extensions; 447 let _ = Read.line t.buf_read in 448 try int_of_string ("0x" ^ hex_str) 449 with _ -> 0 450 end 451 452 let single_read t dst = 453 let rec aux () = 454 match t.state with 455 | Done -> raise End_of_file 456 | Reading_size -> 457 let size = read_chunk_size t in 458 if size = 0 then begin 459 (* Skip trailers *) 460 let rec skip () = 461 let line = Read.line t.buf_read in 462 if line <> "" then skip () 463 in 464 skip (); 465 t.state <- Done; 466 raise End_of_file 467 end else begin 468 (* Check size limit *) 469 let new_total = Int64.add t.total_read (Int64.of_int size) in 470 if new_total > t.max_body_size then 471 raise (Error.err (Error.Body_too_large { 472 limit = t.max_body_size; 473 actual = Some new_total 474 })); 475 t.state <- Reading_chunk size; 476 aux () 477 end 478 | Reading_chunk remaining -> 479 let to_read = min (Cstruct.length dst) remaining in 480 Read.ensure t.buf_read to_read; 481 let src = Read.peek t.buf_read in 482 let actual = min to_read (Cstruct.length src) in 483 Cstruct.blit src 0 dst 0 actual; 484 Read.consume t.buf_read actual; 485 t.total_read <- Int64.add t.total_read (Int64.of_int actual); 486 let new_remaining = remaining - actual in 487 if new_remaining = 0 then 488 t.state <- Reading_chunk_end 489 else 490 t.state <- Reading_chunk new_remaining; 491 actual 492 | Reading_chunk_end -> 493 let _ = Read.line t.buf_read in (* Consume trailing CRLF *) 494 t.state <- Reading_size; 495 aux () 496 in 497 aux () 498 499 let read_methods = [] 500end 501 502let chunked_body_stream ~limits buf_read = 503 let t = { 504 Chunked_body_source.buf_read; 505 state = Reading_size; 506 total_read = 0L; 507 max_body_size = Response_limits.max_response_body_size limits; 508 } in 509 let ops = Eio.Flow.Pi.source (module Chunked_body_source) in 510 Eio.Resource.T (t, ops) 511 512(** A flow source that reads until connection close (close-delimited). 513 Per RFC 9112 Section 6.3 item 8: When no Transfer-Encoding or Content-Length 514 is present, the body length is determined by reading until connection close. *) 515module Close_delimited_source = struct 516 type t = { 517 buf_read : Read.t; 518 mutable total_read : int64; 519 max_body_size : int64; 520 mutable eof : bool; 521 } 522 523 let single_read t dst = 524 if t.eof then raise End_of_file; 525 526 (* Check size limit *) 527 if t.total_read > t.max_body_size then 528 raise (Error.err (Error.Body_too_large { 529 limit = t.max_body_size; 530 actual = Some t.total_read 531 })); 532 533 if Read.at_end_of_input t.buf_read then begin 534 t.eof <- true; 535 raise End_of_file 536 end; 537 538 let to_read = min (Cstruct.length dst) 8192 in 539 (* Try to ensure data is available, but don't fail on EOF *) 540 (try Read.ensure t.buf_read 1 with End_of_file -> 541 t.eof <- true; 542 raise End_of_file); 543 544 let src = Read.peek t.buf_read in 545 let available = Cstruct.length src in 546 if available = 0 then begin 547 t.eof <- true; 548 raise End_of_file 549 end; 550 551 let actual = min to_read available in 552 Cstruct.blit src 0 dst 0 actual; 553 Read.consume t.buf_read actual; 554 t.total_read <- Int64.add t.total_read (Int64.of_int actual); 555 actual 556 557 let read_methods = [] 558end 559 560let close_delimited_body_stream ~limits buf_read = 561 let t = { 562 Close_delimited_source.buf_read; 563 total_read = 0L; 564 max_body_size = Response_limits.max_response_body_size limits; 565 eof = false; 566 } in 567 let ops = Eio.Flow.Pi.source (module Close_delimited_source) in 568 Eio.Resource.T (t, ops) 569 570(** {1 High-level Response Parsing} *) 571 572(** Check if response should have no body per 573 {{:https://datatracker.ietf.org/doc/html/rfc9110#section-6.4.1}RFC 9110 Section 6.4.1}: 574 {ul 575 {- Any response to a HEAD request} 576 {- 2xx (Successful) response to a CONNECT request (switches to tunnel mode)} 577 {- Any 1xx (Informational) response} 578 {- 204 (No Content) response} 579 {- 304 (Not Modified) response}} *) 580let response_has_no_body ~method_ ~status = 581 match method_, status with 582 | Some `HEAD, _ -> true 583 | Some `CONNECT, s when s >= 200 && s < 300 -> true 584 | _, s when s >= 100 && s < 200 -> true 585 | _, 204 | _, 304 -> true 586 | _ -> false 587 588(** {1 Transfer-Encoding Validation} 589 590 Per RFC 9112 Section 6.1: Transfer-Encoding is a list of transfer codings. 591 If "chunked" is present, it MUST be the final encoding. The encodings are 592 applied in order, so we must reject unknown encodings that appear before chunked. 593 594 Per RFC 9112 Section 6.1: A server MUST NOT send Transfer-Encoding in: 595 - A response to a HEAD request 596 - Any 1xx (Informational) response 597 - A 204 (No Content) response 598 - A 304 (Not Modified) response *) 599 600(** Validate that Transfer-Encoding is not present in responses that MUST NOT have it. 601 Per RFC 9112 Section 6.1: These responses must not include Transfer-Encoding. 602 If present, this is a protocol violation but we log and continue. 603 @return true if Transfer-Encoding is present (violation), false otherwise *) 604let validate_no_transfer_encoding ~method_ ~status transfer_encoding = 605 let should_not_have_te = 606 match method_, status with 607 | Some `HEAD, _ -> true (* HEAD responses must not have TE *) 608 | _, s when s >= 100 && s < 200 -> true (* 1xx responses *) 609 | _, 204 -> true (* 204 No Content *) 610 | _, 304 -> true (* 304 Not Modified *) 611 | _ -> false 612 in 613 match transfer_encoding, should_not_have_te with 614 | Some te, true -> 615 Log.warn (fun m -> m "RFC 9112 violation: Transfer-Encoding '%s' in %s response \ 616 (status %d) - ignoring per spec" te 617 (match method_ with Some `HEAD -> "HEAD" | _ -> "bodiless") 618 status); 619 true 620 | _ -> false 621 622(** Parse Transfer-Encoding header into list of codings. 623 Returns list in order (first coding is outermost) *) 624let parse_transfer_encoding = function 625 | None -> [] 626 | Some te -> 627 String.split_on_char ',' te 628 |> List.map (fun s -> String.trim (String.lowercase_ascii s)) 629 |> List.filter (fun s -> s <> "") 630 631(** Validate Transfer-Encoding per RFC 9112 Section 6.1. 632 Returns [`Chunked] if chunked encoding should be used, [`None] if no body, 633 or raises an error for invalid encodings. 634 @raise Error.t if chunked is not final or unknown encodings precede chunked *) 635let validate_transfer_encoding encodings = 636 match encodings with 637 | [] -> `None 638 | codings -> 639 (* Find position of chunked if present *) 640 let chunked_idx = 641 List.mapi (fun i c -> (i, c)) codings 642 |> List.find_map (fun (i, c) -> if c = "chunked" then Some i else None) 643 in 644 match chunked_idx with 645 | None -> 646 (* No chunked encoding - check if we support any of these *) 647 Log.warn (fun m -> m "Transfer-Encoding without chunked: %s (not supported)" 648 (String.concat ", " codings)); 649 `Unsupported codings 650 | Some idx -> 651 (* Per RFC 9112 Section 6.1: chunked MUST be the final transfer coding *) 652 if idx <> List.length codings - 1 then begin 653 Log.err (fun m -> m "Transfer-Encoding: chunked is not final (RFC 9112 violation)"); 654 raise (Error.err (Error.Invalid_request { 655 reason = "Transfer-Encoding: chunked must be the final encoding" 656 })) 657 end; 658 (* Check encodings before chunked - we only support identity *) 659 let before_chunked = List.filteri (fun i _ -> i < idx) codings in 660 List.iter (fun enc -> 661 match enc with 662 | "identity" -> () (* identity is a no-op *) 663 | other -> 664 Log.warn (fun m -> m "Unsupported encoding '%s' before chunked (treating as identity)" other) 665 ) before_chunked; 666 `Chunked 667 668(** Helper to check if transfer-encoding indicates chunked *) 669let is_chunked_encoding transfer_encoding = 670 match validate_transfer_encoding (parse_transfer_encoding transfer_encoding) with 671 | `Chunked -> true 672 | `None | `Unsupported _ -> false 673 674(** Safely parse Content-Length header, returning None for invalid values. 675 Per RFC 9110 Section 8.6: Content-Length must be >= 0. 676 @raise Error.t if Content-Length is invalid or negative. *) 677let parse_content_length = function 678 | None -> None 679 | Some s -> 680 try 681 let len = Int64.of_string s in 682 (* Per RFC 9110 Section 8.6: Content-Length MUST be >= 0 *) 683 if len < 0L then begin 684 Log.warn (fun m -> m "Negative Content-Length rejected: %s" s); 685 raise (Error.invalid_requestf "Content-Length cannot be negative: %s" s) 686 end; 687 Some len 688 with Failure _ -> 689 Log.warn (fun m -> m "Invalid Content-Length header value: %s" s); 690 raise (Error.invalid_requestf "Invalid Content-Length header: %s" s) 691 692(** Parse complete response (status + headers + body) to string. 693 Per {{:https://datatracker.ietf.org/doc/html/rfc9112#section-6}RFC 9112 Section 6}}. *) 694let response ~limits ?method_ r = 695 let version, status = status_line r in 696 let hdrs = headers ~limits r in 697 698 (* Per RFC 9112 Section 6.1: Validate Transfer-Encoding not present in bodiless responses *) 699 let transfer_encoding = Headers.get `Transfer_encoding hdrs in 700 let _ = validate_no_transfer_encoding ~method_ ~status transfer_encoding in 701 702 (* Per RFC 9110 Section 6.4.1: Certain responses MUST NOT have a body *) 703 if response_has_no_body ~method_ ~status then ( 704 Log.debug (fun m -> m "Response has no body (HEAD, CONNECT 2xx, 1xx, 204, or 304)"); 705 (version, status, hdrs, "") 706 ) else 707 (* Determine how to read body based on headers. 708 Per RFC 9112 Section 6.3: Transfer-Encoding takes precedence over Content-Length *) 709 let content_length = parse_content_length (Headers.get `Content_length hdrs) in 710 let body = match is_chunked_encoding transfer_encoding, content_length with 711 | true, Some _ -> 712 (* Both headers present - potential HTTP request smuggling indicator *) 713 Log.warn (fun m -> m "Both Transfer-Encoding and Content-Length present - \ 714 ignoring Content-Length per RFC 9112 (potential attack indicator)"); 715 chunked_body ~limits r 716 | true, None -> 717 Log.debug (fun m -> m "Reading chunked response body"); 718 chunked_body ~limits r 719 | false, Some len -> 720 Log.debug (fun m -> m "Reading fixed-length response body (%Ld bytes)" len); 721 fixed_body ~limits ~length:len r 722 | false, None -> 723 (match transfer_encoding with 724 | Some te -> 725 Log.warn (fun m -> m "Unsupported transfer-encoding: %s, assuming no body" te); 726 "" 727 | None -> 728 (* RFC 9112 Section 6.3 item 8: If no Transfer-Encoding or Content-Length, 729 the body length is determined by reading until connection close. 730 This is common for HTTP/1.0 responses. *) 731 Log.debug (fun m -> m "No length indicators, reading until connection close"); 732 close_delimited_body ~limits r) 733 in 734 (version, status, hdrs, body) 735 736(** Response with streaming body *) 737type stream_response = { 738 http_version : http_version; 739 status : int; 740 headers : Headers.t; 741 body : [ `String of string 742 | `Stream of Eio.Flow.source_ty Eio.Resource.t 743 | `None ] 744} 745 746let response_stream ~limits ?method_ r = 747 let (version, status) = status_line r in 748 let hdrs = headers ~limits r in 749 750 (* Per RFC 9112 Section 6.1: Validate Transfer-Encoding not present in bodiless responses *) 751 let transfer_encoding = Headers.get `Transfer_encoding hdrs in 752 let _ = validate_no_transfer_encoding ~method_ ~status transfer_encoding in 753 754 (* Determine body type *) 755 let content_length = parse_content_length (Headers.get `Content_length hdrs) in 756 757 (* Per RFC 9112 Section 6.3: When both Transfer-Encoding and Content-Length 758 are present, Transfer-Encoding takes precedence. The presence of both 759 headers is a potential HTTP request smuggling attack indicator. *) 760 let body = match is_chunked_encoding transfer_encoding, content_length with 761 | true, Some _ -> 762 (* Both headers present - log warning per RFC 9112 Section 6.3 *) 763 Log.warn (fun m -> m "Both Transfer-Encoding and Content-Length present - \ 764 ignoring Content-Length per RFC 9112 (potential attack indicator)"); 765 `Stream (chunked_body_stream ~limits r) 766 | true, None -> 767 Log.debug (fun m -> m "Creating chunked body stream"); 768 `Stream (chunked_body_stream ~limits r) 769 | false, Some len -> 770 Log.debug (fun m -> m "Creating fixed-length body stream (%Ld bytes)" len); 771 `Stream (fixed_body_stream ~limits ~length:len r) 772 | false, None -> 773 (match transfer_encoding with 774 | Some te -> 775 Log.warn (fun m -> m "Unsupported transfer-encoding: %s, assuming no body" te); 776 `None 777 | None -> 778 (* RFC 9112 Section 6.3 item 8: If no Transfer-Encoding or Content-Length, 779 the body length is determined by reading until connection close. *) 780 Log.debug (fun m -> m "Creating close-delimited body stream"); 781 `Stream (close_delimited_body_stream ~limits r)) 782 in 783 784 { http_version = version; status; headers = hdrs; body } 785 786(** {1 Convenience Functions} *) 787 788let of_flow ?initial_size ~max_size flow = 789 Read.of_flow ?initial_size ~max_size flow