File Coverage

blib/lib/PAGI/Middleware/ReverseProxy.pm
Criterion Covered Total %
statement 55 89 61.8
branch 18 44 40.9
condition 7 25 28.0
subroutine 9 13 69.2
pod 1 1 100.0
total 90 172 52.3


line stmt bran cond sub pod time code
1             package PAGI::Middleware::ReverseProxy;
2              
3 1     1   472 use strict;
  1         1  
  1         34  
4 1     1   3 use warnings;
  1         1  
  1         39  
5 1     1   4 use parent 'PAGI::Middleware';
  1         2  
  1         4  
6 1     1   44 use Future::AsyncAwait;
  1         1  
  1         4  
7              
8             =head1 NAME
9              
10             PAGI::Middleware::ReverseProxy - Handle X-Forwarded-* headers from reverse proxies
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::Middleware::Builder;
15              
16             my $app = builder {
17             enable 'ReverseProxy',
18             trusted_proxies => ['127.0.0.1', '10.0.0.0/8'];
19             $my_app;
20             };
21              
22             =head1 DESCRIPTION
23              
24             PAGI::Middleware::ReverseProxy processes X-Forwarded-* headers from trusted
25             reverse proxies and updates the scope with the original client information.
26              
27             =head1 CONFIGURATION
28              
29             =over 4
30              
31             =item * trusted_proxies (default: ['127.0.0.1', '::1'])
32              
33             Arrayref of trusted proxy IP addresses or CIDR ranges.
34              
35             =item * trust_all (default: 0)
36              
37             If true, trust X-Forwarded headers from any source. Use with caution!
38              
39             =back
40              
41             =head1 HEADERS PROCESSED
42              
43             =over 4
44              
45             =item * X-Forwarded-For - Original client IP
46              
47             =item * X-Forwarded-Proto - Original protocol (http/https)
48              
49             =item * X-Forwarded-Host - Original Host header
50              
51             =item * X-Forwarded-Port - Original port
52              
53             =item * X-Real-IP - Alternative to X-Forwarded-For (nginx)
54              
55             =back
56              
57             =cut
58              
59             sub _init {
60 3     3   6 my ($self, $config) = @_;
61              
62 3   50     11 $self->{trusted_proxies} = $config->{trusted_proxies} // ['127.0.0.1', '::1'];
63 3   50     13 $self->{trust_all} = $config->{trust_all} // 0;
64             }
65              
66             sub wrap {
67 3     3 1 23 my ($self, $app) = @_;
68              
69 3     3   66 return async sub {
70 3         6 my ($scope, $receive, $send) = @_;
71 3 50       7 if ($scope->{type} ne 'http') {
72 0         0 await $app->($scope, $receive, $send);
73 0         0 return;
74             }
75              
76             # Check if request is from trusted proxy
77 3 50 50     10 my $client_ip = exists $scope->{client} ? ($scope->{client}[0] // '') : '';
78 3 100 66     10 unless ($self->{trust_all} || $self->_is_trusted($client_ip)) {
79 1         3 await $app->($scope, $receive, $send);
80 1         87 return;
81             }
82              
83             # Build modified scope
84 2         9 my %new_scope = %$scope;
85              
86             # X-Forwarded-For or X-Real-IP
87 2         7 my $forwarded_for = $self->_get_header($scope, 'x-forwarded-for');
88 2         4 my $real_ip = $self->_get_header($scope, 'x-real-ip');
89              
90 2 100       6 if ($forwarded_for) {
    50          
91             # Take the leftmost IP (original client)
92 1         8 my ($original_ip) = split /\s*,\s*/, $forwarded_for;
93 1         4 $original_ip =~ s/^\s+//;
94 1         2 $original_ip =~ s/\s+$//;
95 1 50       3 if (exists $scope->{client}) {
96 1         2 $new_scope{client} = [$original_ip, $scope->{client}[1]];
97 1         2 $new_scope{original_client} = $scope->{client};
98             } else {
99 0         0 $new_scope{client} = [$original_ip, undef];
100             }
101             } elsif ($real_ip) {
102 0 0       0 if (exists $scope->{client}) {
103 0         0 $new_scope{client} = [$real_ip, $scope->{client}[1]];
104 0         0 $new_scope{original_client} = $scope->{client};
105             } else {
106 0         0 $new_scope{client} = [$real_ip, undef];
107             }
108             }
109              
110             # X-Forwarded-Proto
111 2         6 my $forwarded_proto = $self->_get_header($scope, 'x-forwarded-proto');
112 2 100       5 if ($forwarded_proto) {
113 1         2 $forwarded_proto = lc($forwarded_proto);
114 1 50       6 $new_scope{scheme} = $forwarded_proto if $forwarded_proto =~ /^https?$/;
115             }
116              
117             # X-Forwarded-Host
118 2         5 my $forwarded_host = $self->_get_header($scope, 'x-forwarded-host');
119 2 50       4 if ($forwarded_host) {
120             # Update headers with new Host
121 0         0 my @new_headers;
122 0   0     0 for my $h (@{$scope->{headers} // []}) {
  0         0  
123 0 0       0 if (lc($h->[0]) eq 'host') {
124 0         0 push @new_headers, ['host', $forwarded_host];
125             } else {
126 0         0 push @new_headers, $h;
127             }
128             }
129 0         0 $new_scope{headers} = \@new_headers;
130             }
131              
132             # X-Forwarded-Port
133 2         4 my $forwarded_port = $self->_get_header($scope, 'x-forwarded-port');
134 2 50 33     5 if ($forwarded_port && $forwarded_port =~ /^\d+$/) {
135 0         0 $new_scope{server} = [$scope->{server}[0], int($forwarded_port)];
136             }
137              
138 2         5 await $app->(\%new_scope, $receive, $send);
139 3         15 };
140             }
141              
142             sub _is_trusted {
143 3     3   4 my ($self, $ip) = @_;
144              
145 3         5 for my $trusted (@{$self->{trusted_proxies}}) {
  3         29  
146 3 50       9 if ($trusted =~ m{/}) {
147             # CIDR notation
148 0 0       0 return 1 if $self->_ip_in_cidr($ip, $trusted);
149             } else {
150             # Exact match
151 3 100       12 return 1 if $ip eq $trusted;
152             }
153             }
154 1         4 return 0;
155             }
156              
157             sub _ip_in_cidr {
158 0     0   0 my ($self, $ip, $cidr) = @_;
159              
160 0         0 my ($network, $bits) = split m{/}, $cidr;
161              
162             # Simple IPv4 check
163 0 0 0     0 return 0 unless $ip =~ /^[\d.]+$/ && $network =~ /^[\d.]+$/;
164              
165 0         0 my $ip_num = $self->_ip_to_num($ip);
166 0         0 my $net_num = $self->_ip_to_num($network);
167              
168 0 0 0     0 return 0 unless defined $ip_num && defined $net_num;
169              
170 0         0 my $mask = ~((1 << (32 - $bits)) - 1) & 0xFFFFFFFF;
171 0         0 return ($ip_num & $mask) == ($net_num & $mask);
172             }
173              
174             sub _ip_to_num {
175 0     0   0 my ($self, $ip) = @_;
176              
177 0         0 my @octets = split /\./, $ip;
178 0 0       0 return unless @octets == 4;
179 0 0 0 0   0 return unless _all(sub { /^\d+$/ && $_ >= 0 && $_ <= 255 }, @octets);
  0 0       0  
180              
181 0         0 return ($octets[0] << 24) + ($octets[1] << 16) + ($octets[2] << 8) + $octets[3];
182             }
183              
184             sub _all {
185 0     0   0 my ($code, @list) = @_;
186 0         0 for (@list) {
187 0 0       0 return 0 unless $code->();
188             }
189 0         0 return 1;
190             }
191              
192             sub _get_header {
193 10     10   14 my ($self, $scope, $name) = @_;
194              
195 10         13 $name = lc($name);
196 10   50     8 for my $h (@{$scope->{headers} // []}) {
  10         17  
197 10 100       20 return $h->[1] if lc($h->[0]) eq $name;
198             }
199 8         10 return;
200             }
201              
202             1;
203              
204             __END__