Files
ja4-platform/services/mod-reqin-log/tests/unit/test_h2_parsing.c
toto 8ca4a1e849 feat(mod_reqin_log): fingerprinting HTTP/2 passif (Akamai format)
Ajoute un filtre d'entrée de connexion (AP_FTYPE_CONNECTION, APR_HOOK_LAST)
qui s'insère entre mod_ssl et mod_http2 pour lire de manière non-destructive
le preface HTTP/2 (RFC 9113 §3.4) et en extraire :

- h2_fingerprint    : fingerprint Akamai complet
                      ex. '1:65536,2:0,4:6291456,6:262144|15663105|0|m,a,s,p'
- h2_settings_fp    : entrées SETTINGS brutes  (ex. '1:65536,4:6291456')
- h2_window_update  : incrément WINDOW_UPDATE  (ex. '15663105')
- h2_pseudo_order   : ordre des pseudo-headers (ex. 'm,a,s,p' Chrome,
                                                     'm,p,s,a' Firefox)

Technique : lecture spéculative AP_MODE_SPECULATIVE (non-destructive)
de 512 octets — la donnée reste disponible pour mod_http2. Le filtre
se retire de la chaîne après la première invocation.

Stockage dans c->notes (H2_NOTE_*) puis émission JSON dans log_request().
ClickHouse : 4 nouvelles colonnes dans http_logs + JSONExtract dans mv_http_logs.
Migration pour déploiements existants : 04_http2_fields.sql.
14 tests unitaires (cmocka) couvrent Chrome/Firefox/HTTP1/troncature/HPACK.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 23:46:50 +02:00

