Skip to content

Commit b95a97c

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 b95a97c

File tree

2 files changed

+166
-1
lines changed

2 files changed

+166
-1
lines changed

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

0 commit comments

Comments
 (0)