Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/Suave.Tests/Http2.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HpackHeaderListTooLargeException>
(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"
]

[<Tests>]
Expand Down
54 changes: 53 additions & 1 deletion src/Suave/Hpack.fs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@
/// 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
Expand Down Expand Up @@ -742,7 +756,7 @@
newName wbuf huff set0000 k v

let getRevIndex (dynbtl : DynamicTable) : RevIndex =
let (EncodeInfo (rev,_)) = dynbtl.codeInfo

Check warning on line 759 in src/Suave/Hpack.fs

View workflow job for this annotation

GitHub Actions / h2spec

Incomplete pattern matches on this expression. For example, the value 'DecodeInfo (_)' may indicate a case not covered by the pattern(s).
rev

let encodeTokenHeader (wbuf : MemoryStream) size (strategy: EncodeStrategy) (first: bool) (dyntbl:DynamicTable) (hl:TokenHeaderList) =
Expand Down Expand Up @@ -811,7 +825,7 @@
else decode' 0 (int w)

let isSuitableSize size (dyntbl : DynamicTable) =
let (DecodeInfo(_,limiref)) = dyntbl.codeInfo

Check warning on line 828 in src/Suave/Hpack.fs

View workflow job for this annotation

GitHub Actions / h2spec

Incomplete pattern matches on this expression. For example, the value 'EncodeInfo (_)' may indicate a case not covered by the pattern(s).
// RFC 7541 §6.3 — the new maximum size MUST be less than OR equal to the
// value advertised by the decoder via SETTINGS_HEADER_TABLE_SIZE. Using a
// strict `<` rejected the boundary value, which is exactly what an encoder
Expand Down Expand Up @@ -893,7 +907,7 @@
failwith "Header block truncated"

let huffmanDecoder (dyntbl : DynamicTable) =
let (DecodeInfo(dec,_)) = dyntbl.codeInfo

Check warning on line 910 in src/Suave/Hpack.fs

View workflow job for this annotation

GitHub Actions / h2spec

Incomplete pattern matches on this expression. For example, the value 'EncodeInfo (_)' may indicate a case not covered by the pattern(s).
dec

let headerStuff (dyntbl : DynamicTable) (rbuf:MemoryStream) =
Expand Down Expand Up @@ -976,12 +990,36 @@

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<Header>()
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
Expand All @@ -990,5 +1028,19 @@
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)
108 changes: 88 additions & 20 deletions src/Suave/Http2.fs
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,48 @@
/// 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).
Expand Down Expand Up @@ -1315,7 +1357,12 @@
// ---------------------------------------------------------------------------

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<int32, StreamData>()
let mutable pendingReassembly : HeaderBlockReassembly option = None
let connectionInboundWindow = newFlowControlWindow defaultSetting.initialWindowSize
Expand Down Expand Up @@ -1620,13 +1667,20 @@
// 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
Expand Down Expand Up @@ -1696,19 +1750,28 @@
| 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 ()
| None ->
match payload with

Check warning on line 1774 in src/Suave/Http2.fs

View workflow job for this annotation

GitHub Actions / h2spec

Incomplete pattern matches on this expression. For example, the value 'Settings (false, _)' may indicate a case not covered by the pattern(s). However, a pattern rule with a 'when' clause might successfully match this value.
| Headers (priorityOpt, fragment) ->
// RFC 7540 §5.3.1: a stream cannot depend on itself; this is a
// stream-level PROTOCOL_ERROR. Abort header processing entirely
Expand All @@ -1717,15 +1780,20 @@
| 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
Expand Down Expand Up @@ -1889,7 +1957,7 @@
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
Expand Down Expand Up @@ -2051,7 +2119,7 @@
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.
Expand Down
Loading