diff --git a/src/Suave.Tests/Http2.fs b/src/Suave.Tests/Http2.fs index 890bca06..81c0e624 100644 --- a/src/Suave.Tests/Http2.fs +++ b/src/Suave.Tests/Http2.fs @@ -379,6 +379,48 @@ let hpackTests (_ : SuaveConfig) = let dec = newDynamicTableForDecoding 4096 4096 let decoded = Hpack.decodeHeader dec bytes Expect.equal decoded [ "x-test", "1" ] "lowercase literal name decodes intact" + + yield testCase "decodeHeaderBounded enforces the decoded list-size cap (HPACK bomb defence)" <| fun _ -> + // Defence against the "HTTP/2 bomb" amplification attack + // (https://blog.calif.io/p/codex-discovered-a-hidden-http2-bomb): + // a small wire payload can expand to a huge decoded header list via + // repeated indexed-header references. We populate the dynamic table + // with one entry whose value alone exceeds the budget and then send + // a single indexed reference to it (1 byte); the bounded decoder + // must abort with HpackHeaderListTooLargeException instead of + // returning the giant header. + // + // Wire format: + // 1) literal header w/ incremental indexing, new name (0x40) + // name = "x" (length 1) + // value = 200 × 'a' as a non-Huffman string (length-prefix 0x7F+73 = 0x7F,0x49) + // -> dynamic table entry 62: ("x", 200×'a') + // 2) indexed reference 0xBE (= 0x80 | 62) — replays that entry. + let value = Array.create 200 (byte 'a') + let lit = + [| yield 0x40uy // literal, incremental indexing, new name + yield 0x01uy // name length = 1 + yield byte 'x' + // value length 200 = 127 + 73 → 0x7F, 0x49 + yield 0x7Fuy + yield 0x49uy + yield! value |] + let bytes = Array.append lit [| 0xBEuy |] + let dec = newDynamicTableForDecoding 4096 4096 + // Cap is set well below 200 + name + 32 to force the trip. + Expect.throwsT + (fun () -> Hpack.decodeHeaderBounded dec (Some 64) bytes |> ignore) + "indexed reference into oversized dynamic-table entry must trip the bounded decoder" + + // Sanity: with a comfortable cap, the same input decodes normally. + let dec2 = newDynamicTableForDecoding 4096 4096 + let decoded = Hpack.decodeHeaderBounded dec2 (Some 4096) bytes + Expect.equal decoded.Length 2 "two headers decoded under a generous cap" + + // Sanity: passing `None` is equivalent to the legacy decoder. + let dec3 = newDynamicTableForDecoding 4096 4096 + let decoded' = Hpack.decodeHeaderBounded dec3 None bytes + Expect.equal decoded'.Length 2 "None cap matches legacy unbounded behaviour" ] [] diff --git a/src/Suave/Hpack.fs b/src/Suave/Hpack.fs index 93d4237e..bf8c368c 100644 --- a/src/Suave/Hpack.fs +++ b/src/Suave/Hpack.fs @@ -18,6 +18,20 @@ module Hpack = /// GOAWAY(COMPRESSION_ERROR). exception HpackHeaderNameUppercaseException of string + /// Raised by the HPACK decoder when the cumulative decoded header-list size + /// of a single header block exceeds the limit advertised via + /// SETTINGS_MAX_HEADER_LIST_SIZE (RFC 7540 §6.5.2) and computed per RFC 7541 + /// §4.1 (`name.Length + value.Length + 32` for every entry). This is the + /// primary defence against the "HPACK bomb" amplification attack where a + /// handful of bytes on the wire (e.g. repeated indexed-header references + /// into a populated dynamic table) expand to many megabytes of allocated + /// header strings on the server. The HTTP/2 layer catches this and tears + /// the connection down with GOAWAY(ENHANCE_YOUR_CALM); a stream-level + /// reset is not safe because the dynamic table may already be in a + /// partially-updated state by the time the limit trips. + /// The carried `int` is the decoded size at the point the limit tripped. + exception HpackHeaderListTooLargeException of int + type HeaderName = string type HeaderValue = string @@ -976,12 +990,36 @@ if I < 2^N - 1, encode I on N bits let singleton x = Builder (prepend x) - let decodeSimple (dyntbl : DynamicTable) (rbuf : MemoryStream) : HeaderList = + /// Decode a header block while enforcing the HPACK list-size budget. + /// + /// `maxListSize`: + /// * `None` — unbounded (legacy behaviour, used by some tests). + /// * `Some bytes` — abort with `HpackHeaderListTooLargeException` as soon + /// as the cumulative `name + value + 32` (per RFC 7541 + /// §4.1) of every header decoded so far exceeds `bytes`. + /// + /// The check is performed *during* decoding so that an attacker cannot + /// force the server to fully decode (and allocate) a multi-megabyte header + /// list before we notice. We still pay for the headers decoded up to the + /// trip point; the bound chosen by the caller (typically a few tens of + /// kilobytes) keeps that worst case finite. + let decodeSimpleBounded (dyntbl : DynamicTable) (maxListSize : int option) + (rbuf : MemoryStream) : HeaderList = let list = new List
() + let mutable cumulative = 0 + let check (name: string) (value: string) = + match maxListSize with + | None -> () + | Some cap -> + // RFC 7541 §4.1: size = name.Length + value.Length + 32 (octets). + cumulative <- cumulative + name.Length + value.Length + headerSizeMagicNumber + if cumulative > cap then + raise (HpackHeaderListTooLargeException cumulative) let rec go builder = if rbuf.Position <> rbuf.Length then let w = byte(rbuf.ReadByte()) let (token,headerValue) as tv = toTokenHeader dyntbl w rbuf + check (tokenFoldedKey token) headerValue let builder' = builder << tv go builder' else @@ -990,5 +1028,19 @@ if I < 2^N - 1, encode I on N bits kvs go empty + // Legacy unbounded variant kept for callers (including the in-tree test + // suite) that don't want a size cap. + let decodeSimple (dyntbl : DynamicTable) (rbuf : MemoryStream) : HeaderList = + decodeSimpleBounded dyntbl None rbuf + let decodeHeader (dyntbl : DynamicTable) (inp: byte array) : HeaderList = decodeHPACK dyntbl inp decodeSimple + + /// Like `decodeHeader` but caps the cumulative decoded header-list size at + /// `maxListSize` bytes (RFC 7541 §4.1 accounting). Passing `None` is + /// equivalent to `decodeHeader`. Used by the HTTP/2 layer to enforce + /// SETTINGS_MAX_HEADER_LIST_SIZE and defend against HPACK amplification + /// ("HTTP/2 bomb") attacks. + let decodeHeaderBounded (dyntbl : DynamicTable) (maxListSize : int option) + (inp: byte array) : HeaderList = + decodeHPACK dyntbl inp (fun dt rb -> decodeSimpleBounded dt maxListSize rb) diff --git a/src/Suave/Http2.fs b/src/Suave/Http2.fs index eb1d57f7..864dfbb1 100644 --- a/src/Suave/Http2.fs +++ b/src/Suave/Http2.fs @@ -847,6 +847,48 @@ module Http2 = /// Maximum legal flow-control window size per RFC 7540 §6.9.1. let maxFlowControlWindow = 0x7fffffff + // --------------------------------------------------------------------------- + // HPACK / header-block defence-in-depth limits + // + // The HTTP/2 "header bomb" attack (https://blog.calif.io/p/codex-discovered-a-hidden-http2-bomb) + // abuses HPACK's amplification: a handful of bytes on the wire — repeated + // indexed-header references into a populated dynamic table, or unbounded + // CONTINUATION chains — can expand into hundreds of megabytes of allocated + // header strings on the server. We mitigate this with two complementary + // caps: + // + // * `defaultMaxHeaderListSize` — RFC 7541 §4.1 size of the *decoded* + // header list. Advertised via + // SETTINGS_MAX_HEADER_LIST_SIZE so + // well-behaved peers stop sending + // oversized blocks; enforced by the + // HPACK decoder at decode time + // (HpackHeaderListTooLargeException). + // * `defaultMaxHeaderBlockReassemblyBytes` — hard cap on the *compressed* + // cumulative size of one + // HEADERS/CONTINUATION block. Stops + // an attacker from making us + // buffer-then-decode arbitrarily + // many CONTINUATION frames. + // + // Tripping either limit results in GOAWAY(ENHANCE_YOUR_CALM); a stream-level + // reset is unsafe because the HPACK dynamic table may already be in a + // partially-mutated state by the time the cap is hit. + // --------------------------------------------------------------------------- + + /// Default cap (in bytes, RFC 7541 §4.1 accounting) on the decoded size of + /// a single header list. Chosen to match Suave's HTTP/1.1 header-buffer + /// heuristics and the de-facto industry default (Tomcat / IIS ~32 KiB). + let defaultMaxHeaderListSize : int32 = 32768 + + /// Hard cap on the cumulative *compressed* bytes of one header block + /// (HEADERS + any CONTINUATION frames). HPACK can be denser than the + /// uncompressed form (the whole point of indexed representations) so this + /// is set to the decoded cap; combined with the per-block decoded check + /// it leaves no path for an unbounded attacker accumulation. + let defaultMaxHeaderBlockReassemblyBytes : int32 = defaultMaxHeaderListSize + + /// A mutable flow-control window. `available` is the number of DATA octets /// the holder may still send (for an outbound window) or receive (for an /// inbound window). @@ -1315,7 +1357,12 @@ module Http2 = // --------------------------------------------------------------------------- let mutable peerSettings = defaultSetting - let mutable localSettings = defaultSetting + // `localSettings` reflects what *we* advertise to the peer. We override + // the RFC default for SETTINGS_MAX_HEADER_LIST_SIZE so peers know to + // cap their header blocks, and so the in-tree HPACK decoder has an + // explicit budget to enforce (defence against the HPACK bomb attack). + let mutable localSettings = + { defaultSetting with maxHeaderBlockSize = Some defaultMaxHeaderListSize } let streams = new Dictionary() let mutable pendingReassembly : HeaderBlockReassembly option = None let connectionInboundWindow = newFlowControlWindow defaultSetting.initialWindowSize @@ -1620,13 +1667,20 @@ module Http2 = // HPACK-decode the block. A `HpackHeaderNameUppercaseException` is // mapped to a stream-level PROTOCOL_ERROR (RFC 7540 §8.1.2: a request // containing uppercase header field names MUST be treated as - // malformed). Any other decode failure is a connection-level + // malformed). A `HpackHeaderListTooLargeException` is mapped to a + // connection-level ENHANCE_YOUR_CALM (the dynamic table state is + // partially updated when we abort decode, so the connection cannot + // safely continue). Any other decode failure is a connection-level // COMPRESSION_ERROR (RFC 7540 §4.3). let decoded = - try Ok (decodeHeader x.decodeDynamicTable fragment) + try Ok (decodeHeaderBounded x.decodeDynamicTable + localSettings.maxHeaderBlockSize + fragment) with | HpackHeaderNameUppercaseException _ -> Result.Error (true, ProtocolError) + | HpackHeaderListTooLargeException _ -> + Result.Error (false, EnhanceYourCalm) | _ -> Result.Error (false, CompressionError) match decoded with @@ -1696,14 +1750,23 @@ module Http2 = | Some acc -> match payload with | Continuation fragment -> - match feedContinuation acc header fragment with - | NeedMore acc' -> - pendingReassembly <- Some acc' - | Complete (streamId, _, frag, endStream) -> + // Defence against unbounded CONTINUATION chains (HPACK bomb): + // refuse to buffer more compressed header bytes than the + // advertised cap. ENHANCE_YOUR_CALM (RFC 7540 §7) is the + // intended signal for "you're using too many resources". + if int64 acc.pending.Length + int64 fragment.Length + > int64 defaultMaxHeaderBlockReassemblyBytes then pendingReassembly <- None - do! x.completeHeaderBlock streamId frag endStream webPart - | ReassemblyAbort err -> - do! x.sendGoAwayAndStop err + do! x.sendGoAwayAndStop EnhanceYourCalm + else + match feedContinuation acc header fragment with + | NeedMore acc' -> + pendingReassembly <- Some acc' + | Complete (streamId, _, frag, endStream) -> + pendingReassembly <- None + do! x.completeHeaderBlock streamId frag endStream webPart + | ReassemblyAbort err -> + do! x.sendGoAwayAndStop err | _ -> do! x.sendGoAwayAndStop ProtocolError return () @@ -1717,15 +1780,20 @@ module Http2 = | Some p when p.streamIdentifier = header.streamIdentifier -> do! x.resetStream header.streamIdentifier ProtocolError | _ -> - let endStream = testEndStream header.flags - if testEndHeaderFlag header.flags then - do! x.completeHeaderBlock header.streamIdentifier fragment endStream webPart + // Reject an initial HEADERS fragment that already exceeds the + // compressed-block cap (also a HPACK-bomb mitigation). + if fragment.Length > defaultMaxHeaderBlockReassemblyBytes then + do! x.sendGoAwayAndStop EnhanceYourCalm else - pendingReassembly <- - Some { streamId = header.streamIdentifier - isPushPromise = false - pending = fragment - endStream = endStream } + let endStream = testEndStream header.flags + if testEndHeaderFlag header.flags then + do! x.completeHeaderBlock header.streamIdentifier fragment endStream webPart + else + pendingReassembly <- + Some { streamId = header.streamIdentifier + isPushPromise = false + pending = fragment + endStream = endStream } | Continuation _ -> // Continuation outside of a header block is a PROTOCOL_ERROR. do! x.sendGoAwayAndStop ProtocolError @@ -1889,7 +1957,7 @@ module Http2 = return! SocketOp.abort (InputDataError (None, "Invalid HTTP/2 connection preface")) else let encInfo = { flags = 0uy; streamIdentifier = 0; padding = None } - do! x.write (encInfo, Settings(false, defaultSetting)) + do! x.write (encInfo, Settings(false, localSettings)) } /// Read frames in a loop, dispatching each to `handleFrame`, until either @@ -2051,7 +2119,7 @@ module Http2 = return! SocketOp.abort (InputDataError (None, "Invalid HTTP/2 connection preface")) else let encInfo = { flags = 0uy; streamIdentifier = 0; padding = None } - do! x.write (encInfo, Settings(false, defaultSetting)) + do! x.write (encInfo, Settings(false, localSettings)) } /// Top-level entry point used by the HTTP/2 prior-knowledge handler.