From b95a97cad4cff5f4661fcf49601614fd6cc54e8d Mon Sep 17 00:00:00 2001 From: Miki Date: Thu, 4 Dec 2025 02:09:02 -0800 Subject: [PATCH] Add HTTP/3 Host header support for ModSecurity ModSecurity cannot see the Host header in HTTP/3 requests because HTTP/3 uses the `:authority` pseudo-header, which nginx parses into `r->headers_in.server` but doesn't add to the headers list. This commit: - Adds `NGX_HTTP_VERSION_30` case to `http_version` switch - Manually extracts Host from `r->headers_in.server` for HTTP/3 requests - Adds Host header to ModSecurity transaction before processing other headers Fixes #305 false positives from OWASP CRS rule 920280 (Missing Host Header) on HTTP/3 connections. Tested with nginx 1.29.3 and ModSecurity 3.0.13. --- src/ngx_http_modsecurity_access.c | 24 ++++- tests/modsecurity-h3.t | 143 ++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 tests/modsecurity-h3.t diff --git a/src/ngx_http_modsecurity_access.c b/src/ngx_http_modsecurity_access.c index effa8a91..ed543e58 100644 --- a/src/ngx_http_modsecurity_access.c +++ b/src/ngx_http_modsecurity_access.c @@ -198,6 +198,11 @@ ngx_http_modsecurity_access_handler(ngx_http_request_t *r) case NGX_HTTP_VERSION_20 : http_version = "2.0"; break; +#endif +#if defined(nginx_version) && nginx_version >= 1025000 + case NGX_HTTP_VERSION_30 : + http_version = "3.0"; + break; #endif default : http_version = ngx_str_to_char(r->http_protocol, r->pool); @@ -233,9 +238,26 @@ ngx_http_modsecurity_access_handler(ngx_http_request_t *r) } /** - * Since incoming request headers are already in place, lets send it to ModSecurity + * HTTP/3 uses :authority pseudo-header instead of Host header and nginx + * parses it into r->headers_in.server (see ngx_http_v3_request.c#L982) + * but doesn't add it to the headers list, so ModSecurity never sees it. * + * Per RFC 9114 ยง4.3.1, when an HTTP/3 request is normalized to HTTP/1.1, + * a `Host` header must be generated from the `:authority` pseudo-header + * if `Host` is absent. This code does not check for a pre-existing `Host` + * header because, if present, it will be overwritten by the subsequent + * call to `msc_add_n_request_header()`. */ + if (strcmp(http_version, "3.0") == 0 && r->headers_in.server.len > 0) { + dd("adding Host header from :authority: %.*s", + (int)r->headers_in.server.len, r->headers_in.server.data); + + msc_add_n_request_header(ctx->modsec_transaction, + (const unsigned char *)"Host", 4, + (const unsigned char *)r->headers_in.server.data, + r->headers_in.server.len); + } + ngx_list_part_t *part = &r->headers_in.headers.part; ngx_table_elt_t *data = part->elts; ngx_uint_t i = 0; diff --git a/tests/modsecurity-h3.t b/tests/modsecurity-h3.t new file mode 100644 index 00000000..ff6198d1 --- /dev/null +++ b/tests/modsecurity-h3.t @@ -0,0 +1,143 @@ +#!/usr/bin/perl + +# Tests for ModSecurity module (HTTP/3). +# Tests that Host header from :authority pseudo-header is passed to ModSecurity. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::HTTP3; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http http_v3/) + ->has_daemon('openssl'); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + ssl_certificate_key localhost.key; + ssl_certificate localhost.crt; + + server { + listen 127.0.0.1:%%PORT_8980_UDP%% quic; + server_name localhost; + + location / { + modsecurity on; + modsecurity_rules ' + SecRuleEngine On + SecRule ARGS "@streq whee" "id:10,phase:2" + '; + return 200 "OK"; + } + + location /check-host { + modsecurity on; + modsecurity_rules ' + SecRuleEngine On + SecRule &REQUEST_HEADERS:Host "@gt 0" "id:999,phase:1,log,pass,msg:Host header FOUND with value %{REQUEST_HEADERS.Host}" + SecRule &REQUEST_HEADERS:Host "@eq 0" "id:920280,phase:1,deny,status:449,msg:Missing Host Header" + '; + return 200 "Host header present"; + } + + location /inspect-host { + modsecurity on; + modsecurity_rules ' + SecRuleEngine On + SecRule REQUEST_HEADERS:Host "@streq localhost" "id:100,phase:1,pass,setvar:tx.host_matched=1" + SecRule TX:host_matched "!@eq 1" "id:101,phase:1,deny,status:400,msg:Host header mismatch" + '; + return 200 "Host matched"; + } + + } +} +EOF + +$t->write_file('openssl.conf', <testdir(); + +foreach my $name ('localhost') { + system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=$name/ " + . "-out $d/$name.crt -keyout $d/$name.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for $name: $!\n"; +} + +$t->run(); +$t->plan(3); + +############################################################################### + +my ($s, $sid, $frames, $frame); + +$s = Test::Nginx::HTTP3->new(); +$sid = $s->new_stream({ + headers => [ + { name => ':method', value => 'GET', mode => 0 }, + { name => ':scheme', value => 'http', mode => 0 }, + { name => ':path', value => '/', mode => 0 }, + { name => ':authority', value => 'localhost', mode => 4 }, + ] +}); +$frames = $s->read(all => [{ sid => $sid, fin => 1 }]); +($frame) = grep { $_->{type} eq "HEADERS" } @$frames; +is($frame->{headers}->{':status'}, 200, 'basic HTTP/3 request'); + +$s = Test::Nginx::HTTP3->new(); +$sid = $s->new_stream({ + headers => [ + { name => ':method', value => 'GET', mode => 0 }, + { name => ':scheme', value => 'http', mode => 0 }, + { name => ':path', value => '/check-host', mode => 4 }, + { name => ':authority', value => 'localhost', mode => 4 }, + ] +}); +$frames = $s->read(all => [{ sid => $sid, fin => 1 }]); +($frame) = grep { $_->{type} eq "HEADERS" } @$frames; +is($frame->{headers}->{':status'}, 200, 'Host header from :authority visible to ModSecurity'); + +$s = Test::Nginx::HTTP3->new(); +$sid = $s->new_stream({ + headers => [ + { name => ':method', value => 'GET', mode => 0 }, + { name => ':scheme', value => 'http', mode => 0 }, + { name => ':path', value => '/inspect-host', mode => 4 }, + { name => ':authority', value => 'localhost', mode => 4 }, + ] +}); +$frames = $s->read(all => [{ sid => $sid, fin => 1 }]); +($frame) = grep { $_->{type} eq "HEADERS" } @$frames; +is($frame->{headers}->{':status'}, 200, 'Host header value matches :authority'); + +###############################################################################