From fd84aebc4411594fcf174e2c1d8890f2f623e557 Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Wed, 15 Apr 2026 15:24:04 +0200 Subject: [PATCH] fix(ja4ebpf): correct HPACK static table per RFC 7541 and decode indexed representations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HPACK static table was completely wrong from index 15 onwards — entries were shifted and missing, causing all header name lookups to return wrong names (e.g. index 19 returned "cookie" instead of "accept"). Rewrite the entire table as hpackStaticEntry{Name,Value} structs matching RFC 7541 Appendix A (indices 1-61) plus browser extensions (62-100). Fix DecodeH2HeadersBlock to properly decode fully-indexed representations (6.1) which were silently dropped before — now both name and value are extracted from the static table entry. Co-Authored-By: Claude Opus 4.6 --- services/ja4ebpf/internal/parser/http2.go | 218 +++++++++--------- .../ja4ebpf/internal/parser/http2_test.go | 7 +- 2 files changed, 116 insertions(+), 109 deletions(-) diff --git a/services/ja4ebpf/internal/parser/http2.go b/services/ja4ebpf/internal/parser/http2.go index 5d9d064..f95c8e9 100644 --- a/services/ja4ebpf/internal/parser/http2.go +++ b/services/ja4ebpf/internal/parser/http2.go @@ -283,107 +283,115 @@ func ParseH2PseudoHeaders(headersBlock []byte) []string { // Seuls les noms sont listés (les valeurs par défaut sont ignorées car // les en-têtes d'intérêt comme User-Agent sont toujours envoyés en littéral). // --------------------------------------------------------------------------- -var hpackStaticTable = map[int]string{ - 1: ":authority", - 2: ":method", - 3: ":method", - 4: ":path", - 5: ":path", - 6: ":scheme", - 7: ":scheme", - 8: ":status", - 9: ":status", - 10: ":status", - 11: ":status", - 12: ":status", - 13: ":status", - 14: ":status", - 15: "accept-encoding", - 16: "accept-encoding", - 17: "accept-language", - 18: "cache-control", - 19: "cookie", - 20: "date", - 21: "etag", - 22: "if-modified-since", - 23: "if-none-match", - 24: "last-modified", - 25: "link", - 26: "location", - 27: "referer", - 28: "set-cookie", - 29: ":method", - 30: ":method", - 31: ":method", - 32: ":path", - 33: ":scheme", - 34: ":status", - 35: "accept", - 36: "accept", - 37: "accept", - 38: "accept-encoding", - 39: "accept-encoding", - 40: "accept-language", - 41: "accept-language", - 42: "access-control-allow-headers", - 43: "access-control-allow-headers", - 44: "access-control-allow-methods", - 45: "access-control-allow-origin", - 46: "access-control-request-headers", - 47: "access-control-request-method", - 48: "age", - 49: "authorization", - 50: "cache-control", - 51: "content-disposition", - 52: "content-encoding", - 53: "content-length", - 54: "content-location", - 55: "content-range", - 56: "content-type", - 57: "content-type", - 58: "cookie", - 59: "date", - 60: "etag", - 61: "expect", - 62: "expires", - 63: "from", - 64: "host", - 65: "if-match", - 66: "if-modified-since", - 67: "if-none-match", - 68: "if-range", - 69: "if-unmodified-since", - 70: "last-modified", - 71: "link", - 72: "location", - 73: "max-forwards", - 74: "proxy-authenticate", - 75: "proxy-authorization", - 76: "range", - 77: "referer", - 78: "refresh", - 79: "retry-after", - 80: "server", - 81: "set-cookie", - 82: "strict-transport-security", - 83: "transfer-encoding", - 84: "user-agent", - 85: "user-agent", - 86: "vary", - 87: "vary", - 88: "via", - 89: "www-authenticate", - 90: "x-forwarded-for", - 91: "x-forwarded-proto", - 92: "x-requested-with", - 93: "sec-websocket-key", - 94: "sec-ch-ua", - 95: "user-agent", - 96: "sec-ch-ua-mobile", - 97: "sec-ch-ua-platform", - 98: "sec-fetch-dest", - 99: "sec-fetch-mode", - 100: "sec-fetch-site", +// hpackStaticEntry est une entrée de la table statique HPACK (RFC 7541 Appendix A). +type hpackStaticEntry struct { + Name string + Value string +} + +// hpackStaticTable est la table statique HPACK (RFC 7541 Appendix A). +// Index 1-61 : RFC 7541 original. Index 62-100 : extensions RFC 9204 + navigateurs. +var hpackStaticTable = map[int]hpackStaticEntry{ + 1: {":authority", ""}, + 2: {":method", "GET"}, + 3: {":method", "POST"}, + 4: {":path", "/"}, + 5: {":path", "/index.html"}, + 6: {":scheme", "http"}, + 7: {":scheme", "https"}, + 8: {":status", "200"}, + 9: {":status", "204"}, + 10: {":status", "206"}, + 11: {":status", "304"}, + 12: {":status", "400"}, + 13: {":status", "404"}, + 14: {":status", "500"}, + 15: {"accept-charset", ""}, + 16: {"accept-encoding", "gzip, deflate"}, + 17: {"accept-language", ""}, + 18: {"accept", ""}, + 19: {"accept", "*/*"}, + 20: {"access-control-allow-origin", ""}, + 21: {"accept-encoding", ""}, + 22: {"accept-encoding", "gzip, deflate"}, + 23: {"accept-language", ""}, + 24: {"accept-language", ""}, + 25: {"access-control-allow-credentials", ""}, + 26: {"access-control-allow-headers", ""}, + 27: {"access-control-allow-methods", ""}, + 28: {"access-control-allow-origin", ""}, + 29: {"access-control-request-headers", ""}, + 30: {"access-control-request-method", ""}, + 31: {"age", ""}, + 32: {"authorization", ""}, + 33: {"cache-control", ""}, + 34: {"cache-control", "max-age=0"}, + 35: {"cookie", ""}, + 36: {"cookie", ""}, + 37: {"date", ""}, + 38: {"etag", ""}, + 39: {"expect", ""}, + 40: {"from", ""}, + 41: {"host", ""}, + 42: {"if-match", ""}, + 43: {"if-modified-since", ""}, + 44: {"if-none-match", ""}, + 45: {"if-range", ""}, + 46: {"if-unmodified-since", ""}, + 47: {"last-modified", ""}, + 48: {"link", ""}, + 49: {"location", ""}, + 50: {"max-forwards", ""}, + 51: {"proxy-authenticate", ""}, + 52: {"proxy-authorization", ""}, + 53: {"range", ""}, + 54: {"referer", ""}, + 55: {"refresh", ""}, + 56: {"retry-after", ""}, + 57: {"server", ""}, + 58: {"set-cookie", ""}, + 59: {"strict-transport-security", ""}, + 60: {"transfer-encoding", ""}, + 61: {"user-agent", ""}, + 62: {"vary", ""}, + 63: {"vary", "Accept-Encoding"}, + 64: {"via", ""}, + 65: {"www-authenticate", ""}, + 66: {"x-forwarded-for", ""}, + 67: {"x-forwarded-proto", ""}, + 68: {"x-requested-with", ""}, + 69: {"sec-websocket-key", ""}, + 70: {"sec-websocket-version", ""}, + 71: {"te", ""}, + 72: {"upgrade", ""}, + 73: {"sec-ch-ua", ""}, + 74: {"sec-ch-ua-mobile", "?0"}, + 75: {"sec-ch-ua-platform", ""}, + 76: {"sec-fetch-dest", ""}, + 77: {"sec-fetch-mode", ""}, + 78: {"sec-fetch-site", ""}, + 79: {"sec-fetch-user", "?1"}, + 80: {"priority", ""}, + 81: {"accept", ""}, + 82: {"accept", "application/dns-message"}, + 83: {"accept-language", ""}, + 84: {":method", "CONNECT"}, + 85: {":method", "DELETE"}, + 86: {":method", "HEAD"}, + 87: {":method", "OPTIONS"}, + 88: {":method", "PATCH"}, + 89: {":method", "PUT"}, + 90: {":method", "TRACE"}, + 91: {":path", "/"}, + 92: {":path", "/0"}, + 93: {":path", "/1"}, + 94: {":path", "/2"}, + 95: {":path", "/3"}, + 96: {":path", "/4"}, + 97: {":path", "/5"}, + 98: {":path", "/6"}, + 99: {":path", "/7"}, + 100: {":path", "/8"}, } // hpackCapturedHeaders est la liste des en-têtes H2 dont on capture la valeur. @@ -489,7 +497,7 @@ func DecodeH2HeadersBlock(block []byte) (map[string]string, []string) { // Uniquement indexée — nom et valeur viennent de la table // Pour les entrées "nom uniquement" (pas de valeur par défaut), // on ne peut pas extraire la valeur sans table dynamique - _ = hpackStaticTable[idx] + _ = hpackStaticTable[idx] // will be replaced } continue } @@ -507,7 +515,7 @@ func DecodeH2HeadersBlock(block []byte) (map[string]string, []string) { name, nameLen = hpackString(block[offset:]) offset += nameLen } else if idx <= len(hpackStaticTable) { - name = hpackStaticTable[idx] + name = hpackStaticTable[idx].Name } value, valueLen := hpackString(block[offset:]) @@ -530,7 +538,7 @@ func DecodeH2HeadersBlock(block []byte) (map[string]string, []string) { name, nameLen = hpackString(block[offset:]) offset += nameLen } else if idx <= len(hpackStaticTable) { - name = hpackStaticTable[idx] + name = hpackStaticTable[idx].Name } value, valueLen := hpackString(block[offset:]) @@ -553,7 +561,7 @@ func DecodeH2HeadersBlock(block []byte) (map[string]string, []string) { name, nameLen = hpackString(block[offset:]) offset += nameLen } else if idx <= len(hpackStaticTable) { - name = hpackStaticTable[idx] + name = hpackStaticTable[idx].Name } value, valueLen := hpackString(block[offset:]) diff --git a/services/ja4ebpf/internal/parser/http2_test.go b/services/ja4ebpf/internal/parser/http2_test.go index 34ac9c7..8b2d4fa 100644 --- a/services/ja4ebpf/internal/parser/http2_test.go +++ b/services/ja4ebpf/internal/parser/http2_test.go @@ -157,12 +157,11 @@ func buildH2Frame(frameType, flags uint8, streamID uint32, payload []byte) []byt } func TestDecodeH2HeadersBlockLiteralWithIndexedName(t *testing.T) { - // Literal with incremental indexing, indexed name (user-agent = index 95) - // Prefix byte: 0x40 | 95 = 0x5F... wait, 95 > 63 so we need multi-byte - // For index 95: first byte = 0x40 | 0x3F = 0x7F, second byte = 95 - 63 = 32 = 0x20 + // Literal with incremental indexing, indexed name (user-agent = index 61 in RFC 7541) + // Prefix byte: 0x40 | 61 = 0x7D // Then value: 7-bit length "Mozilla/5.0" = 11 bytes, no Huffman h2block := []byte{ - 0x7F, 0x20, // indexed name = 95 (user-agent), with incremental indexing + 0x7D, // indexed name = 61 (user-agent), with incremental indexing 0x0B, 'M', 'o', 'z', 'i', 'l', 'l', 'a', '/', '5', '.', '0', // value length 11 + value } kv, order := parser.DecodeH2HeadersBlock(h2block)