From c5fca67313f5184e98214e7af2b4a553e66a9b3d Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Fri, 5 Jun 2026 22:10:02 -0400 Subject: [PATCH] net.http: add HPACK header compression (RFC 7541) for HTTP/2 (#27353) * net.http: add HPACK header compression (RFC 7541) for HTTP/2 Add a self-contained HPACK encoder and decoder, the first building block for HTTP/2 support. This does not touch any existing net.http code paths; it only adds new files in the http module. Included: - Variable-length integer codec (Section 5.1), with overflow rejection. - Static Huffman codec (Section 5.2 / Appendix B), with rejection of an explicit EOS symbol and of invalid (too long / non-all-ones) padding. The code table is generated from the canonical RFC table. - The 61-entry static table (Appendix A). - A size-bounded dynamic table with FIFO eviction and dynamic size updates (Sections 2.3.2, 4). - H2HpackDecoder: handles all field representations (indexed, literal with incremental indexing, without indexing, never indexed) and dynamic table size updates, with the ordering and bounds checks required by Section 6. - H2HpackEncoder: emits indexed representations for static-table hits and literal (never-indexed for sensitive headers like cookie/authorization) otherwise. It does not mutate the dynamic table, keeping encoder/decoder state trivially consistent while remaining fully interoperable. Tests cover the RFC 7541 Appendix C worked examples (integer C.1, all four representations C.2, the request sequences C.3 without and C.4 with Huffman, verified byte-for-byte against the spec), Huffman round-trips, dynamic table eviction, encode/decode round-trips, and adversarial inputs (integer overflow, EOS in a Huffman string, bad padding, index 0, out-of-range index, and a dynamic table size update placed after a header field or over the limit). Passes under -W -cstrict -cc clang. Co-Authored-By: Claude Opus 4.8 * net.http: harden HPACK decoder against integer truncation Validate HPACK indexes and string lengths in u64 space before narrowing to int, so a malicious peer cannot use an oversized value to truncate past a bounds check: - lookup: compute the dynamic index as u64 and reject it when it exceeds the dynamic table length, before casting. Previously an index of (2^32 + N) truncated to N could resolve onto a valid dynamic entry and return the wrong header instead of an out-of-range error. - read_string: compare the string length against the remaining buffer in u64 space. Previously an oversized length truncated to a small/negative int could bypass the bounds check and cause an invalid slice (panic) or misdecode. Adds regression tests that build both oversized values (2^32 + n) and assert a clean decode error. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Richard Wheeler Co-authored-by: Claude Opus 4.8 --- vlib/net/http/h2_hpack.v | 329 +++++++++++++++ vlib/net/http/h2_hpack_huffman.v | 85 ++++ vlib/net/http/h2_hpack_huffman_table.v | 528 +++++++++++++++++++++++++ vlib/net/http/h2_hpack_static.v | 73 ++++ vlib/net/http/h2_hpack_test.v | 316 +++++++++++++++ 5 files changed, 1331 insertions(+) create mode 100644 vlib/net/http/h2_hpack.v create mode 100644 vlib/net/http/h2_hpack_huffman.v create mode 100644 vlib/net/http/h2_hpack_huffman_table.v create mode 100644 vlib/net/http/h2_hpack_static.v create mode 100644 vlib/net/http/h2_hpack_test.v diff --git a/vlib/net/http/h2_hpack.v b/vlib/net/http/h2_hpack.v new file mode 100644 index 000000000..102a5b81e --- /dev/null +++ b/vlib/net/http/h2_hpack.v @@ -0,0 +1,329 @@ +// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module http + +// This file implements HPACK header compression (RFC 7541), used by HTTP/2. +// It is self-contained: it only depends on the static and Huffman tables in +// the sibling h2_hpack_*.v files, and does not touch the rest of net.http. + +// h2_hpack_default_table_size is the default maximum size of the HPACK +// dynamic table, in bytes (RFC 7541 Section 4.2, and the default value of +// SETTINGS_HEADER_TABLE_SIZE). +pub const h2_hpack_default_table_size = 4096 + +// H2HeaderField is a single HTTP/2 header field (a name/value pair). +// Field names are expected to be lowercase, as required by RFC 7540. +pub struct H2HeaderField { +pub: + name string + value string +} + +// H2DynEntry is one entry in the HPACK dynamic table. +struct H2DynEntry { + name string + value string + size int // name.len + value.len + 32 (RFC 7541 Section 4.1) +} + +// H2DynTable is the HPACK dynamic table: a size-bounded FIFO of header fields. +// entries[0] is always the most recently added entry, which corresponds to the +// lowest dynamic index in the HPACK index space. +struct H2DynTable { +mut: + entries []H2DynEntry + cur_size int + max_size int = h2_hpack_default_table_size +} + +// add inserts a new entry at the front of the table, evicting the oldest +// entries until it fits. Per RFC 7541 Section 4.4, an entry larger than the +// whole table empties it and is not added. +fn (mut t H2DynTable) add(name string, value string) { + sz := name.len + value.len + 32 + for t.entries.len > 0 && t.cur_size + sz > t.max_size { + t.cur_size -= t.entries.last().size + t.entries.delete_last() + } + if sz > t.max_size { + return + } + t.entries.insert(0, H2DynEntry{ + name: name + value: value + size: sz + }) + t.cur_size += sz +} + +// set_max_size updates the maximum table size, evicting oldest entries as +// needed to respect the new limit. +fn (mut t H2DynTable) set_max_size(n int) { + t.max_size = n + for t.entries.len > 0 && t.cur_size > t.max_size { + t.cur_size -= t.entries.last().size + t.entries.delete_last() + } +} + +// get returns the dynamic-table entry at 1-based dynamic index `i` +// (i.e. i==1 is the newest entry), or none if out of range. +fn (t &H2DynTable) get(i int) ?H2HeaderField { + if i < 1 || i > t.entries.len { + return none + } + e := t.entries[i - 1] + return H2HeaderField{ + name: e.name + value: e.value + } +} + +// H2HpackReader is a cursor over an HPACK header block being decoded. +struct H2HpackReader { + buf []u8 +mut: + pos int +} + +// read_int reads an HPACK variable-length integer with a prefix of +// `prefix_bits` bits at the current position, advancing past it +// (RFC 7541 Section 5.1). +fn (mut r H2HpackReader) read_int(prefix_bits int) !u64 { + if r.pos >= r.buf.len { + return error('hpack: integer truncated') + } + max_prefix := u64((1 << prefix_bits) - 1) + mut value := u64(r.buf[r.pos]) & max_prefix + r.pos++ + if value < max_prefix { + return value + } + mut m := 0 + for { + if r.pos >= r.buf.len { + return error('hpack: integer continuation truncated') + } + b := r.buf[r.pos] + r.pos++ + value += u64(b & 0x7f) << m + m += 7 + // A 64-bit value never needs more than 9 continuation bytes; reject + // pathological inputs early (RFC 7541 Section 5.1 security note). + if m > 63 { + return error('hpack: integer overflow') + } + if b & 0x80 == 0 { + break + } + } + return value +} + +// read_string reads an HPACK string literal at the current position, +// advancing past it and decoding Huffman coding when the H bit is set. +fn (mut r H2HpackReader) read_string() !string { + if r.pos >= r.buf.len { + return error('hpack: string truncated') + } + huffman := (r.buf[r.pos] & 0x80) != 0 + length := r.read_int(7)! + // Compare in u64 space, before narrowing to int, so an oversized length + // from a malicious peer cannot truncate past this bounds check. + if length > u64(r.buf.len - r.pos) { + return error('hpack: string length exceeds buffer') + } + n := int(length) + raw := r.buf[r.pos..r.pos + n] + r.pos += n + if huffman { + return h2_huffman_decode(raw)!.bytestr() + } + return raw.bytestr() +} + +// h2_hpack_write_int appends an HPACK variable-length integer to `out`, using +// a prefix of `prefix_bits` bits whose high bits are set from `high_bits`. +fn h2_hpack_write_int(mut out []u8, value u64, prefix_bits int, high_bits u8) { + max_prefix := u64((1 << prefix_bits) - 1) + if value < max_prefix { + out << high_bits | u8(value) + return + } + out << high_bits | u8(max_prefix) + mut v := value - max_prefix + for v >= 0x80 { + out << u8((v & 0x7f) | 0x80) + v >>= 7 + } + out << u8(v) +} + +// h2_encode_string appends an HPACK string literal to `out`, choosing Huffman +// coding when it is shorter than the raw bytes (RFC 7541 Section 5.2). +fn h2_encode_string(mut out []u8, s string) { + raw := s.bytes() + huff := h2_huffman_encode(raw) + if huff.len < raw.len { + h2_hpack_write_int(mut out, u64(huff.len), 7, 0x80) // H = 1 + out << huff + } else { + h2_hpack_write_int(mut out, u64(raw.len), 7, 0x00) // H = 0 + out << raw + } +} + +// h2_is_sensitive reports whether a header should never be added to the HPACK +// dynamic table (encoded as "never indexed"), to avoid CRIME-style attacks. +fn h2_is_sensitive(name string) bool { + return name == 'cookie' || name == 'authorization' || name == 'proxy-authorization' +} + +// h2_hpack_find_static searches the static table for `name`/`value`. +// It returns the 1-based HPACK index and whether the value also matched. +// The index is 0 when the name is not present at all. +fn h2_hpack_find_static(name string, value string) (int, bool) { + mut name_idx := 0 + for i, f in h2_hpack_static_table { + if f.name == name { + if f.value == value { + return i + 1, true + } + if name_idx == 0 { + name_idx = i + 1 + } + } + } + return name_idx, false +} + +// H2HpackEncoder encodes header field lists into HPACK header blocks. +// This encoder never adds entries to the dynamic table: it uses indexed +// representations for static-table hits and literal-without-indexing (or +// never-indexed, for sensitive headers) otherwise. That keeps encoder and +// decoder state trivially in sync while remaining fully interoperable. +pub struct H2HpackEncoder { +pub mut: + dyn_table H2DynTable +} + +// encode returns the HPACK header block for `fields`. +pub fn (mut e H2HpackEncoder) encode(fields []H2HeaderField) []u8 { + mut out := []u8{} + for f in fields { + e.encode_field(mut out, f) + } + return out +} + +fn (mut e H2HpackEncoder) encode_field(mut out []u8, f H2HeaderField) { + sensitive := h2_is_sensitive(f.name) + idx, exact := h2_hpack_find_static(f.name, f.value) + if exact && !sensitive { + // Indexed Header Field (RFC 7541 Section 6.1). + h2_hpack_write_int(mut out, u64(idx), 7, 0x80) + return + } + // Literal Header Field without Indexing (6.2.2) or Never Indexed (6.2.3). + pattern := if sensitive { u8(0x10) } else { u8(0x00) } + if idx != 0 { + h2_hpack_write_int(mut out, u64(idx), 4, pattern) + } else { + h2_hpack_write_int(mut out, 0, 4, pattern) + h2_encode_string(mut out, f.name) + } + h2_encode_string(mut out, f.value) +} + +// H2HpackDecoder decodes HPACK header blocks into header field lists, +// maintaining the dynamic table across calls on the same connection. +pub struct H2HpackDecoder { +pub mut: + dyn_table H2DynTable + max_dynamic_size int = h2_hpack_default_table_size // upper bound we advertised to the peer +} + +// lookup resolves a 1-based HPACK index against the static and dynamic tables. +fn (d &H2HpackDecoder) lookup(idx u64) !H2HeaderField { + if idx == 0 { + return error('hpack: index 0 is not valid') + } + if idx <= u64(h2_hpack_static_len) { + return h2_hpack_static_table[idx - 1] + } + // Validate the dynamic index in u64 space before narrowing to int, so an + // out-of-range index from a malicious peer cannot truncate onto a valid + // dynamic entry. + dyn_idx := idx - u64(h2_hpack_static_len) + if dyn_idx > u64(d.dyn_table.entries.len) { + return error('hpack: index ${idx} out of range') + } + return d.dyn_table.get(int(dyn_idx)) or { error('hpack: index ${idx} out of range') } +} + +fn (d &H2HpackDecoder) read_literal(mut r H2HpackReader, prefix_bits int) !H2HeaderField { + name_index := r.read_int(prefix_bits)! + name := if name_index == 0 { + r.read_string()! + } else { + d.lookup(name_index)!.name + } + value := r.read_string()! + return H2HeaderField{ + name: name + value: value + } +} + +// decode parses one HPACK header block and returns its header fields, +// updating the dynamic table as instructed by the block. +pub fn (mut d H2HpackDecoder) decode(block []u8) ![]H2HeaderField { + mut out := []H2HeaderField{} + mut r := H2HpackReader{ + buf: block + } + mut seen_field := false + for r.pos < block.len { + b := block[r.pos] + if b & 0x80 != 0 { + // Indexed Header Field (RFC 7541 Section 6.1). + idx := r.read_int(7)! + out << d.lookup(idx)! + seen_field = true + } else if b & 0x40 != 0 { + // Literal Header Field with Incremental Indexing (6.2.1). + f := d.read_literal(mut r, 6)! + d.dyn_table.add(f.name, f.value) + out << f + seen_field = true + } else if b & 0x20 != 0 { + // Dynamic Table Size Update (6.3). Must precede any header field. + if seen_field { + return error('hpack: dynamic table size update after a header field') + } + new_size := r.read_int(5)! + if int(new_size) > d.max_dynamic_size { + return error('hpack: dynamic table size update ${new_size} exceeds limit ${d.max_dynamic_size}') + } + d.dyn_table.set_max_size(int(new_size)) + } else { + // Literal without Indexing (6.2.2) or Never Indexed (6.2.3); + // neither updates the dynamic table. + f := d.read_literal(mut r, 4)! + out << f + seen_field = true + } + } + return out +} + +// set_max_dynamic_size updates the maximum dynamic-table size the decoder will +// accept (i.e. the SETTINGS_HEADER_TABLE_SIZE value advertised to the peer), +// shrinking the table immediately if needed. +pub fn (mut d H2HpackDecoder) set_max_dynamic_size(n int) { + d.max_dynamic_size = n + if d.dyn_table.max_size > n { + d.dyn_table.set_max_size(n) + } +} diff --git a/vlib/net/http/h2_hpack_huffman.v b/vlib/net/http/h2_hpack_huffman.v new file mode 100644 index 000000000..6557a0e0f --- /dev/null +++ b/vlib/net/http/h2_hpack_huffman.v @@ -0,0 +1,85 @@ +// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module http + +// h2_huffman_eos is the index of the EOS (end-of-string) symbol in the +// HPACK Huffman table (RFC 7541 Appendix B). +const h2_huffman_eos = 256 + +// h2_huffman_decode_map maps a (bit_length, code) pair, packed as +// `(bit_length << 32) | code`, to its symbol. It is built once at startup +// from the canonical code table and is read-only afterwards. +const h2_huffman_decode_map = build_h2_huffman_decode_map() + +fn build_h2_huffman_decode_map() map[u64]int { + mut m := map[u64]int{} + for sym in 0 .. h2_huffman_codes.len { + l := u64(h2_huffman_code_lens[sym]) + code := u64(h2_huffman_codes[sym]) + m[(l << 32) | code] = sym + } + return m +} + +// h2_huffman_encode returns the HPACK Huffman encoding of `input` +// (RFC 7541 Section 5.2). The final byte is padded with the most significant +// bits of the EOS code, i.e. all ones. +fn h2_huffman_encode(input []u8) []u8 { + mut out := []u8{cap: input.len} + mut acc := u64(0) + mut nbits := 0 + for b in input { + code := u64(h2_huffman_codes[b]) + clen := int(h2_huffman_code_lens[b]) + acc = (acc << clen) | code + nbits += clen + for nbits >= 8 { + nbits -= 8 + out << u8((acc >> nbits) & 0xff) + } + // Keep only the still-buffered low bits, so acc stays bounded. + acc = if nbits == 0 { u64(0) } else { acc & ((u64(1) << nbits) - 1) } + } + if nbits > 0 { + pad := 8 - nbits + out << u8(((acc << pad) | ((u64(1) << pad) - 1)) & 0xff) + } + return out +} + +// h2_huffman_decode reverses h2_huffman_encode. It returns an error for the +// invalid cases called out by RFC 7541 Section 5.2: an explicit EOS symbol, +// padding longer than 7 bits, or padding that is not all ones. +fn h2_huffman_decode(input []u8) ![]u8 { + mut out := []u8{cap: input.len + input.len / 2} + mut cur := u64(0) + mut cur_len := 0 + for b in input { + for bit := 7; bit >= 0; bit-- { + cur = (cur << 1) | u64((b >> u8(bit)) & 1) + cur_len++ + if cur_len > 30 { + return error('hpack: invalid huffman code (no symbol within 30 bits)') + } + if sym := h2_huffman_decode_map[(u64(cur_len) << 32) | cur] { + if sym == h2_huffman_eos { + return error('hpack: EOS symbol encountered in huffman string') + } + out << u8(sym) + cur = 0 + cur_len = 0 + } + } + } + if cur_len >= 8 { + return error('hpack: invalid huffman padding (more than 7 bits left)') + } + if cur_len > 0 { + mask := (u64(1) << cur_len) - 1 + if cur & mask != mask { + return error('hpack: invalid huffman padding (not all ones)') + } + } + return out +} diff --git a/vlib/net/http/h2_hpack_huffman_table.v b/vlib/net/http/h2_hpack_huffman_table.v new file mode 100644 index 000000000..4b9b72edd --- /dev/null +++ b/vlib/net/http/h2_hpack_huffman_table.v @@ -0,0 +1,528 @@ +// Code generated from RFC 7541 Appendix B (HPACK Huffman code). DO NOT EDIT. +// Source: HTTP/2 (RFC 7541) static Huffman table, 256 byte symbols + EOS (257). +module http + +// h2_huffman_codes holds the Huffman code for each symbol, right-aligned in a +// u32. Index 0..255 are byte values; index 256 is the EOS symbol. The matching +// bit length for each code is in h2_huffman_code_lens. +const h2_huffman_codes = [ + u32(0x1ff8), + 0x7fffd8, + 0xfffffe2, + 0xfffffe3, + 0xfffffe4, + 0xfffffe5, + 0xfffffe6, + 0xfffffe7, + 0xfffffe8, + 0xffffea, + 0x3ffffffc, + 0xfffffe9, + 0xfffffea, + 0x3ffffffd, + 0xfffffeb, + 0xfffffec, + 0xfffffed, + 0xfffffee, + 0xfffffef, + 0xffffff0, + 0xffffff1, + 0xffffff2, + 0x3ffffffe, + 0xffffff3, + 0xffffff4, + 0xffffff5, + 0xffffff6, + 0xffffff7, + 0xffffff8, + 0xffffff9, + 0xffffffa, + 0xffffffb, + 0x14, + 0x3f8, + 0x3f9, + 0xffa, + 0x1ff9, + 0x15, + 0xf8, + 0x7fa, + 0x3fa, + 0x3fb, + 0xf9, + 0x7fb, + 0xfa, + 0x16, + 0x17, + 0x18, + 0x0, + 0x1, + 0x2, + 0x19, + 0x1a, + 0x1b, + 0x1c, + 0x1d, + 0x1e, + 0x1f, + 0x5c, + 0xfb, + 0x7ffc, + 0x20, + 0xffb, + 0x3fc, + 0x1ffa, + 0x21, + 0x5d, + 0x5e, + 0x5f, + 0x60, + 0x61, + 0x62, + 0x63, + 0x64, + 0x65, + 0x66, + 0x67, + 0x68, + 0x69, + 0x6a, + 0x6b, + 0x6c, + 0x6d, + 0x6e, + 0x6f, + 0x70, + 0x71, + 0x72, + 0xfc, + 0x73, + 0xfd, + 0x1ffb, + 0x7fff0, + 0x1ffc, + 0x3ffc, + 0x22, + 0x7ffd, + 0x3, + 0x23, + 0x4, + 0x24, + 0x5, + 0x25, + 0x26, + 0x27, + 0x6, + 0x74, + 0x75, + 0x28, + 0x29, + 0x2a, + 0x7, + 0x2b, + 0x76, + 0x2c, + 0x8, + 0x9, + 0x2d, + 0x77, + 0x78, + 0x79, + 0x7a, + 0x7b, + 0x7ffe, + 0x7fc, + 0x3ffd, + 0x1ffd, + 0xffffffc, + 0xfffe6, + 0x3fffd2, + 0xfffe7, + 0xfffe8, + 0x3fffd3, + 0x3fffd4, + 0x3fffd5, + 0x7fffd9, + 0x3fffd6, + 0x7fffda, + 0x7fffdb, + 0x7fffdc, + 0x7fffdd, + 0x7fffde, + 0xffffeb, + 0x7fffdf, + 0xffffec, + 0xffffed, + 0x3fffd7, + 0x7fffe0, + 0xffffee, + 0x7fffe1, + 0x7fffe2, + 0x7fffe3, + 0x7fffe4, + 0x1fffdc, + 0x3fffd8, + 0x7fffe5, + 0x3fffd9, + 0x7fffe6, + 0x7fffe7, + 0xffffef, + 0x3fffda, + 0x1fffdd, + 0xfffe9, + 0x3fffdb, + 0x3fffdc, + 0x7fffe8, + 0x7fffe9, + 0x1fffde, + 0x7fffea, + 0x3fffdd, + 0x3fffde, + 0xfffff0, + 0x1fffdf, + 0x3fffdf, + 0x7fffeb, + 0x7fffec, + 0x1fffe0, + 0x1fffe1, + 0x3fffe0, + 0x1fffe2, + 0x7fffed, + 0x3fffe1, + 0x7fffee, + 0x7fffef, + 0xfffea, + 0x3fffe2, + 0x3fffe3, + 0x3fffe4, + 0x7ffff0, + 0x3fffe5, + 0x3fffe6, + 0x7ffff1, + 0x3ffffe0, + 0x3ffffe1, + 0xfffeb, + 0x7fff1, + 0x3fffe7, + 0x7ffff2, + 0x3fffe8, + 0x1ffffec, + 0x3ffffe2, + 0x3ffffe3, + 0x3ffffe4, + 0x7ffffde, + 0x7ffffdf, + 0x3ffffe5, + 0xfffff1, + 0x1ffffed, + 0x7fff2, + 0x1fffe3, + 0x3ffffe6, + 0x7ffffe0, + 0x7ffffe1, + 0x3ffffe7, + 0x7ffffe2, + 0xfffff2, + 0x1fffe4, + 0x1fffe5, + 0x3ffffe8, + 0x3ffffe9, + 0xffffffd, + 0x7ffffe3, + 0x7ffffe4, + 0x7ffffe5, + 0xfffec, + 0xfffff3, + 0xfffed, + 0x1fffe6, + 0x3fffe9, + 0x1fffe7, + 0x1fffe8, + 0x7ffff3, + 0x3fffea, + 0x3fffeb, + 0x1ffffee, + 0x1ffffef, + 0xfffff4, + 0xfffff5, + 0x3ffffea, + 0x7ffff4, + 0x3ffffeb, + 0x7ffffe6, + 0x3ffffec, + 0x3ffffed, + 0x7ffffe7, + 0x7ffffe8, + 0x7ffffe9, + 0x7ffffea, + 0x7ffffeb, + 0xffffffe, + 0x7ffffec, + 0x7ffffed, + 0x7ffffee, + 0x7ffffef, + 0x7fffff0, + 0x3ffffee, + 0x3fffffff, +]! + +// h2_huffman_code_lens holds the bit length of each Huffman code in +// h2_huffman_codes (1..30 bits). +const h2_huffman_code_lens = [ + u8(13), + 23, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 24, + 30, + 28, + 28, + 30, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 30, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 6, + 10, + 10, + 12, + 13, + 6, + 8, + 11, + 10, + 10, + 8, + 11, + 8, + 6, + 6, + 6, + 5, + 5, + 5, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 7, + 8, + 15, + 6, + 12, + 10, + 13, + 6, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 8, + 7, + 8, + 13, + 19, + 13, + 14, + 6, + 15, + 5, + 6, + 5, + 6, + 5, + 6, + 6, + 6, + 5, + 7, + 7, + 6, + 6, + 6, + 5, + 6, + 7, + 6, + 5, + 5, + 6, + 7, + 7, + 7, + 7, + 7, + 15, + 11, + 14, + 13, + 28, + 20, + 22, + 20, + 20, + 22, + 22, + 22, + 23, + 22, + 23, + 23, + 23, + 23, + 23, + 24, + 23, + 24, + 24, + 22, + 23, + 24, + 23, + 23, + 23, + 23, + 21, + 22, + 23, + 22, + 23, + 23, + 24, + 22, + 21, + 20, + 22, + 22, + 23, + 23, + 21, + 23, + 22, + 22, + 24, + 21, + 22, + 23, + 23, + 21, + 21, + 22, + 21, + 23, + 22, + 23, + 23, + 20, + 22, + 22, + 22, + 23, + 22, + 22, + 23, + 26, + 26, + 20, + 19, + 22, + 23, + 22, + 25, + 26, + 26, + 26, + 27, + 27, + 26, + 24, + 25, + 19, + 21, + 26, + 27, + 27, + 26, + 27, + 24, + 21, + 21, + 26, + 26, + 28, + 27, + 27, + 27, + 20, + 24, + 20, + 21, + 22, + 21, + 21, + 23, + 22, + 22, + 25, + 25, + 24, + 24, + 26, + 23, + 26, + 27, + 26, + 26, + 27, + 27, + 27, + 27, + 27, + 28, + 27, + 27, + 27, + 27, + 27, + 26, + 30, +]! diff --git a/vlib/net/http/h2_hpack_static.v b/vlib/net/http/h2_hpack_static.v new file mode 100644 index 000000000..9125d4d2e --- /dev/null +++ b/vlib/net/http/h2_hpack_static.v @@ -0,0 +1,73 @@ +// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module http + +// h2_hpack_static_table is the HPACK static table (RFC 7541 Appendix A). +// HPACK indexes into it are 1-based, so entry N is at index N-1 here. +const h2_hpack_static_table = [ + H2HeaderField{':authority', ''}, + H2HeaderField{':method', 'GET'}, + H2HeaderField{':method', 'POST'}, + H2HeaderField{':path', '/'}, + H2HeaderField{':path', '/index.html'}, + H2HeaderField{':scheme', 'http'}, + H2HeaderField{':scheme', 'https'}, + H2HeaderField{':status', '200'}, + H2HeaderField{':status', '204'}, + H2HeaderField{':status', '206'}, + H2HeaderField{':status', '304'}, + H2HeaderField{':status', '400'}, + H2HeaderField{':status', '404'}, + H2HeaderField{':status', '500'}, + H2HeaderField{'accept-charset', ''}, + H2HeaderField{'accept-encoding', 'gzip, deflate'}, + H2HeaderField{'accept-language', ''}, + H2HeaderField{'accept-ranges', ''}, + H2HeaderField{'accept', ''}, + H2HeaderField{'access-control-allow-origin', ''}, + H2HeaderField{'age', ''}, + H2HeaderField{'allow', ''}, + H2HeaderField{'authorization', ''}, + H2HeaderField{'cache-control', ''}, + H2HeaderField{'content-disposition', ''}, + H2HeaderField{'content-encoding', ''}, + H2HeaderField{'content-language', ''}, + H2HeaderField{'content-length', ''}, + H2HeaderField{'content-location', ''}, + H2HeaderField{'content-range', ''}, + H2HeaderField{'content-type', ''}, + H2HeaderField{'cookie', ''}, + H2HeaderField{'date', ''}, + H2HeaderField{'etag', ''}, + H2HeaderField{'expect', ''}, + H2HeaderField{'expires', ''}, + H2HeaderField{'from', ''}, + H2HeaderField{'host', ''}, + H2HeaderField{'if-match', ''}, + H2HeaderField{'if-modified-since', ''}, + H2HeaderField{'if-none-match', ''}, + H2HeaderField{'if-range', ''}, + H2HeaderField{'if-unmodified-since', ''}, + H2HeaderField{'last-modified', ''}, + H2HeaderField{'link', ''}, + H2HeaderField{'location', ''}, + H2HeaderField{'max-forwards', ''}, + H2HeaderField{'proxy-authenticate', ''}, + H2HeaderField{'proxy-authorization', ''}, + H2HeaderField{'range', ''}, + H2HeaderField{'referer', ''}, + H2HeaderField{'refresh', ''}, + H2HeaderField{'retry-after', ''}, + H2HeaderField{'server', ''}, + H2HeaderField{'set-cookie', ''}, + H2HeaderField{'strict-transport-security', ''}, + H2HeaderField{'transfer-encoding', ''}, + H2HeaderField{'user-agent', ''}, + H2HeaderField{'vary', ''}, + H2HeaderField{'via', ''}, + H2HeaderField{'www-authenticate', ''}, +]! + +// h2_hpack_static_len is the number of entries in the HPACK static table. +const h2_hpack_static_len = h2_hpack_static_table.len diff --git a/vlib/net/http/h2_hpack_test.v b/vlib/net/http/h2_hpack_test.v new file mode 100644 index 000000000..8542e0d69 --- /dev/null +++ b/vlib/net/http/h2_hpack_test.v @@ -0,0 +1,316 @@ +module http + +// Tests for the HPACK implementation (RFC 7541). The byte sequences below are +// the worked examples from RFC 7541 Appendix C; decoding them is the canonical +// correctness check for an HPACK decoder. + +// hexb parses a hex string (spaces allowed) into bytes. +fn hexb(s string) []u8 { + clean := s.replace(' ', '').replace('\n', '') + mut out := []u8{cap: clean.len / 2} + for i := 0; i + 1 < clean.len; i += 2 { + hi := hex_nibble(clean[i]) + lo := hex_nibble(clean[i + 1]) + out << u8((hi << 4) | lo) + } + return out +} + +fn hex_nibble(c u8) u8 { + return match c { + `0`...`9` { c - `0` } + `a`...`f` { c - `a` + 10 } + `A`...`F` { c - `A` + 10 } + else { 0 } + } +} + +fn assert_fields(got []H2HeaderField, want [][]string) { + assert got.len == want.len, 'field count: got ${got.len}, want ${want.len}' + for i, w in want { + assert got[i].name == w[0], 'field ${i} name: got "${got[i].name}", want "${w[0]}"' + assert got[i].value == w[1], 'field ${i} value: got "${got[i].value}", want "${w[1]}"' + } +} + +// --- Integer representation (RFC 7541 Section 5.1) --- + +fn test_hpack_integer_examples() { + // C.1.1: encode 10 with a 5-bit prefix -> single byte 0x0a. + mut b := []u8{} + h2_hpack_write_int(mut b, 10, 5, 0) + assert b == [u8(0x0a)] + mut r := H2HpackReader{ + buf: b + } + assert r.read_int(5)! == 10 + + // C.1.2: encode 1337 with a 5-bit prefix -> 0x1f 0x9a 0x0a. + b = []u8{} + h2_hpack_write_int(mut b, 1337, 5, 0) + assert b == [u8(0x1f), 0x9a, 0x0a] + r = H2HpackReader{ + buf: b + } + assert r.read_int(5)! == 1337 + + // C.1.3: encode 42 with an 8-bit prefix -> single byte 0x2a. + b = []u8{} + h2_hpack_write_int(mut b, 42, 8, 0) + assert b == [u8(0x2a)] + r = H2HpackReader{ + buf: b + } + assert r.read_int(8)! == 42 +} + +fn test_hpack_integer_overflow_rejected() { + // A run of continuation bytes with the high bit always set must not loop + // forever or overflow; it should error. + bad := [u8(0x1f), 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80] + mut r := H2HpackReader{ + buf: bad + } + r.read_int(5) or { return } + assert false, 'expected integer overflow error' +} + +// --- Huffman coding (RFC 7541 Section 5.2) --- + +fn test_huffman_roundtrip() { + samples := ['', 'www.example.com', 'no-cache', 'custom-value', '/sample/path', + 'Mon, 21 Oct 2013 20:13:21 GMT', 'https://www.example.com', 'private'] + for s in samples { + enc := h2_huffman_encode(s.bytes()) + dec := h2_huffman_decode(enc)! + assert dec.bytestr() == s, 'huffman roundtrip failed for "${s}"' + } +} + +fn test_huffman_known_vector() { + // "www.example.com" Huffman-encoded, from RFC 7541 C.4.1. + enc := hexb('f1e3c2e5f23a6ba0ab90f4ff') + dec := h2_huffman_decode(enc)! + assert dec.bytestr() == 'www.example.com' +} + +fn test_huffman_rejects_padding_not_all_ones() { + // Valid encoding of "0" is 5 bits (00000); pad the rest of the byte with + // zeros instead of ones -> invalid per RFC 7541 Section 5.2. + bad := [u8(0x00)] // '0' code is 00000, then 000 padding (not all ones) + h2_huffman_decode(bad) or { return } + assert false, 'expected invalid huffman padding error' +} + +// --- C.2: Header field representations --- + +fn test_hpack_c_2_1_literal_incremental() { + mut d := H2HpackDecoder{} + fields := d.decode(hexb('400a 6375 7374 6f6d 2d6b 6579 0d63 7573 746f 6d2d 6865 6164 6572'))! + assert_fields(fields, [['custom-key', 'custom-header']]) + // Added to the dynamic table: size = 10 + 13 + 32 = 55. + assert d.dyn_table.entries.len == 1 + assert d.dyn_table.cur_size == 55 + assert d.dyn_table.entries[0].name == 'custom-key' +} + +fn test_hpack_c_2_2_literal_without_indexing() { + mut d := H2HpackDecoder{} + fields := d.decode(hexb('040c 2f73 616d 706c 652f 7061 7468'))! + assert_fields(fields, [[':path', '/sample/path']]) + assert d.dyn_table.entries.len == 0 +} + +fn test_hpack_c_2_3_never_indexed() { + mut d := H2HpackDecoder{} + fields := d.decode(hexb('1008 7061 7373 776f 7264 0673 6563 7265 74'))! + assert_fields(fields, [['password', 'secret']]) + assert d.dyn_table.entries.len == 0 +} + +fn test_hpack_c_2_4_indexed() { + mut d := H2HpackDecoder{} + fields := d.decode(hexb('82'))! + assert_fields(fields, [[':method', 'GET']]) +} + +// --- C.3: Request sequence without Huffman, shared decoder --- + +fn test_hpack_c_3_request_sequence() { + mut d := H2HpackDecoder{} + + f1 := d.decode(hexb('8286 8441 0f77 7777 2e65 7861 6d70 6c65 2e63 6f6d'))! + assert_fields(f1, [[':method', 'GET'], [':scheme', 'http'], + [':path', '/'], [':authority', 'www.example.com']]) + assert d.dyn_table.cur_size == 57 + + f2 := d.decode(hexb('8286 84be 5808 6e6f 2d63 6163 6865'))! + assert_fields(f2, [[':method', 'GET'], [':scheme', 'http'], + [':path', '/'], [':authority', 'www.example.com'], ['cache-control', 'no-cache']]) + + f3 := + d.decode(hexb('8287 85bf 400a 6375 7374 6f6d 2d6b 6579 0c63 7573 746f 6d2d 7661 6c75 65'))! + assert_fields(f3, [[':method', 'GET'], [':scheme', 'https'], + [':path', '/index.html'], [':authority', 'www.example.com'], + ['custom-key', 'custom-value']]) +} + +// --- C.4: Request sequence with Huffman, shared decoder --- + +fn test_hpack_c_4_request_sequence_huffman() { + mut d := H2HpackDecoder{} + + f1 := d.decode(hexb('8286 8441 8cf1 e3c2 e5f2 3a6b a0ab 90f4 ff'))! + assert_fields(f1, [[':method', 'GET'], [':scheme', 'http'], + [':path', '/'], [':authority', 'www.example.com']]) + + f2 := d.decode(hexb('8286 84be 5886 a8eb 1064 9cbf'))! + assert_fields(f2, [[':method', 'GET'], [':scheme', 'http'], + [':path', '/'], [':authority', 'www.example.com'], ['cache-control', 'no-cache']]) + + f3 := d.decode(hexb('8287 85bf 4088 25a8 49e9 5ba9 7d7f 8925 a849 e95b b8e8 b4bf'))! + assert_fields(f3, [[':method', 'GET'], [':scheme', 'https'], + [':path', '/index.html'], [':authority', 'www.example.com'], + ['custom-key', 'custom-value']]) +} + +// --- Dynamic table eviction (RFC 7541 Sections 4.3, 4.4) --- + +fn test_dyn_table_eviction_on_add() { + // Mirrors python-hpack's eviction test: a 66-byte table holds only one of + // these two entries at a time. + mut t := H2DynTable{ + max_size: 66 + } + t.add('a', 'b') // size = 1 + 1 + 32 = 34 + assert t.entries.len == 1 + assert t.cur_size == 34 + t.add('long-custom-header', 'longish value') // size = 18 + 13 + 32 = 63 + assert t.entries.len == 1 + assert t.entries[0].name == 'long-custom-header' + assert t.cur_size == 63 +} + +fn test_dyn_table_oversized_entry_empties_table() { + mut t := H2DynTable{ + max_size: 64 + } + t.add('a', 'b') + assert t.entries.len == 1 + // An entry larger than the whole table empties it and is not added. + t.add('x'.repeat(100), '') + assert t.entries.len == 0 + assert t.cur_size == 0 +} + +fn test_dyn_table_resize_evicts() { + mut t := H2DynTable{} + t.add('a', 'b') + t.add('c', 'd') + assert t.entries.len == 2 + t.set_max_size(34) // room for exactly one 34-byte entry (the newest) + assert t.entries.len == 1 + assert t.entries[0].name == 'c' + t.set_max_size(0) + assert t.entries.len == 0 + assert t.cur_size == 0 +} + +// A "size update then re-add" sequence exercised through the decoder: an +// indexed reference to an evicted entry must fail. +fn test_decoder_dynamic_indexing_and_eviction() { + mut d := H2HpackDecoder{} + // Literal incremental indexing of custom-key: custom-header (size 55). + _ := d.decode(hexb('400a 6375 7374 6f6d 2d6b 6579 0d63 7573 746f 6d2d 6865 6164 6572'))! + assert d.dyn_table.entries.len == 1 + // Index 62 now refers to that entry. + f := d.decode([u8(0xbe)])! + assert_fields(f, [['custom-key', 'custom-header']]) + // Shrinking the table to 0 evicts it; index 62 is then out of range. + d.decode([u8(0x20)])! // dynamic table size update to 0 + assert d.dyn_table.entries.len == 0 + d.decode([u8(0xbe)]) or { return } + assert false, 'expected out-of-range error after eviction' +} + +// --- Encoder + round-trip --- + +fn test_hpack_encode_indexed_static() { + mut e := H2HpackEncoder{} + // :method GET is static index 2 -> single byte 0x82. + out := e.encode([H2HeaderField{':method', 'GET'}]) + assert out == [u8(0x82)] +} + +fn test_hpack_roundtrip() { + fields := [ + H2HeaderField{':method', 'GET'}, + H2HeaderField{':scheme', 'https'}, + H2HeaderField{':authority', 'example.com'}, + H2HeaderField{':path', '/index.html'}, + H2HeaderField{'user-agent', 'v.http/0.1'}, + H2HeaderField{'accept', '*/*'}, + H2HeaderField{'cookie', 'session=abc123'}, + ] + mut e := H2HpackEncoder{} + mut d := H2HpackDecoder{} + encoded := e.encode(fields) + decoded := d.decode(encoded)! + assert_fields(decoded, [[':method', 'GET'], [':scheme', 'https'], + [':authority', 'example.com'], [':path', '/index.html'], + ['user-agent', 'v.http/0.1'], ['accept', '*/*'], ['cookie', 'session=abc123']]) +} + +// --- Decoder error handling --- + +fn test_hpack_rejects_zero_index() { + mut d := H2HpackDecoder{} + d.decode([u8(0x80)]) or { return } // indexed header field, index 0 + assert false, 'expected error for index 0' +} + +fn test_hpack_rejects_out_of_range_index() { + mut d := H2HpackDecoder{} + d.decode([u8(0xff), 0x00]) or { return } // index 62, dynamic table empty + assert false, 'expected error for out-of-range index' +} + +fn test_hpack_rejects_size_update_after_field() { + mut d := H2HpackDecoder{} + // Indexed field (0x82) followed by a dynamic table size update (0x20). + d.decode([u8(0x82), 0x20]) or { return } + assert false, 'expected error for size update after field' +} + +fn test_hpack_rejects_size_update_over_limit() { + mut d := H2HpackDecoder{} + d.set_max_dynamic_size(4096) + // 0x3f e0 0f = dynamic table size update to 4096+... well over 4096. + // Dynamic table size update to 8192 (> 4096 limit). + d.decode([u8(0x3f), 0xe1, 0x3f]) or { return } + assert false, 'expected error for size update over limit' +} + +fn test_hpack_rejects_truncating_index() { + mut d := H2HpackDecoder{} + // Insert one dynamic entry, so dynamic index 1 (HPACK index 62) is valid. + _ := d.decode(hexb('400a 6375 7374 6f6d 2d6b 6579 0d63 7573 746f 6d2d 6865 6164 6572'))! + // Indexed representation with idx = 2^32 + 62: it truncates to 62 (a valid + // dynamic index) when narrowed to a 32-bit int, but must be rejected. + mut block := []u8{} + h2_hpack_write_int(mut block, u64(0x1_0000_0000) + 62, 7, 0x80) + d.decode(block) or { return } + assert false, 'expected out-of-range error for truncating index' +} + +fn test_hpack_rejects_truncating_string_length() { + mut d := H2HpackDecoder{} + mut block := []u8{} + block << 0x00 // literal without indexing, name index 0 + block << 0x00 // empty name (H=0, length 0) + // Value string length = 2^32 + 5, which truncates to 5 in a 32-bit int. + h2_hpack_write_int(mut block, u64(0x1_0000_0000) + 5, 7, 0x00) + // No value bytes follow; the oversized length must be rejected cleanly. + d.decode(block) or { return } + assert false, 'expected length-exceeds-buffer error for truncating string length' +} -- 2.39.5