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 <noreply@anthropic.com>
273 lines
9.6 KiB
Go
273 lines
9.6 KiB
Go
package parser_test
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/antitbone/ja4/ja4ebpf/internal/parser"
|
|
)
|
|
|
|
func TestDetectH2PrefaceTrue(t *testing.T) {
|
|
preface := []byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
|
|
data := append(preface, 0x00, 0x00) // données supplémentaires
|
|
|
|
if !parser.DetectH2Preface(data) {
|
|
t.Error("H2Magic non détecté dans un buffer valide")
|
|
}
|
|
}
|
|
|
|
func TestDetectH2PrefaceFalse(t *testing.T) {
|
|
if parser.DetectH2Preface([]byte("GET / HTTP/1.1\r\n")) {
|
|
t.Error("détection faux positif HTTP/1.1 comme HTTP/2")
|
|
}
|
|
}
|
|
|
|
func TestDetectH2PrefaceTooShort(t *testing.T) {
|
|
if parser.DetectH2Preface([]byte("PRI *")) {
|
|
t.Error("détection sur buffer trop court")
|
|
}
|
|
}
|
|
|
|
func TestH2MagicPrefaceLen(t *testing.T) {
|
|
if parser.H2MagicPrefaceLen() != 24 {
|
|
t.Errorf("longueur préambule HTTP/2 attendue 24, obtenue %d", parser.H2MagicPrefaceLen())
|
|
}
|
|
}
|
|
|
|
func TestParseH2ClientPrefaceSettingsEmpty(t *testing.T) {
|
|
// Frame SETTINGS vide (longueur 0, aucun paramètre) sur stream 0
|
|
frame := buildH2Frame(0x4, 0x0, 0, []byte{})
|
|
settings, err := parser.ParseH2ClientPreface(frame)
|
|
if err != nil {
|
|
t.Fatalf("ParseH2ClientPreface: %v", err)
|
|
}
|
|
if settings == nil {
|
|
t.Fatal("settings ne doit pas être nil")
|
|
}
|
|
// Tous les champs doivent être -1 (absent)
|
|
if settings.HeaderTableSize != -1 {
|
|
t.Errorf("HeaderTableSize: attendu -1, obtenu %d", settings.HeaderTableSize)
|
|
}
|
|
if settings.InitialWindowSize != -1 {
|
|
t.Errorf("InitialWindowSize: attendu -1, obtenu %d", settings.InitialWindowSize)
|
|
}
|
|
}
|
|
|
|
func TestParseH2ClientPrefaceSettingsWithValues(t *testing.T) {
|
|
// Frame SETTINGS avec INITIAL_WINDOW_SIZE=65536 et MAX_CONCURRENT_STREAMS=100
|
|
settingsPayload := []byte{
|
|
0x00, 0x04, 0x00, 0x01, 0x00, 0x00, // INITIAL_WINDOW_SIZE = 65536
|
|
0x00, 0x03, 0x00, 0x00, 0x00, 0x64, // MAX_CONCURRENT_STREAMS = 100
|
|
}
|
|
frame := buildH2Frame(0x4, 0x0, 0, settingsPayload)
|
|
|
|
settings, err := parser.ParseH2ClientPreface(frame)
|
|
if err != nil {
|
|
t.Fatalf("ParseH2ClientPreface: %v", err)
|
|
}
|
|
|
|
if settings.InitialWindowSize != 65536 {
|
|
t.Errorf("InitialWindowSize: attendu 65536, obtenu %d", settings.InitialWindowSize)
|
|
}
|
|
if settings.MaxConcurrentStreams != 100 {
|
|
t.Errorf("MaxConcurrentStreams: attendu 100, obtenu %d", settings.MaxConcurrentStreams)
|
|
}
|
|
// Les paramètres non présents restent à -1
|
|
if settings.HeaderTableSize != -1 {
|
|
t.Errorf("HeaderTableSize non fourni: attendu -1, obtenu %d", settings.HeaderTableSize)
|
|
}
|
|
}
|
|
|
|
func TestParseH2ClientPrefaceWindowUpdate(t *testing.T) {
|
|
// Frame WINDOW_UPDATE sur stream 0 avec incrément = 1073741824
|
|
wuPayload := []byte{0x40, 0x00, 0x00, 0x00} // 0x40000000 = 1073741824
|
|
frame := buildH2Frame(0x8, 0x0, 0, wuPayload)
|
|
|
|
settings, err := parser.ParseH2ClientPreface(frame)
|
|
if err != nil {
|
|
t.Fatalf("ParseH2ClientPreface: %v", err)
|
|
}
|
|
if settings.WindowUpdateIncrement != 1073741824 {
|
|
t.Errorf("WindowUpdateIncrement: attendu 1073741824, obtenu %d", settings.WindowUpdateIncrement)
|
|
}
|
|
}
|
|
|
|
func TestParseH2ClientPrefaceCombined(t *testing.T) {
|
|
// SETTINGS + WINDOW_UPDATE combinés (comme envoyé par curl/h2)
|
|
settingsPayload := []byte{
|
|
0x00, 0x01, 0x00, 0x00, 0x10, 0x00, // HEADER_TABLE_SIZE = 4096
|
|
0x00, 0x04, 0x00, 0x00, 0xff, 0xff, // INITIAL_WINDOW_SIZE = 65535
|
|
}
|
|
wuPayload := []byte{0x00, 0x0f, 0x00, 0x01} // WINDOW_UPDATE incr = 983041
|
|
|
|
frames := buildH2Frame(0x4, 0x0, 0, settingsPayload)
|
|
frames = append(frames, buildH2Frame(0x8, 0x0, 0, wuPayload)...)
|
|
|
|
settings, err := parser.ParseH2ClientPreface(frames)
|
|
if err != nil {
|
|
t.Fatalf("ParseH2ClientPreface: %v", err)
|
|
}
|
|
if settings.HeaderTableSize != 4096 {
|
|
t.Errorf("HeaderTableSize: attendu 4096, obtenu %d", settings.HeaderTableSize)
|
|
}
|
|
if settings.InitialWindowSize != 65535 {
|
|
t.Errorf("InitialWindowSize: attendu 65535, obtenu %d", settings.InitialWindowSize)
|
|
}
|
|
if settings.WindowUpdateIncrement != 983041 {
|
|
t.Errorf("WindowUpdateIncrement: attendu 983041, obtenu %d", settings.WindowUpdateIncrement)
|
|
}
|
|
}
|
|
|
|
func TestParseH2ClientPrefaceEmpty(t *testing.T) {
|
|
// Données vides : doit retourner sans erreur, settings avec valeurs par défaut (-1)
|
|
settings, err := parser.ParseH2ClientPreface([]byte{})
|
|
if err != nil {
|
|
t.Fatalf("ParseH2ClientPreface sur vide: %v", err)
|
|
}
|
|
if settings == nil {
|
|
t.Error("settings ne doit pas être nil même pour données vides")
|
|
}
|
|
if settings.HeaderTableSize != -1 {
|
|
t.Errorf("HeaderTableSize: attendu -1 par défaut, obtenu %d", settings.HeaderTableSize)
|
|
}
|
|
}
|
|
|
|
func TestParseH2ClientPrefaceTruncatedFrame(t *testing.T) {
|
|
// Frame tronquée : en-tête complet mais payload incomplet
|
|
truncated := []byte{0x00, 0x00, 0x06, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
0x00, 0x01} // payload tronqué
|
|
settings, err := parser.ParseH2ClientPreface(truncated)
|
|
if err != nil {
|
|
t.Fatalf("ParseH2ClientPreface sur frame tronquée: %v (doit tolérer)", err)
|
|
}
|
|
// Les paramètres restent à -1 car le payload est incomplet
|
|
_ = settings
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
// buildH2Frame construit une frame HTTP/2 brute (en-tête 9 octets + payload).
|
|
func buildH2Frame(frameType, flags uint8, streamID uint32, payload []byte) []byte {
|
|
l := len(payload)
|
|
frame := []byte{
|
|
byte(l >> 16), byte(l >> 8), byte(l), // longueur sur 3 octets
|
|
frameType, flags,
|
|
byte(streamID >> 24), byte(streamID >> 16), byte(streamID >> 8), byte(streamID),
|
|
}
|
|
return append(frame, payload...)
|
|
}
|
|
|
|
func TestDecodeH2HeadersBlockLiteralWithIndexedName(t *testing.T) {
|
|
// 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{
|
|
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)
|
|
if kv["user-agent"] != "Mozilla/5.0" {
|
|
t.Errorf("user-agent: attendu 'Mozilla/5.0', obtenu %q", kv["user-agent"])
|
|
}
|
|
if len(order) != 1 || order[0] != "user-agent" {
|
|
t.Errorf("order: attendu [user-agent], obtenu %v", order)
|
|
}
|
|
}
|
|
|
|
func TestDecodeH2HeadersBlockLiteralWithoutIndexing(t *testing.T) {
|
|
// Literal without indexing, indexed name (accept-encoding = index 16)
|
|
// 4-bit prefix max = 15, so index 16 needs multi-byte: 0x0F 0x01
|
|
h2block := []byte{
|
|
0x0F, 0x01, // literal without indexing, name index = 16 (accept-encoding)
|
|
0x12, 'g', 'z', 'i', 'p', ',', ' ', 'd', 'e', 'f', 'l', 'a', 't', 'e', ',', ' ', 'b', 'r', // value
|
|
}
|
|
kv, _ := parser.DecodeH2HeadersBlock(h2block)
|
|
if kv["accept-encoding"] != "gzip, deflate, br" {
|
|
t.Errorf("accept-encoding: attendu 'gzip, deflate, br', obtenu %q", kv["accept-encoding"])
|
|
}
|
|
}
|
|
|
|
func TestDecodeH2HeadersBlockLiteralNewName(t *testing.T) {
|
|
// Literal with incremental indexing, new name
|
|
// Prefix byte: 0x40 (index = 0, new name)
|
|
// Name: "x-custom-header", Value: "test-value"
|
|
name := "x-custom-header"
|
|
value := "test-value"
|
|
h2block := []byte{
|
|
0x40, // literal with incremental indexing, new name
|
|
byte(len(name)), // name length
|
|
}
|
|
h2block = append(h2block, []byte(name)...)
|
|
h2block = append(h2block, byte(len(value)))
|
|
h2block = append(h2block, []byte(value)...)
|
|
|
|
kv, order := parser.DecodeH2HeadersBlock(h2block)
|
|
// x-custom-header is not in hpackCapturedHeaders, so it won't be in kv
|
|
if len(kv) != 0 {
|
|
t.Errorf("x-custom-header ne doit pas être capturé (pas dans hpackCapturedHeaders), obtenu %v", kv)
|
|
}
|
|
_ = order
|
|
}
|
|
|
|
func TestDecodeH2HeadersBlockPseudoHeaders(t *testing.T) {
|
|
// Pseudo-headers :method GET (indexed, byte 0x82), :path / (indexed, byte 0x84)
|
|
// Then :authority as literal with indexed name (index 1)
|
|
// 0x40 | 1 = 0x41, then value "example.com"
|
|
h2block := []byte{
|
|
0x82, // indexed :method GET
|
|
0x84, // indexed :path /
|
|
0x41, // literal with incremental indexing, name index 1 (:authority)
|
|
0x0B, 'e', 'x', 'a', 'm', 'p', 'l', 'e', '.', 'c', 'o', 'm', // value
|
|
}
|
|
kv, order := parser.DecodeH2HeadersBlock(h2block)
|
|
if kv[":authority"] != "example.com" {
|
|
t.Errorf(":authority: attendu 'example.com', obtenu %q", kv[":authority"])
|
|
}
|
|
if len(order) < 1 {
|
|
t.Errorf("order ne doit pas être vide, obtenu %v", order)
|
|
}
|
|
}
|
|
|
|
func TestIsH2FrameHeader(t *testing.T) {
|
|
// Frame SETTINGS valide
|
|
frame := buildH2Frame(0x4, 0x0, 0, []byte{})
|
|
if !parser.IsH2FrameHeader(frame) {
|
|
t.Error("IsH2FrameHeader doit retourner true pour frame SETTINGS valide")
|
|
}
|
|
// Données aléatoires
|
|
random := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}
|
|
if parser.IsH2FrameHeader(random) {
|
|
t.Error("IsH2FrameHeader doit retourner false pour données invalides (length > 16384)")
|
|
}
|
|
// Trop court
|
|
if parser.IsH2FrameHeader([]byte{0x00, 0x00}) {
|
|
t.Error("IsH2FrameHeader doit retourner false pour données trop courtes")
|
|
}
|
|
}
|
|
|
|
func TestExtractH2HeaderKV(t *testing.T) {
|
|
// HEADERS frame with :authority literal
|
|
headersPayload := []byte{
|
|
0x41, // literal with incremental indexing, name index 1 (:authority)
|
|
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // value
|
|
}
|
|
frame := buildH2Frame(0x1, 0x04, 1, headersPayload) // HEADERS, END_HEADERS, stream 1
|
|
|
|
kv := parser.ExtractH2HeaderKV(frame)
|
|
if kv[":authority"] != "example" {
|
|
t.Errorf(":authority: attendu 'example', obtenu %q", kv[":authority"])
|
|
}
|
|
}
|
|
|
|
func TestFormatTCPOptions(t *testing.T) {
|
|
// MSS(2,4bytes) + WS(3,3bytes) + SACK(4,2bytes) + NOP(1) + TS(8,10bytes)
|
|
opts := []byte{
|
|
2, 4, 0x05, 0xB4, // MSS = 1460
|
|
3, 3, 6, // WS = 6
|
|
4, 2, // SACK Permitted
|
|
1, // NOP
|
|
8, 10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // TS
|
|
}
|
|
// This function is in the writer package, not parser - skip direct test here
|
|
_ = opts
|
|
}
|