File Coverage

lib/PAGI/Middleware/TrustedHosts.pm
Criterion Covered Total %
statement 48 56 85.7
branch 7 12 58.3
condition 4 9 44.4
subroutine 10 10 100.0
pod 1 1 100.0
total 70 88 79.5


line stmt bran cond sub pod time code
1             package PAGI::Middleware::TrustedHosts;
2              
3 1     1   445 use strict;
  1         2  
  1         35  
4 1     1   4 use warnings;
  1         2  
  1         37  
5 1     1   3 use parent 'PAGI::Middleware';
  1         1  
  1         3  
6 1     1   43 use Future::AsyncAwait;
  1         2  
  1         4  
7              
8             =head1 NAME
9              
10             PAGI::Middleware::TrustedHosts - Host header validation middleware
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::Middleware::Builder;
15              
16             my $app = builder {
17             enable 'TrustedHosts',
18             hosts => ['example.com', 'www.example.com', '*.example.com'];
19             $my_app;
20             };
21              
22             =head1 DESCRIPTION
23              
24             PAGI::Middleware::TrustedHosts validates the Host header against a list
25             of allowed hosts. This helps prevent host header injection attacks.
26              
27             =head1 CONFIGURATION
28              
29             =over 4
30              
31             =item * hosts (required)
32              
33             Array of allowed host patterns. Patterns can include:
34             - Exact hostnames: 'example.com'
35             - Wildcard subdomains: '*.example.com'
36             - Port specifications: 'example.com:8080'
37              
38             =item * allow_empty (default: 0)
39              
40             If true, allow requests without a Host header.
41              
42             =back
43              
44             =cut
45              
46             sub _init {
47 3     3   5 my ($self, $config) = @_;
48              
49 3   50     12 $self->{hosts} = $config->{hosts} // die "TrustedHosts requires 'hosts' option";
50 3   50     12 $self->{allow_empty} = $config->{allow_empty} // 0;
51              
52             # Compile host patterns to regexes
53 3         4 $self->{_patterns} = [map { $self->_compile_pattern($_) } @{$self->{hosts}}];
  4         9  
  3         7  
54             }
55              
56             sub _compile_pattern {
57 4     4   5 my ($self, $pattern) = @_;
58              
59             # Escape regex special chars except *
60 4         8 my $escaped = quotemeta($pattern);
61             # Convert escaped * back to regex wildcard
62 4         10 $escaped =~ s/\\\*/.*/g;
63 4         77 return qr/^$escaped$/i;
64             }
65              
66             sub wrap {
67 3     3 1 25 my ($self, $app) = @_;
68              
69 3     3   45 return async sub {
70 3         5 my ($scope, $receive, $send) = @_;
71             # Only handle HTTP requests
72 3 50       8 if ($scope->{type} ne 'http') {
73 0         0 await $app->($scope, $receive, $send);
74 0         0 return;
75             }
76              
77             # Get Host header
78 3         6 my $host = $self->_get_header($scope, 'host');
79              
80             # Check if host is allowed
81 3 50 33     13 if (!defined $host || $host eq '') {
82 0 0       0 if ($self->{allow_empty}) {
83 0         0 await $app->($scope, $receive, $send);
84 0         0 return;
85             }
86 0         0 await $self->_send_error($send, 400, 'Missing Host header');
87 0         0 return;
88             }
89              
90             # Strip port for matching if needed
91 3         5 my $host_for_match = $host;
92              
93             # Check against patterns
94 3         3 my $allowed = 0;
95 3         2 for my $pattern (@{$self->{_patterns}}) {
  3         6  
96 3 100       18 if ($host_for_match =~ $pattern) {
97 2         3 $allowed = 1;
98 2         3 last;
99             }
100             }
101              
102 3 100       6 if ($allowed) {
103 2         4 await $app->($scope, $receive, $send);
104             } else {
105 1         10 await $self->_send_error($send, 400, 'Invalid Host header');
106             }
107 3         10 };
108             }
109              
110             sub _get_header {
111 3     3   5 my ($self, $scope, $name) = @_;
112              
113 3         5 $name = lc($name);
114 3   50     4 for my $h (@{$scope->{headers} // []}) {
  3         7  
115 3 50       11 return $h->[1] if lc($h->[0]) eq $name;
116             }
117 0         0 return;
118             }
119              
120 1     1   2 async sub _send_error {
121 1         2 my ($self, $send, $status, $message) = @_;
122              
123 1         6 await $send->({
124             type => 'http.response.start',
125             status => $status,
126             headers => [
127             ['content-type', 'text/plain'],
128             ['content-length', length($message)],
129             ],
130             });
131 1         56 await $send->({
132             type => 'http.response.body',
133             body => $message,
134             more => 0,
135             });
136             }
137              
138             1;
139              
140             __END__