459 lines
15 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* test_h2_parsing.c — Tests unitaires du fingerprinting HTTP/2 passif.
*
* Les fonctions testées (hpack_int_decode, h2_extract_pseudo_order,
* h2_parse_preface_buf) sont réimplimentées localement pour éviter les
* dépendances Apache/APR. La logique est identique à mod_reqin_log.c.
*/
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>
#include <string.h>
#include <stdio.h>
#include <stdint.h>
/* ====== Réimplémentation locale des fonctions H2 ====== */
static int hpack_int_decode(const unsigned char *buf, size_t len, int prefix,
size_t *pos, unsigned int *out)
{
unsigned int mask = (1u << prefix) - 1u;
unsigned int b, m;
if (*pos >= len) return 0;
*out = buf[(*pos)++] & mask;
if (*out < mask) return 1;
m = 0;
while (*pos < len) {
b = buf[(*pos)++];
*out += (b & 0x7fu) << m;
m += 7;
if (!(b & 0x80u)) return 1;
if (m > 28) return 0;
}
return 0;
}
static char h2_hpack_pseudo(unsigned int index)
{
switch (index) {
case 1: return 'a';
case 2: case 3: return 'm';
case 4: case 5: return 'p';
case 6: case 7: return 's';
default: return 0;
}
}
static void h2_extract_pseudo_order(const unsigned char *hpack, size_t len, char *out)
{
size_t pos = 0;
int out_pos = 0;
int first = 1;
while (pos < len && out_pos < 7) {
unsigned char byte = hpack[pos];
if (byte & 0x80u) {
unsigned int idx = 0;
if (!hpack_int_decode(hpack, len, 7, &pos, &idx)) break;
if (idx == 0) break;
char c = h2_hpack_pseudo(idx);
if (!c) break;
if (!first) out[out_pos++] = ',';
out[out_pos++] = c;
first = 0;
} else if ((byte & 0xe0u) == 0x20u) {
unsigned int sz = 0;
if (!hpack_int_decode(hpack, len, 5, &pos, &sz)) break;
} else {
break;
}
}
out[out_pos] = '\0';
}
/* Résultat de h2_parse_preface_buf — version allégée (pas d'APR) */
typedef struct {
char settings[256];
char wupdate[16];
char pseudo[16];
char fingerprint[512];
int has_priority;
int is_h2;
} h2_result_t;
static void h2_parse_preface_buf(const char *buf, size_t len, h2_result_t *res)
{
static const char H2_MAGIC[] = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
const size_t MAGIC_LEN = 24u;
const size_t FRAME_HDR = 9u;
memset(res, 0, sizeof(*res));
strcpy(res->wupdate, "0");
if (len < MAGIC_LEN || memcmp(buf, H2_MAGIC, MAGIC_LEN) != 0) return;
int settings_out = 0;
size_t pos = MAGIC_LEN;
while (pos + FRAME_HDR <= len) {
size_t frame_len = ((unsigned char)buf[pos] << 16)
| ((unsigned char)buf[pos+1] << 8)
| (unsigned char)buf[pos+2];
unsigned char type = (unsigned char)buf[pos+3];
unsigned char flags = (unsigned char)buf[pos+4];
uint32_t stream_id = (((unsigned char)buf[pos+5] & 0x7fu) << 24)
| ((unsigned char)buf[pos+6] << 16)
| ((unsigned char)buf[pos+7] << 8)
| (unsigned char)buf[pos+8];
pos += FRAME_HDR;
if (pos + frame_len > len) break;
if (type == 0x04u && stream_id == 0 && !(flags & 0x01u)) {
size_t sp = 0;
while (sp + 6 <= frame_len &&
settings_out < (int)sizeof(res->settings) - 24) {
uint16_t id = ((unsigned char)buf[pos + sp] << 8)
| (unsigned char)buf[pos + sp + 1];
uint32_t val = ((unsigned char)buf[pos + sp + 2] << 24)
| ((unsigned char)buf[pos + sp + 3] << 16)
| ((unsigned char)buf[pos + sp + 4] << 8)
| (unsigned char)buf[pos + sp + 5];
sp += 6;
if (settings_out > 0)
res->settings[settings_out++] = ',';
settings_out += snprintf(res->settings + settings_out,
(int)sizeof(res->settings) - settings_out,
"%u:%u", id, val);
}
} else if (type == 0x08u && stream_id == 0) {
if (frame_len >= 4) {
uint32_t inc = (((unsigned char)buf[pos] & 0x7fu) << 24)
| ((unsigned char)buf[pos+1] << 16)
| ((unsigned char)buf[pos+2] << 8)
| (unsigned char)buf[pos+3];
snprintf(res->wupdate, sizeof(res->wupdate), "%u", inc);
}
} else if (type == 0x01u && stream_id > 0) {
size_t hpack_start = 0;
int parse_ok = 1;
if ((flags & 0x08u) && parse_ok) {
if (hpack_start >= frame_len) {
parse_ok = 0;
} else {
unsigned char pad_len = (unsigned char)buf[pos + hpack_start++];
if (frame_len < hpack_start + (size_t)pad_len)
parse_ok = 0;
else
frame_len -= (size_t)pad_len;
}
}
if ((flags & 0x20u) && parse_ok) {
if (hpack_start + 5u > frame_len) {
parse_ok = 0;
} else {
hpack_start += 5u;
res->has_priority = 1;
}
}
if (parse_ok && hpack_start < frame_len) {
h2_extract_pseudo_order(
(const unsigned char *)(buf + pos + hpack_start),
frame_len - hpack_start,
res->pseudo
);
}
pos += frame_len;
break;
}
pos += frame_len;
}
if (res->settings[0] != '\0') {
res->is_h2 = 1;
snprintf(res->fingerprint, sizeof(res->fingerprint), "%s|%s|%d|%s",
res->settings, res->wupdate, res->has_priority, res->pseudo);
}
}
/* ====== Données de test : preface Chrome 120 ====== */
/*
* Preface HTTP/2 Chrome 120 (capturée) :
* Magic (24 octets)
* SETTINGS frame : HEADER_TABLE_SIZE=65536, ENABLE_PUSH=0,
* INITIAL_WINDOW_SIZE=6291456, MAX_HEADER_LIST_SIZE=262144
* WINDOW_UPDATE : incrément 15663105
* HEADERS stream 1 : :method GET, :authority, :scheme https, :path /
* → ordre HPACK indexé : 0x82(GET), 0x81(:auth), 0x87(https), 0x84(/)
*/
static const unsigned char CHROME_PREFACE[] = {
/* Magic */
'P','R','I',' ','*',' ','H','T','T','P','/','2','.','0','\r','\n',
'\r','\n','S','M','\r','\n','\r','\n',
/* SETTINGS frame : length=24, type=0x04, flags=0x00, stream=0 */
0x00, 0x00, 0x18, /* length = 24 = 4×6 */
0x04, /* type SETTINGS */
0x00, /* flags = 0 */
0x00, 0x00, 0x00, 0x00, /* stream 0 */
/* Entry 1: HEADER_TABLE_SIZE (1) = 65536 = 0x00010000 */
0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
/* Entry 2: ENABLE_PUSH (2) = 0 */
0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
/* Entry 3: INITIAL_WINDOW_SIZE (4) = 6291456 = 0x00600000 */
0x00, 0x04, 0x00, 0x60, 0x00, 0x00,
/* Entry 4: MAX_HEADER_LIST_SIZE (6) = 262144 = 0x00040000 */
0x00, 0x06, 0x00, 0x04, 0x00, 0x00,
/* WINDOW_UPDATE frame : length=4, type=0x08, flags=0, stream=0 */
0x00, 0x00, 0x04,
0x08,
0x00,
0x00, 0x00, 0x00, 0x00,
/* increment = 15663105 = 0x00EF0001 */
0x00, 0xEF, 0x00, 0x01,
/* HEADERS frame : length=14, type=0x01, flags=0x05 (END_STREAM|END_HEADERS), stream=1 */
0x00, 0x00, 0x0E,
0x01,
0x05,
0x00, 0x00, 0x00, 0x01,
/* HPACK : :method GET (0x82), :authority (0x81), :scheme https (0x87), :path / (0x84) */
/* → ordre Chrome : m,a,s,p */
0x82, 0x81, 0x87, 0x84,
/* + quelques headers supplémentaires (indices statiques) */
0x86, /* :scheme http (index 6, régulier → stop après pseudo) */
0x53, /* accept (sans valeur — littéral, arrête le scan) */
0x00, 0x05, 0x74, 0x65, 0x78, 0x74, 0x2F, 0x68, 0x74, 0x6D, 0x6C
};
/* ====== Données de test : preface Firefox 120 ====== */
/*
* Preface HTTP/2 Firefox 120 :
* SETTINGS: HEADER_TABLE_SIZE=65536, INITIAL_WINDOW_SIZE=131072, MAX_FRAME_SIZE=16384
* WINDOW_UPDATE: 12517377
* HEADERS: :method GET (0x82), :path / (0x84), :scheme https (0x87), :authority (0x81)
* → ordre Firefox : m,p,s,a
*/
static const unsigned char FIREFOX_PREFACE[] = {
/* Magic */
'P','R','I',' ','*',' ','H','T','T','P','/','2','.','0','\r','\n',
'\r','\n','S','M','\r','\n','\r','\n',
/* SETTINGS frame : length=18, type=0x04, flags=0x00, stream=0 */
0x00, 0x00, 0x12,
0x04,
0x00,
0x00, 0x00, 0x00, 0x00,
/* HEADER_TABLE_SIZE (1) = 65536 */
0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
/* INITIAL_WINDOW_SIZE (4) = 131072 = 0x00020000 */
0x00, 0x04, 0x00, 0x02, 0x00, 0x00,
/* MAX_FRAME_SIZE (5) = 16384 = 0x00004000 */
0x00, 0x05, 0x00, 0x00, 0x40, 0x00,
/* WINDOW_UPDATE : increment = 12517377 = 0x00BF0001 */
0x00, 0x00, 0x04,
0x08,
0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0xBF, 0x00, 0x01,
/* HEADERS frame : length=4, type=0x01, flags=0x05, stream=1 */
0x00, 0x00, 0x04,
0x01,
0x05,
0x00, 0x00, 0x00, 0x01,
/* HPACK : :method GET (0x82), :path / (0x84), :scheme https (0x87), :authority (0x81) */
/* → ordre Firefox : m,p,s,a */
0x82, 0x84, 0x87, 0x81
};
/* ====== Données de test : flux HTTP/1.1 (ne doit pas matcher) ====== */
static const char HTTP1_DATA[] =
"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
/* ====== Tests ====== */
static void test_chrome_settings_parsed(void **state)
{
(void)state;
h2_result_t res;
h2_parse_preface_buf((const char *)CHROME_PREFACE, sizeof(CHROME_PREFACE), &res);
assert_int_equal(res.is_h2, 1);
/* SETTINGS attendus : 1:65536,2:0,4:6291456,6:262144 */
assert_string_equal(res.settings, "1:65536,2:0,4:6291456,6:262144");
}
static void test_chrome_window_update(void **state)
{
(void)state;
h2_result_t res;
h2_parse_preface_buf((const char *)CHROME_PREFACE, sizeof(CHROME_PREFACE), &res);
assert_string_equal(res.wupdate, "15663105");
}
static void test_chrome_pseudo_order(void **state)
{
(void)state;
h2_result_t res;
h2_parse_preface_buf((const char *)CHROME_PREFACE, sizeof(CHROME_PREFACE), &res);
/* Chrome : :method(m), :authority(a), :scheme(s), :path(p) */
assert_string_equal(res.pseudo, "m,a,s,p");
}
static void test_chrome_fingerprint_akamai(void **state)
{
(void)state;
h2_result_t res;
h2_parse_preface_buf((const char *)CHROME_PREFACE, sizeof(CHROME_PREFACE), &res);
assert_string_equal(res.fingerprint,
"1:65536,2:0,4:6291456,6:262144|15663105|0|m,a,s,p");
}
static void test_firefox_settings_parsed(void **state)
{
(void)state;
h2_result_t res;
h2_parse_preface_buf((const char *)FIREFOX_PREFACE, sizeof(FIREFOX_PREFACE), &res);
assert_int_equal(res.is_h2, 1);
assert_string_equal(res.settings, "1:65536,4:131072,5:16384");
}
static void test_firefox_pseudo_order(void **state)
{
(void)state;
h2_result_t res;
h2_parse_preface_buf((const char *)FIREFOX_PREFACE, sizeof(FIREFOX_PREFACE), &res);
/* Firefox : :method(m), :path(p), :scheme(s), :authority(a) */
assert_string_equal(res.pseudo, "m,p,s,a");
}
static void test_firefox_fingerprint_akamai(void **state)
{
(void)state;
h2_result_t res;
h2_parse_preface_buf((const char *)FIREFOX_PREFACE, sizeof(FIREFOX_PREFACE), &res);
assert_string_equal(res.fingerprint,
"1:65536,4:131072,5:16384|12517377|0|m,p,s,a");
}
static void test_http1_not_detected(void **state)
{
(void)state;
h2_result_t res;
h2_parse_preface_buf(HTTP1_DATA, strlen(HTTP1_DATA), &res);
assert_int_equal(res.is_h2, 0);
assert_string_equal(res.settings, "");
assert_string_equal(res.fingerprint, "");
}
static void test_empty_buffer_not_detected(void **state)
{
(void)state;
h2_result_t res;
h2_parse_preface_buf("", 0, &res);
assert_int_equal(res.is_h2, 0);
}
static void test_truncated_preface_no_crash(void **state)
{
(void)state;
h2_result_t res;
/* Magic complet mais frame tronquée */
h2_parse_preface_buf((const char *)CHROME_PREFACE, 30, &res);
assert_int_equal(res.is_h2, 0); /* SETTINGS incomplet → pas de fingerprint */
}
static void test_hpack_int_single_byte(void **state)
{
(void)state;
/* Entier 7-bit < 127 → encodé sur 1 octet */
unsigned char buf[] = { 0x82 }; /* 0x80 | 2 → index=2 */
size_t pos = 0;
unsigned int out = 0;
int ok = hpack_int_decode(buf, 1, 7, &pos, &out);
assert_int_equal(ok, 1);
assert_int_equal(out, 2);
assert_int_equal(pos, 1);
}
static void test_hpack_pseudo_table(void **state)
{
(void)state;
assert_int_equal(h2_hpack_pseudo(1), 'a');
assert_int_equal(h2_hpack_pseudo(2), 'm');
assert_int_equal(h2_hpack_pseudo(3), 'm');
assert_int_equal(h2_hpack_pseudo(4), 'p');
assert_int_equal(h2_hpack_pseudo(5), 'p');
assert_int_equal(h2_hpack_pseudo(6), 's');
assert_int_equal(h2_hpack_pseudo(7), 's');
assert_int_equal(h2_hpack_pseudo(8), 0); /* header régulier */
assert_int_equal(h2_hpack_pseudo(62), 0);
}
static void test_pseudo_order_extraction_direct(void **state)
{
(void)state;
/* HPACK block : :method(0x82), :path(0x84), :scheme(0x87), :authority(0x81) */
unsigned char hpack[] = { 0x82, 0x84, 0x87, 0x81 };
char out[16];
h2_extract_pseudo_order(hpack, sizeof(hpack), out);
assert_string_equal(out, "m,p,s,a");
}
static void test_pseudo_order_stops_at_regular_header(void **state)
{
(void)state;
/* :method(0x82), puis header régulier (0x88 = index 8) */
unsigned char hpack[] = { 0x82, 0x88 };
char out[16];
h2_extract_pseudo_order(hpack, sizeof(hpack), out);
assert_string_equal(out, "m");
}
/* ====== main ====== */
int main(void)
{
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_chrome_settings_parsed),
cmocka_unit_test(test_chrome_window_update),
cmocka_unit_test(test_chrome_pseudo_order),
cmocka_unit_test(test_chrome_fingerprint_akamai),
cmocka_unit_test(test_firefox_settings_parsed),
cmocka_unit_test(test_firefox_pseudo_order),
cmocka_unit_test(test_firefox_fingerprint_akamai),
cmocka_unit_test(test_http1_not_detected),
cmocka_unit_test(test_empty_buffer_not_detected),
cmocka_unit_test(test_truncated_preface_no_crash),
cmocka_unit_test(test_hpack_int_single_byte),
cmocka_unit_test(test_hpack_pseudo_table),
cmocka_unit_test(test_pseudo_order_extraction_direct),
cmocka_unit_test(test_pseudo_order_stops_at_regular_header),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}