Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/ngx_http_modsecurity_access.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
143 changes: 143 additions & 0 deletions tests/modsecurity-h3.t
Original file line number Diff line number Diff line change
@@ -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', <<EOF);
[ req ]
default_bits = 2048
encrypt_key = no
distinguished_name = req_distinguished_name
[ req_distinguished_name ]
EOF

my $d = $t->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');

###############################################################################