File Coverage

blib/lib/Mojolicious/Plugin/TrustedProxy.pm
Criterion Covered Total %
statement 116 117 100.0
branch 45 54 83.3
condition 24 38 63.1
subroutine 11 11 100.0
pod 1 1 100.0
total 197 221 89.5


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::TrustedProxy;
2              
3             # https://github.com/Kage/Mojolicious-Plugin-TrustedProxy
4              
5 7     7   545473 use Mojo::Base 'Mojolicious::Plugin';
  7         23  
  7         46  
6 7     7   1110 use Mojo::Util qw(trim monkey_patch);
  7         18  
  7         412  
7 7     7   3639 use Data::Validate::IP qw(is_ip is_ipv4_mapped_ipv6);
  7         215164  
  7         708  
8 7     7   3868 use Net::CIDR::Lite;
  7         28676  
  7         275  
9 7     7   3862 use Net::IP::Lite qw(ip_transform);
  7         36548  
  7         698  
10              
11             our $VERSION = '0.04';
12              
13 7   50 7   65 use constant DEBUG => $ENV{MOJO_TRUSTEDPROXY_DEBUG} || 0;
  7         21  
  7         12394  
14              
15             sub register {
16 8     8 1 9199   my ($self, $app, $conf) = @_;
17              
18 8         17   $app->log->debug(sprintf('[%s] VERSION = %s', __PACKAGE__, $VERSION))
19                 if DEBUG;
20              
21             # Normalize config and set defaults
22 8   100     70   $conf->{ip_headers} //= ['x-forwarded-for', 'x-real-ip'];
23               $conf->{ip_headers} = [$conf->{ip_headers}]
24 8 50       37     unless ref($conf->{ip_headers}) eq 'ARRAY';
25              
26 8   100     48   $conf->{scheme_headers} //= ['x-forwarded-proto', 'x-ssl'];
27               $conf->{scheme_headers} = [$conf->{scheme_headers}]
28 8 50       28     unless ref($conf->{scheme_headers}) eq 'ARRAY';
29              
30 8   100     64   $conf->{https_values} //= ['https', 'on', '1', 'true', 'enable', 'enabled'];
31               $conf->{https_values} = [$conf->{https_values}]
32 8 50       43     unless ref($conf->{https_values}) eq 'ARRAY';
33              
34 8   50     71   $conf->{parse_rfc7239} //= ($conf->{parse_forwarded} // 1);
      33        
35              
36 8   100     48   $conf->{trusted_sources} //= ['127.0.0.0/8', '10.0.0.0/8'];
37               $conf->{trusted_sources} = [$conf->{trusted_sources}]
38 8 100       29     unless ref($conf->{trusted_sources}) eq 'ARRAY';
39              
40 8   100     53   $conf->{hide_headers} //= 0;
41              
42             # Monkey patch a remote_proxy_address attribute into Mojo::Transaction
43               monkey_patch 'Mojo::Transaction',
44                 'remote_proxy_address' => sub {
45 16     16   3534       my $self = shift;
        16      
46 16 100       70       return $self->{remote_proxy_addr} unless @_;
47 13         37       $self->{remote_proxy_addr} = shift;
48 13         44       return $self;
49 8         64     };
50              
51             # Assemble trusted source CIDR map
52 8         346   my $cidr = Net::CIDR::Lite->new;
53 8         91   foreach my $trust (@{$conf->{trusted_sources}}) {
  8         28  
54 13 50       172     if (ref($trust) eq 'ARRAY') {
55 0         0       $cidr->add_any(@$trust); # uncoverable statement
56                 } else {
57 13         131       $cidr->add_any($trust);
58                 }
59 13         2246     $cidr->clean;
60               }
61               $app->defaults(
62 8         314     'trustedproxy.conf' => $conf,
63                 'trustedproxy.cidr' => $cidr,
64               );
65              
66             # Register helper
67               $app->helper(is_trusted_source => sub {
68 24     24   356     my $c = shift;
69 24   33     113     my $ip = shift || $c->tx->remote_proxy_address || $c->tx->remote_address;
70 24         67     my $cidr = $c->stash('trustedproxy.cidr');
71                 return undef unless
72 24 50 33     306       is_ip($ip) && $cidr && $cidr->isa('Net::CIDR::Lite');
      33        
73 24 50       1592     $ip = ip_transform($ip, {convert_to => 'ipv4'}) if is_ipv4_mapped_ipv6($ip);
74 24         975     $c->app->log->debug(sprintf(
75                   '[%s] Testing if IP address "%s" is in trusted sources list',
76                   __PACKAGE__, $ip)) if DEBUG;
77 24         150     return $cidr->find($ip);
78 8         285   });
79              
80             # Register hook
81               $app->hook(around_dispatch => sub {
82 24     24   245517     my ($next, $c) = @_;
83 24         123     my $conf = $c->stash('trustedproxy.conf');
84 24 50       337     return $next->() unless defined $conf;
85              
86             # Validate that the upstream source IP is within the CIDR map
87 24         89     my $src_addr = $c->tx->remote_address;
88 24 100 66     556     unless (defined $src_addr && $c->is_trusted_source($src_addr)) {
89 4         241       $c->app->log->debug(sprintf(
90                     '[%s] %s not found in trusted_sources CIDR map',
91                     __PACKAGE__, $src_addr)) if DEBUG;
92 4         11       return $next->();
93                 }
94              
95             # Set forwarded IP address from header
96 20         1562     foreach my $header (@{$conf->{ip_headers}}) {
  20         75  
97 33 100       534       if (my $ip = $c->req->headers->header($header)) {
98 9         309         $ip = trim lc $ip;
99 9 100       153         if (lc $header eq 'x-forwarded-for') {
100 5         26           my @xff = split /\s*,\s*/, $ip;
101 5         20           $ip = trim $xff[0];
102                     }
103 9         50         $c->app->log->debug(sprintf(
104                       '[%s] Matched on IP header "%s" (value: "%s")',
105                       __PACKAGE__, $header, $ip)) if DEBUG;
106 9 100       32         $c->tx->remote_address($ip) if is_ip($ip);
107 9         335         $c->tx->remote_proxy_address($src_addr);
108 9         26         last;
109                   }
110                 }
111              
112             # Set forwarded scheme from header
113 20         265     foreach my $header (@{$conf->{scheme_headers}}) {
  20         83  
114 37 100       454       if (my $scheme = $c->req->headers->header($header)) {
115 6         148         $scheme = trim lc $scheme;
116 6 100 66     78         if (!!$scheme && grep { $scheme eq lc $_ } @{$conf->{https_values}}) {
  31         88  
  6         25  
117 4         15           $c->app->log->debug(sprintf(
118                         '[%s] Matched on HTTPS header "%s" (value: "%s")',
119                         __PACKAGE__, $header, $scheme)) if DEBUG;
120 4         22           $c->req->url->base->scheme('https');
121 4         78           last;
122                     }
123                   }
124                 }
125              
126             # Parse RFC-7239 ("Forwarded" header) if present
127 20 100       350     if (my $fwd = $c->req->headers->header('forwarded')) {
128 5 50       112       if ($conf->{parse_rfc7239}) {
129 5         18         $fwd = trim lc $fwd;
130 5         53         $c->app->log->debug(sprintf(
131                       '[%s] Matched on Forwarded header (value: "%s")',
132                       __PACKAGE__, $fwd)) if DEBUG;
133 5         18         my @pairs = map { split /\s*,\s*/, $_ } split ';', $fwd;
  8         26  
134 5         15         my ($fwd_for, $fwd_by, $fwd_proto, $fwd_host);
135 5         19         my $ipv4_mask = qr/\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}/;
136 5         19         my $ipv6_mask = qr/(([0-9a-fA-F]{0,4})([:|.])){2,7}([0-9a-fA-F]{0,4})/;
137 5         19         foreach my $param (@pairs) {
138 8         32           $param = trim $param;
139 8 100       217           if ($param =~ /(for|by)=($ipv4_mask|$ipv6_mask)/i) {
    100          
    50          
140 4 100       22             $fwd_for = $2 if lc $1 eq 'for';
141 4 100       20             $fwd_by = $2 if lc $1 eq 'by';
142                       } elsif ($param =~ /proto=(https?)/i) {
143 2         8             $fwd_proto = $1;
144                       } elsif ($param =~ /host=((([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]))$/i) {
145 2         8             $fwd_host = $1;
146                       }
147                     }
148 5 100 66     22         if ($fwd_for && is_ip($fwd_for)) {
149 2         59           $c->app->log->debug(sprintf(
150                         '[%s] Matched Forwarded header "for" parameter (value: "%s")',
151                         __PACKAGE__, $fwd_for)) if DEBUG;
152 2         7           $c->tx->remote_address($fwd_for);
153 2         30           $c->tx->remote_proxy_address($src_addr);
154                     }
155 5 100 66     30         if ($fwd_by && is_ip($fwd_by)) {
156 2         48           $c->app->log->debug(sprintf(
157                         '[%s] Matched Forwarded header "by" parameter (value: "%s")',
158                         __PACKAGE__, $fwd_by)) if DEBUG;
159 2         21           $c->tx->remote_proxy_address($fwd_by);
160                     }
161 5 100       15         if ($fwd_proto) {
162 2         3           $c->app->log->debug(sprintf(
163                         '[%s] Matched Forwarded header "proto" parameter (value: "%s")',
164                         __PACKAGE__, $fwd_proto)) if DEBUG;
165 2         8           $c->req->url->base->scheme($fwd_proto);
166                     }
167 5 100       53         if ($fwd_host) {
168 2         3           $c->app->log->debug(sprintf(
169                         '[%s] Matched Forwarded header "host" parameter (value: "%s")',
170                         __PACKAGE__, $fwd_host)) if DEBUG;
171 2         7           $c->req->url->base->host($fwd_host);
172                     }
173                   }
174                 }
175              
176             # Hide headers from the rest of the application
177 20 100       369     if (!!$conf->{hide_headers}) {
178 1         2       $c->app->log->debug(sprintf(
179                     '[%s] Removing headers from request', __PACKAGE__)) if DEBUG;
180 1         2       $c->req->headers->remove($_) foreach @{$conf->{ip_headers}};
  1         8  
181 1         41       $c->req->headers->remove($_) foreach @{$conf->{scheme_headers}};
  1         8  
182 1         34       $c->req->headers->remove('forwarded');
183                 }
184              
185             # Carry on :)
186 20         80     $next->();
187 8         1178   });
188              
189             }
190              
191             1;
192             __END__
193            
194             =pod
195            
196             =head1 NAME
197            
198             Mojolicious::Plugin::TrustedProxy - Mojolicious plugin to set the remote
199             address, connection scheme, and more from trusted upstream proxies
200            
201             =head1 VERSION
202            
203             Version 0.04
204            
205             =head1 SYNOPSIS
206            
207             use Mojolicious::Lite;
208            
209             plugin 'TrustedProxy' => {
210             ip_headers => ['x-forwarded-for', 'x-real-ip'],
211             scheme_headers => ['x-forwarded-proto', 'x-ssl'],
212             https_values => ['https', 'on', '1', 'true', 'enable', 'enabled'],
213             parse_rfc7239 => 1,
214             trusted_sources => ['127.0.0.0/8', '10.0.0.0/8'],
215             hide_headers => 0,
216             };
217            
218             # Example of how you could verify expected functionality
219             get '/test' => sub {
220             my $c = shift;
221             $c->render(json => {
222             'tx.remote_address' => $c->tx->remote_address,
223             'tx.remote_proxy_address' => $c->tx->remote_proxy_address,
224             'req.url.base.scheme' => $c->req->url->base->scheme,
225             'req.url.base.host' => $c->req->url->base->host,
226             'is_trusted_source' => $c->is_trusted_source,
227             'is_trusted_source("1.1.1.1")' => $c->is_trusted_source('1.1.1.1'),
228             });
229             };
230            
231             app->start;
232            
233             =head1 DESCRIPTION
234            
235             L<Mojolicious::Plugin::TrustedProxy> modifies every L<Mojolicious> request
236             transaction to override connecting user agent values only when the request comes
237             from trusted upstream sources. You can specify multiple request headers where
238             trusted upstream sources define the real user agent IP address or the real
239             connection scheme, or disable either, and can hide the headers from the rest of
240             the application if needed.
241            
242             This plugin provides much of the same functionality as setting
243             C<MOJO_REVERSE_PROXY=1>, but with more granular control over what headers to
244             use and what upstream sources can send them. This is especially useful if your
245             Mojolicious app is directly exposed to the internet, or if it sits behind
246             multiple upstream proxies. You should therefore ensure your application does
247             not enable the default Mojolicious reverse proxy handler when using this plugin.
248            
249             This plugin supports parsing L<RFC 7239|http://tools.ietf.org/html/rfc7239>
250             compliant C<Forwarded> headers, validates all IP addresses, and will
251             automatically convert RFC-4291 IPv4-to-IPv6 mapped values (useful for when your
252             Mojolicious listens on both IP versions). Please be aware that C<Forwarded>
253             headers are only partially supported. More information is available in L</BUGS>.
254            
255             Debug logging can be enabled by setting the C<MOJO_TRUSTEDPROXY_DEBUG>
256             environment variable. This plugin also adds a C<remote_proxy_address>
257             attribute into C<Mojo::Transaction>. If a remote IP address override header is
258             matched from a trusted upstream proxy, then C<< tx->remote_proxy_address >>
259             will be set to the IP address of that proxy.
260            
261             =head1 CONFIG
262            
263             =head2 ip_headers
264            
265             List of zero, one, or many HTTP headers where the real user agent IP address
266             will be defined by the trusted upstream sources. The first matched header is
267             used. An empty value will disable this and keep the original scheme value.
268             Default is C<['x-forwarded-for', 'x-real-ip']>.
269            
270             If a header is matched in the request, then C<< tx->remote_address >> is set to
271             the value, and C<< tx->remote_proxy_address >> is set to the IP address of the
272             upstream source.
273            
274             =head2 scheme_headers
275            
276             List of zero, one, or many HTTP headers where the real user agent connection
277             scheme will be defined by the trusted upstream sources. The first matched header
278             is used. An empty value will disable this and keep the original remote address
279             value. Default is C<['x-forwarded-proto', 'x-ssl']>.
280            
281             This tests that the header value is "truthy" but does not contain the literal
282             barewords C<http>, C<off>, or C<false>. If the header contains any other
283             "truthy" value, then C<< req->url->base->scheme >> is set to C<https>.
284            
285             =head2 https_values
286            
287             List of values to consider as "truthy" when evaluating the headers in
288             L</scheme_headers>. Default is
289             C<['https', 'on', '1', 'true', 'enable', 'enabled']>.
290            
291             =head2 parse_rfc7239, parse_forwarded
292            
293             Enable support for parsing L<RFC 7239|http://tools.ietf.org/html/rfc7239>
294             compliant C<Forwarded> HTTP headers. Default is C<1> (enabled).
295            
296             If a C<Forwarded> header is matched, the following actions occur with the first
297             semicolon-delimited group of parameters found in the header value:
298            
299             =over
300            
301             =item
302            
303             If the C<for> parameter is found, then C<< tx->remote_address >> is set to the
304             first matching value.
305            
306             =item
307            
308             If the C<by> parameter is found, then C<< tx->remote_proxy_address >> is set
309             to the first matching value, otherwise it is set to the IP address of the
310             upstream source.
311            
312             =item
313            
314             If the C<proto> parameter is found, then C<< req->url->base->scheme >> is set
315             to the first matching value.
316            
317             =item
318            
319             If the C<host> parameter is found, then C<< req->url->base->host >> is set to
320             the first matching value.
321            
322             =back
323            
324             B<Note!> If enabled, the headers defined in L</ip_headers> and
325             L</scheme_headers> will be overridden by any corresponding values found in
326             the C<Forwarded> header.
327            
328             =head2 trusted_sources
329            
330             List of one or more IP addresses or CIDR classes that are trusted upstream
331             sources. (B<Warning!> An empty value will trust from all IPv4 sources!) Default
332             is C<['127.0.0.0/8', '10.0.0.0/8']>.
333            
334             Supports all IP, CIDR, and range definition types from L<Net::CIDR::Lite>.
335            
336             =head2 hide_headers
337            
338             Hide all headers defined in L</ip_headers>, L</scheme_headers>, and
339             C<Forwarded> from the rest of the application when coming from trusted upstream
340             sources. Default is C<0> (disabled).
341            
342             =head1 HELPERS
343            
344             =head2 is_trusted_source
345            
346             # From Controller context
347             sub get_page {
348             my $c = shift;
349             if ($c->is_trusted_source || $c->is_trusted_source('1.2.3.4')) {
350             ...
351             }
352             }
353            
354             Validate if an IP address is in the L</trusted_sources> list. If no argument is
355             provided, then this helper will first check C<< tx->remote_proxy_address >>
356             then C<< tx->remote_address >>. Returns C<1> if in the L</trusted_sources> list,
357             C<0> if not, or C<undef> if the IP address is invalid.
358            
359             =head1 CDN AND CLOUD SUPPORT
360            
361             L<Mojolicious::Plugin::TrustedProxy> is compatible with assumedly all
362             third-party content delivery networks and cloud providers. Below is an
363             incomplete list of some of the most well-known providers and the recommended
364             L<config|/CONFIG> values to use for them.
365            
366             =head2 Akamai
367            
368             =over
369            
370             =item ip_headers
371            
372             Set L</ip_headers> to C<['true-client-ip']> (unless you set this to a different
373             value) and enable True Client IP in the origin server behavior for your site
374             property. Akamai also supports C<['x-forwarded-for']>, which is enabled by
375             default in L<Mojolicious::Plugin::TrustedProxy>.
376            
377             =item scheme_headers
378            
379             There is no known way to pass this by default with Akamai. It may be possible
380             to pass a custom header via a combination of a Site Property variable and a
381             custom behavior that injects an outgoing request header based on that variable,
382             but this has not been tested or confirmed.
383            
384             =item trusted_sources
385            
386             This is only possible if you have the
387             L<Site Shield|https://www.akamai.com/us/en/products/security/site-shield.jsp>
388             product from Akamai. If so, set L</trusted_sources> to the complete list of
389             IPs provided in your Site Shield map.
390            
391             =back
392            
393             =head2 AWS
394            
395             =over
396            
397             =item ip_headers
398            
399             The AWS Elastic Load Balancer uses C<['x-forwarded-for']>, which is enabled by
400             default in L<Mojolicious::Plugin::TrustedProxy>.
401            
402             =item scheme_headers
403            
404             The AWS Elastic Load Balancer uses C<['x-forwarded-proto']>, which is enabled
405             by default in L<Mojolicious::Plugin::TrustedProxy>.
406            
407             =item trusted_sources
408            
409             Depending on your setup, this could be one of the C<172.x.x.x> IP addresses
410             or ranges within your Virtual Private Cloud, the IP address(es) of your Elastic
411             or Application Load Balancer, or could be the public IP ranges for your AWS
412             region. Go to
413             L<https://docs.aws.amazon.com/general/latest/gr/aws-ip-ranges.html> for an
414             updated list of AWS's IPv4 and IPv6 CIDR ranges.
415            
416             =back
417            
418             =head2 Cloudflare
419            
420             =over
421            
422             =item ip_headers
423            
424             Set L</ip_headers> to C<['cf-connecting-ip']>, or C<['true-client-ip']> if
425             using an enterprise plan. Cloudflare also supports C<['x-forwarded-for']>,
426             which is enabled by default in L<Mojolicious::Plugin::TrustedProxy>.
427            
428             =item scheme_headers
429            
430             Cloudflare uses the C<x-forwarded-proto> header, which is enabled by default
431             in L<Mojolicious::Plugin::TrustedProxy>.
432            
433             =item trusted_sources
434            
435             Go to L<https://www.cloudflare.com/ips/> for an updated list of Cloudflare's
436             IPv4 and IPv6 CIDR ranges.
437            
438             =back
439            
440             =head1 SECURITY
441            
442             Caution should be taken that you set only the L</CONFIG> values necessary for
443             your application in a most-common-first order, and that your upstream proxies
444             remove any headers you do not want passed through to your application.
445            
446             For example, if you use Cloudflare and set L</ip_headers> to
447             C<['x-real-ip', 'cf-connecting-ip']> and did not configure Cloudflare to
448             remove C<x-real-ip> headers from requests, an attacker could use this trick
449             your application into using whatever IP he or she defines due to being passed
450             through your trusted proxy and the C<x-real-ip> header being the first to be
451             evaluated.
452            
453             =head1 AUTHOR
454            
455             Kage <kage I<AT> kage I<DOT> wtf>
456            
457             =head1 BUGS
458            
459             Please report any bugs or feature requests on Github:
460             L<https://github.com/Kage/Mojolicious-Plugin-TrustedProxy>
461            
462             =over
463            
464             =item Hostnames not supported
465            
466             Excluding the C<host> parameter of RFC 7239, this plugin does not currently
467             support hostnames or hostname resolution and there are no plans to implement
468             this. If you have a use case that requires this, please feel free to submit a
469             pull request.
470            
471             =item HTTP 'Forwarded' only partially supported
472            
473             Only partial support for RFC 7239 is currently implemented, but this should
474             work with most common use cases. The full specification allows for complex
475             structures and quoting that is difficult to implement safely. Full RFC support
476             is expected to be implemented soon.
477            
478             =back
479            
480             =head1 SEE ALSO
481            
482             L<Mojolicious::Plugin::RemoteAddr>, L<Mojolicious::Plugin::ClientIP::Pluggable>,
483             L<Mojolicious>, L<Mojolicious::Guides>, L<http://mojolicio.us>.
484            
485             =head1 COPYRIGHT
486            
487             MIT License
488            
489             Copyright (c) 2019 Kage
490            
491             Permission is hereby granted, free of charge, to any person obtaining a copy
492             of this software and associated documentation files (the "Software"), to deal
493             in the Software without restriction, including without limitation the rights
494             to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
495             copies of the Software, and to permit persons to whom the Software is
496             furnished to do so, subject to the following conditions:
497            
498             The above copyright notice and this permission notice shall be included in all
499             copies or substantial portions of the Software.
500            
501             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
502             IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
503             FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
504             AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
505             LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
506             OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
507             SOFTWARE.
508            
509             =cut
510