Files
ja4-platform/services/ja4ebpf/internal/parser/http2_test.go
Jacquin Antoine 24306ef390 feat(ja4ebpf): add SSL_write uprobe, HPACK decoder, and AcceptCache for session correlation
Add uprobe_ssl_write_entry/uretprobe_ssl_write_exit to capture server HTTP
responses via SSL_write with direction=1. Implement full HPACK decoder
(RFC 7541 static table, multi-byte integers, literal representations) for
HTTP/2 header extraction. Add AcceptCache mapping {tgid,fd}→SessionKey
from accept4 events as authoritative source for SSL correlation when BPF
ssl_conn_map has src_ip=0. Add ip_total_length to tcp_syn_event BPF struct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 03:34:43 +02:00

274 lines
9.7 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 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
// Then value: 7-bit length "Mozilla/5.0" = 11 bytes, no Huffman
h2block := []byte{
0x7F, 0x20, // indexed name = 95 (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
}