My aggregated monorepo of OCaml code, automaintained
at doc-fixes 386 lines 13 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6let src = Logs.Src.create "requests.headers" ~doc:"HTTP Headers" 7module Log = (val Logs.src_log src : Logs.LOG) 8 9(* Use a map with lowercase keys for case-insensitive lookup *) 10module StringMap = Map.Make(String) 11 12(** The internal representation stores: (canonical_name, values) *) 13type t = (string * string list) StringMap.t 14 15let empty = StringMap.empty 16 17(** {1 Header Injection Prevention} 18 19 Per Recommendation #3: Validate that header names and values do not contain 20 newlines (CR/LF) which could enable HTTP request smuggling attacks. 21 22 Note: We use Invalid_argument here to avoid a dependency cycle with Error module. 23 The error will be caught and wrapped appropriately by higher-level code. *) 24 25exception Invalid_header of { name: string; reason: string } 26 27(** {1 Basic Auth Credential Validation} 28 29 Per RFC 7617 Section 2: 30 - Username must not contain a colon character 31 - Neither username nor password may contain control characters (0x00-0x1F, 0x7F) *) 32 33exception Invalid_basic_auth of { reason: string } 34 35let contains_control_chars s = 36 String.exists (fun c -> 37 let code = Char.code c in 38 code <= 0x1F || code = 0x7F 39 ) s 40 41let validate_basic_auth_credentials ~username ~password = 42 (* RFC 7617 Section 2: "a user-id containing a colon character is invalid" *) 43 if String.contains username ':' then 44 raise (Invalid_basic_auth { 45 reason = "Username contains colon character (RFC 7617 Section 2)" 46 }); 47 (* RFC 7617 Section 2: "The user-id and password MUST NOT contain any control characters" *) 48 if contains_control_chars username then 49 raise (Invalid_basic_auth { 50 reason = "Username contains control characters (RFC 7617 Section 2)" 51 }); 52 if contains_control_chars password then 53 raise (Invalid_basic_auth { 54 reason = "Password contains control characters (RFC 7617 Section 2)" 55 }) 56 57let validate_header_name_str name = 58 if String.contains name '\r' || String.contains name '\n' then 59 raise (Invalid_header { 60 name; 61 reason = "Header name contains CR/LF characters (potential HTTP smuggling)" 62 }) 63 64let validate_header_value name value = 65 if String.contains value '\r' || String.contains value '\n' then 66 raise (Invalid_header { 67 name; 68 reason = "Header value contains CR/LF characters (potential HTTP smuggling)" 69 }) 70 71(** {1 Core Operations with Typed Header Names} *) 72 73let add (name : Header_name.t) value t = 74 let canonical = Header_name.to_string name in 75 let nkey = Header_name.to_lowercase_string name in 76 validate_header_value canonical value; 77 let existing = 78 match StringMap.find_opt nkey t with 79 | Some (_, values) -> values 80 | None -> [] 81 in 82 (* Append to maintain order, avoiding reversal on retrieval *) 83 StringMap.add nkey (canonical, existing @ [value]) t 84 85let set (name : Header_name.t) value t = 86 let canonical = Header_name.to_string name in 87 let nkey = Header_name.to_lowercase_string name in 88 validate_header_value canonical value; 89 StringMap.add nkey (canonical, [value]) t 90 91let get (name : Header_name.t) t = 92 let nkey = Header_name.to_lowercase_string name in 93 match StringMap.find_opt nkey t with 94 | Some (_, values) -> List.nth_opt values 0 95 | None -> None 96 97let get_all (name : Header_name.t) t = 98 let nkey = Header_name.to_lowercase_string name in 99 match StringMap.find_opt nkey t with 100 | Some (_, values) -> values 101 | None -> [] 102 103let remove (name : Header_name.t) t = 104 let nkey = Header_name.to_lowercase_string name in 105 StringMap.remove nkey t 106 107let mem (name : Header_name.t) t = 108 let nkey = Header_name.to_lowercase_string name in 109 StringMap.mem nkey t 110 111(** {1 String-based Operations for Wire Format Compatibility} 112 113 These are used internally when parsing HTTP messages from the wire, 114 where header names come as strings. *) 115 116let add_string key value t = 117 validate_header_name_str key; 118 validate_header_value key value; 119 let nkey = String.lowercase_ascii key in 120 let existing = 121 match StringMap.find_opt nkey t with 122 | Some (_, values) -> values 123 | None -> [] 124 in 125 StringMap.add nkey (key, existing @ [value]) t 126 127let set_string key value t = 128 validate_header_name_str key; 129 validate_header_value key value; 130 let nkey = String.lowercase_ascii key in 131 StringMap.add nkey (key, [value]) t 132 133let get_string key t = 134 let nkey = String.lowercase_ascii key in 135 match StringMap.find_opt nkey t with 136 | Some (_, values) -> List.nth_opt values 0 137 | None -> None 138 139let get_all_string key t = 140 let nkey = String.lowercase_ascii key in 141 match StringMap.find_opt nkey t with 142 | Some (_, values) -> values 143 | None -> [] 144 145let remove_string key t = 146 let nkey = String.lowercase_ascii key in 147 StringMap.remove nkey t 148 149let mem_string key t = 150 let nkey = String.lowercase_ascii key in 151 StringMap.mem nkey t 152 153(** {1 Conversion} *) 154 155let of_list lst = 156 List.fold_left (fun acc (k, v) -> add_string k v acc) empty lst 157 158let to_list t = 159 StringMap.fold (fun _ (orig_key, values) acc -> 160 (* Values are already in correct order, build list in reverse then reverse at end *) 161 List.fold_left (fun acc v -> (orig_key, v) :: acc) acc values 162 ) t [] 163 |> List.rev 164 165let merge t1 t2 = 166 StringMap.union (fun _ _ v2 -> Some v2) t1 t2 167 168(** {1 Common Header Builders} *) 169 170let content_type mime t = 171 set `Content_type (Mime.to_string mime) t 172 173let content_length len t = 174 set `Content_length (Int64.to_string len) t 175 176let accept mime t = 177 set `Accept (Mime.to_string mime) t 178 179let accept_language lang t = 180 set `Accept_language lang t 181 182let authorization value t = 183 set `Authorization value t 184 185let bearer token t = 186 set `Authorization (Printf.sprintf "Bearer %s" token) t 187 188let basic ~username ~password t = 189 validate_basic_auth_credentials ~username ~password; 190 let credentials = Printf.sprintf "%s:%s" username password in 191 let encoded = Base64.encode_exn credentials in 192 set `Authorization (Printf.sprintf "Basic %s" encoded) t 193 194let user_agent ua t = 195 set `User_agent ua t 196 197let host h t = 198 set `Host h t 199 200let cookie name value t = 201 add `Cookie (Printf.sprintf "%s=%s" name value) t 202 203let range ~start ?end_ () t = 204 let range_value = match end_ with 205 | None -> Printf.sprintf "bytes=%Ld-" start 206 | Some e -> Printf.sprintf "bytes=%Ld-%Ld" start e 207 in 208 set `Range range_value t 209 210(** {1 HTTP 100-Continue Support} 211 212 Per Recommendation #7: Expect: 100-continue protocol for large uploads. 213 RFC 9110 Section 10.1.1 (Expect) *) 214 215let expect value t = 216 set `Expect value t 217 218let expect_100_continue t = 219 set `Expect "100-continue" t 220 221(** {1 TE Header Support} 222 223 Per RFC 9110 Section 10.1.4: The TE header indicates what transfer codings 224 the client is willing to accept in the response, and whether the client is 225 willing to accept trailer fields in a chunked transfer coding. *) 226 227let te value t = 228 set `Te value t 229 230let te_trailers t = 231 set `Te "trailers" t 232 233(** {1 Cache Control Headers} 234 235 Per Recommendation #17 and #19: Response caching and conditional requests. 236 RFC 9111 (HTTP Caching), RFC 9110 Section 8.8.2-8.8.3 (Last-Modified, ETag) *) 237 238let if_none_match etag t = 239 set `If_none_match etag t 240 241let if_match etag t = 242 set `If_match etag t 243 244let if_modified_since date t = 245 set `If_modified_since date t 246 247let if_unmodified_since date t = 248 set `If_unmodified_since date t 249 250(** Format a Ptime.t as an HTTP-date (RFC 9110 Section 5.6.7) *) 251let http_date_of_ptime time = 252 (* HTTP-date format: "Sun, 06 Nov 1994 08:49:37 GMT" *) 253 let (year, month, day), ((hour, min, sec), _tz_offset) = Ptime.to_date_time time in 254 let weekday = match Ptime.weekday time with 255 | `Sun -> "Sun" | `Mon -> "Mon" | `Tue -> "Tue" | `Wed -> "Wed" 256 | `Thu -> "Thu" | `Fri -> "Fri" | `Sat -> "Sat" 257 in 258 let month_name = [| ""; "Jan"; "Feb"; "Mar"; "Apr"; "May"; "Jun"; 259 "Jul"; "Aug"; "Sep"; "Oct"; "Nov"; "Dec" |].(month) in 260 Printf.sprintf "%s, %02d %s %04d %02d:%02d:%02d GMT" 261 weekday day month_name year hour min sec 262 263let if_modified_since_ptime time t = 264 if_modified_since (http_date_of_ptime time) t 265 266let if_unmodified_since_ptime time t = 267 if_unmodified_since (http_date_of_ptime time) t 268 269let cache_control directives t = 270 set `Cache_control directives t 271 272(** Build Cache-Control header from common directive components. 273 For max_stale: [None] = not present, [Some None] = any staleness, [Some (Some n)] = n seconds *) 274let cache_control_directives 275 : ?max_age:int -> 276 ?max_stale:int option option -> 277 ?min_fresh:int -> 278 ?no_cache:bool -> 279 ?no_store:bool -> 280 ?no_transform:bool -> 281 ?only_if_cached:bool -> 282 unit -> t -> t 283 = fun 284 ?max_age 285 ?max_stale 286 ?min_fresh 287 ?(no_cache = false) 288 ?(no_store = false) 289 ?(no_transform = false) 290 ?(only_if_cached = false) 291 () t -> 292 let directives = [] in 293 let directives = match max_age with 294 | Some age -> Printf.sprintf "max-age=%d" age :: directives 295 | None -> directives 296 in 297 let directives = match max_stale with 298 | Some (Some None) -> "max-stale" :: directives 299 | Some (Some (Some secs)) -> Printf.sprintf "max-stale=%d" secs :: directives 300 | Some None | None -> directives 301 in 302 let directives = match min_fresh with 303 | Some secs -> Printf.sprintf "min-fresh=%d" secs :: directives 304 | None -> directives 305 in 306 let directives = if no_cache then "no-cache" :: directives else directives in 307 let directives = if no_store then "no-store" :: directives else directives in 308 let directives = if no_transform then "no-transform" :: directives else directives in 309 let directives = if only_if_cached then "only-if-cached" :: directives else directives in 310 match directives with 311 | [] -> t 312 | _ -> set `Cache_control (String.concat ", " (List.rev directives)) t 313 314let etag value t = 315 set `Etag value t 316 317let last_modified date t = 318 set `Last_modified date t 319 320let last_modified_ptime time t = 321 last_modified (http_date_of_ptime time) t 322 323(* Additional helper for getting multiple header values *) 324let get_multi name t = get_all name t 325 326(** {1 Connection Header Handling} 327 328 Per RFC 9110 Section 7.6.1: The Connection header field lists hop-by-hop 329 header fields that MUST be removed before forwarding the message. *) 330 331(** Parse Connection header value into list of header names. 332 The Connection header lists additional hop-by-hop headers. *) 333let parse_connection_header t = 334 match get `Connection t with 335 | None -> [] 336 | Some value -> 337 String.split_on_char ',' value 338 |> List.map (fun s -> Header_name.of_string (String.trim s)) 339 |> List.filter (fun n -> not (Header_name.equal n (`Other ""))) 340 341(** Get all hop-by-hop headers from a response. 342 Returns the union of default hop-by-hop headers and any headers 343 listed in the Connection header. *) 344let get_hop_by_hop_headers t = 345 let connection_headers = parse_connection_header t in 346 Header_name.hop_by_hop_headers @ connection_headers 347 |> List.sort_uniq Header_name.compare 348 349(** Remove hop-by-hop headers from a header collection. 350 This should be called before caching or forwarding a response. 351 Per RFC 9110 Section 7.6.1. *) 352let remove_hop_by_hop t = 353 let hop_by_hop = get_hop_by_hop_headers t in 354 List.fold_left (fun headers name -> remove name headers) t hop_by_hop 355 356(** Check if a response indicates the connection should be closed. 357 Returns true if Connection: close is present. *) 358let connection_close t = 359 match get `Connection t with 360 | Some value -> 361 String.split_on_char ',' value 362 |> List.exists (fun s -> String.trim (String.lowercase_ascii s) = "close") 363 | None -> false 364 365(** Check if a response indicates the connection should be kept alive. 366 Returns true if Connection: keep-alive is present (HTTP/1.0 behavior). *) 367let connection_keep_alive t = 368 match get `Connection t with 369 | Some value -> 370 String.split_on_char ',' value 371 |> List.exists (fun s -> String.trim (String.lowercase_ascii s) = "keep-alive") 372 | None -> false 373 374(* Pretty printer for headers *) 375let pp ppf t = 376 Format.fprintf ppf "@[<v>Headers:@,"; 377 let headers = to_list t in 378 List.iter (fun (k, v) -> 379 Format.fprintf ppf " %s: %s@," k v 380 ) headers; 381 Format.fprintf ppf "@]" 382 383let pp_brief ppf t = 384 let headers = to_list t in 385 let count = List.length headers in 386 Format.fprintf ppf "Headers(%d entries)" count