File Coverage

blib/lib/Mojolicious/Plugin/TrustedProxy.pm
Criterion Covered Total %
statement 112 113 99.1
branch 41 50 82.0
condition 24 38 63.1
subroutine 11 11 100.0
pod 1 1 100.0
total 189 213 88.7


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