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>
459 lines
15 KiB
C
459 lines
15 KiB
C
/*
|
||
* 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);
|
||
}
|