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   419 use strict;
  1         5  
  1         32  
4 1     1   3 use warnings;
  1         1  
  1         37  
5 1     1   5 use parent 'PAGI::Middleware';
  1         1  
  1         4  
6 1     1   45 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   7 my ($self, $config) = @_;
48              
49 3   50     19 $self->{hosts} = $config->{hosts} // die "TrustedHosts requires 'hosts' option";
50 3   50     19 $self->{allow_empty} = $config->{allow_empty} // 0;
51              
52             # Compile host patterns to regexes
53 3         7 $self->{_patterns} = [map { $self->_compile_pattern($_) } @{$self->{hosts}}];
  4         43  
  3         9  
54             }
55              
56             sub _compile_pattern {
57 4     4   10 my ($self, $pattern) = @_;
58              
59             # Escape regex special chars except *
60 4         14 my $escaped = quotemeta($pattern);
61             # Convert escaped * back to regex wildcard
62 4         13 $escaped =~ s/\\\*/.*/g;
63 4         136 return qr/^$escaped$/i;
64             }
65              
66             sub wrap {
67 3     3 1 38 my ($self, $app) = @_;
68              
69 3     3   68 return async sub {
70 3         7 my ($scope, $receive, $send) = @_;
71             # Only handle HTTP requests
72 3 50       15 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         10 my $host = $self->_get_header($scope, 'host');
79              
80             # Check if host is allowed
81 3 50 33     19 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         6 my $host_for_match = $host;
92              
93             # Check against patterns
94 3         5 my $allowed = 0;
95 3         6 for my $pattern (@{$self->{_patterns}}) {
  3         7  
96 3 100       32 if ($host_for_match =~ $pattern) {
97 2         3 $allowed = 1;
98 2         6 last;
99             }
100             }
101              
102 3 100       9 if ($allowed) {
103 2         8 await $app->($scope, $receive, $send);
104             } else {
105 1         6 await $self->_send_error($send, 400, 'Invalid Host header');
106             }
107 3         19 };
108             }
109              
110             sub _get_header {
111 3     3   7 my ($self, $scope, $name) = @_;
112              
113 3         8 $name = lc($name);
114 3   50     5 for my $h (@{$scope->{headers} // []}) {
  3         13  
115 3 50       16 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         4 my ($self, $send, $status, $message) = @_;
122              
123 1         11 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         81 await $send->({
132             type => 'http.response.body',
133             body => $message,
134             more => 0,
135             });
136             }
137              
138             1;
139              
140             __END__