Skip to content

Commit 6abcc17

Browse files
committed
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.
1 parent b94f2d3 commit 6abcc17

2 files changed

Lines changed: 152 additions & 1 deletion

File tree

src/ngx_http_modsecurity_access.c

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,11 @@ ngx_http_modsecurity_access_handler(ngx_http_request_t *r)
198198
case NGX_HTTP_VERSION_20 :
199199
http_version = "2.0";
200200
break;
201+
#endif
202+
#if defined(nginx_version) && nginx_version >= 1025000
203+
case NGX_HTTP_VERSION_30 :
204+
http_version = "3.0";
205+
break;
201206
#endif
202207
default :
203208
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)
233238
}
234239

235240
/**
236-
* Since incoming request headers are already in place, lets send it to ModSecurity
241+
* HTTP/3 uses :authority pseudo-header instead of Host header and nginx
242+
* parses it into r->headers_in.server (see ngx_http_v3_request.c#L982)
243+
* but doesn't add it to the headers list, so ModSecurity never sees it.
237244
*
245+
* Per RFC 9114 §4.3.1, when an HTTP/3 request is normalized to HTTP/1.1,
246+
* a `Host` header must be generated from the `:authority` pseudo-header
247+
* if `Host` is absent. This code does not check for a pre-existing `Host`
248+
* header because, if present, it will be overwritten by the subsequent
249+
* call to `msc_add_n_request_header()`.
238250
*/
251+
if (strcmp(http_version, "3.0") == 0 && r->headers_in.server.len > 0) {
252+
dd("adding Host header from :authority: %.*s",
253+
(int)r->headers_in.server.len, r->headers_in.server.data);
254+
255+
msc_add_n_request_header(ctx->modsec_transaction,
256+
(const unsigned char *)"Host", 4,
257+
(const unsigned char *)r->headers_in.server.data,
258+
r->headers_in.server.len);
259+
}
260+
239261
ngx_list_part_t *part = &r->headers_in.headers.part;
240262
ngx_table_elt_t *data = part->elts;
241263
ngx_uint_t i = 0;

tests/modsecurity-h3.t

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
#!/usr/bin/perl
2+
3+
# Tests for ModSecurity module (HTTP/3).
4+
# Specifically tests that the Host header from :authority pseudo-header
5+
# is correctly passed to ModSecurity.
6+
7+
###############################################################################
8+
9+
use warnings;
10+
use strict;
11+
12+
use Test::More;
13+
14+
BEGIN { use FindBin; chdir($FindBin::Bin); }
15+
16+
use lib 'lib';
17+
use Test::Nginx;
18+
use Test::Nginx::HTTP3;
19+
20+
###############################################################################
21+
22+
select STDERR; $| = 1;
23+
select STDOUT; $| = 1;
24+
25+
my $t = Test::Nginx->new()->has(qw/http http_v3/)
26+
->has_daemon('openssl');
27+
28+
$t->write_file_expand('nginx.conf', <<'EOF');
29+
30+
%%TEST_GLOBALS%%
31+
32+
daemon off;
33+
34+
events {
35+
}
36+
37+
http {
38+
%%TEST_GLOBALS_HTTP%%
39+
40+
ssl_certificate_key localhost.key;
41+
ssl_certificate localhost.crt;
42+
43+
server {
44+
listen 127.0.0.1:%%PORT_8980_UDP%% quic;
45+
server_name localhost;
46+
47+
location / {
48+
modsecurity on;
49+
modsecurity_rules '
50+
SecRuleEngine On
51+
SecRule ARGS "@streq whee" "id:10,phase:2"
52+
';
53+
return 200 "OK";
54+
}
55+
56+
# Test location that checks for Host header presence
57+
# This simulates CRS rule 920280 behavior
58+
location /check-host {
59+
modsecurity on;
60+
modsecurity_rules '
61+
SecRuleEngine On
62+
SecRule &REQUEST_HEADERS:Host "@eq 0" "id:920280,phase:1,deny,status:400,msg:Missing Host Header"
63+
';
64+
return 200 "Host header present";
65+
}
66+
67+
# Test location that inspects the Host header value
68+
location /inspect-host {
69+
modsecurity on;
70+
modsecurity_rules '
71+
SecRuleEngine On
72+
SecRule REQUEST_HEADERS:Host "@streq localhost" "id:100,phase:1,pass,setvar:tx.host_matched=1"
73+
SecRule TX:host_matched "!@eq 1" "id:101,phase:1,deny,status:400,msg:Host header mismatch"
74+
';
75+
return 200 "Host matched";
76+
}
77+
78+
}
79+
}
80+
EOF
81+
82+
$t->write_file('openssl.conf', <<EOF);
83+
[ req ]
84+
default_bits = 2048
85+
encrypt_key = no
86+
distinguished_name = req_distinguished_name
87+
[ req_distinguished_name ]
88+
EOF
89+
90+
my $d = $t->testdir();
91+
92+
foreach my $name ('localhost') {
93+
system('openssl req -x509 -new '
94+
. "-config $d/openssl.conf -subj /CN=$name/ "
95+
. "-out $d/$name.crt -keyout $d/$name.key "
96+
. ">>$d/openssl.out 2>&1") == 0
97+
or die "Can't create certificate for $name: $!\n";
98+
}
99+
100+
$t->run();
101+
$t->plan(3);
102+
103+
###############################################################################
104+
105+
my ($s, $sid, $frames, $frame);
106+
107+
# Test 1: Basic HTTP/3 request works
108+
$s = Test::Nginx::HTTP3->new();
109+
$sid = $s->new_stream({ path => '/' });
110+
$frames = $s->read(all => [{ sid => $sid, fin => 1 }]);
111+
($frame) = grep { $_->{type} eq "HEADERS" } @$frames;
112+
is($frame->{headers}->{':status'}, 200, 'basic HTTP/3 request');
113+
114+
# Test 2: Host header presence check (simulating CRS 920280)
115+
# This should pass because :authority is converted to Host header
116+
$s = Test::Nginx::HTTP3->new();
117+
$sid = $s->new_stream({ path => '/check-host' });
118+
$frames = $s->read(all => [{ sid => $sid, fin => 1 }]);
119+
($frame) = grep { $_->{type} eq "HEADERS" } @$frames;
120+
is($frame->{headers}->{':status'}, 200, 'Host header from :authority is visible to ModSecurity');
121+
122+
# Test 3: Host header value inspection
123+
$s = Test::Nginx::HTTP3->new();
124+
$sid = $s->new_stream({ path => '/inspect-host' });
125+
$frames = $s->read(all => [{ sid => $sid, fin => 1 }]);
126+
($frame) = grep { $_->{type} eq "HEADERS" } @$frames;
127+
is($frame->{headers}->{':status'}, 200, 'Host header value matches :authority');
128+
129+
###############################################################################

0 commit comments

Comments
 (0)