File Coverage

blib/lib/Mojolicious/Plugin/ClientIP/Pluggable.pm
Criterion Covered Total %
statement 66 69 95.6
branch 22 26 84.6
condition 14 21 66.6
subroutine 12 12 100.0
pod 1 1 100.0
total 115 129 89.1


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::ClientIP::Pluggable;
2              
3             # ABSTRACT: Client IP header handling for Mojolicious requests
4              
5             =head1 NAME
6              
7             Mojolicious::Plugin::ClientIP::Pluggable - Customizable client IP detection plugin for Mojolicious
8              
9             =head1 SYNOPSIS
10              
11             use Mojolicious::Lite;
12              
13             # CloudFlare-waware settings
14             plugin 'ClientIP::Pluggable',
15             analyze_headers => [qw/cf-pseudo-ipv4 cf-connecting-ip true-client-ip/],
16             restrict_family => 'ipv4',
17             fallbacks => [qw/rfc-7239 x-forwarded-for remote_address/];
18              
19              
20             get '/' => sub {
21             my $c = shift;
22             $c->render(text => $c->client_ip);
23             };
24              
25             app->start;
26              
27             =head1 DESCRIPTION
28              
29             Mojolicious::Plugin::ClientIP::Pluggable is a Mojolicious plugin to get an IP address, which
30             allows to specify different HTTP-headers (and their priorities) for client IP address
31             extraction. This is needed as different cloud providers set different headers to disclose
32             real IP address.
33              
34             If the address cannot be extracted from headers different fallback options are available:
35             detect IP address from C header, detect IP address from C header
36             (rfc-7239), or use C environment.
37              
38             The plugin is inspired by L.
39              
40             =head1 METHODS
41              
42             =head2 client_ip
43              
44             Find a client IP address from the specified headers, with optional fallbacks. The address is
45             validated that it is publicly available (aka routable) IP address. Empty string is returned
46             if no valid address can be found.
47              
48             =head1 OPTIONS
49              
50             =head2 analyzed_headers
51              
52             Define order and names of cloud provider injected headers with client IP address.
53             For C we found the following headers are suitable:
54              
55             plugin 'ClientIP::Pluggable',
56             analyzed_headers => [qw/cf-pseudo-ipv4 cf-connecting-ip true-client-ip/].
57              
58             This option is mandatory.
59              
60             More details at L,
61             L,
62             L
63              
64             =head2 restrict_family
65              
66             plugin 'ClientIP::Pluggable', restrict_family => 'ipv4';
67             plugin 'ClientIP::Pluggable', restrict_family => 'ipv6';
68              
69             If defined only IPv4 or IPv6 addresses are considered valid among the possible addresses.
70              
71             By default this option is not defined, allowing IPv4 and IPv6 addresses.
72              
73             =head2 fallbacks
74              
75             plugin 'ClientIP::Pluggable',
76             fallbacks => [qw/rfc-7239 x-forwarded-for remote_address/]);
77              
78             Try to get valid client IP-address from fallback sources, if we fail to do that from
79             cloud-provider headers.
80              
81             C uses C header, C use header
82             (appeared before rfc-7239 and still widely used) or use remote_address environment
83             (C<$c->tx->remote_address>).
84              
85             Default value is C<[remote_address]>.
86              
87             =head1 ENVIRONMENT
88              
89             =head2 CLIENTIP_PLUGGABLE_ALLOW_LOOPBACK
90              
91             Allows non-routable loopback address (C<127.0.0.1>) to pass validation. Use it for
92             test purposes.
93              
94             Default value is C<0>, i.e. loopback addresses do not pass IP-address validation.
95              
96              
97             =head1 COPYRIGHT AND LICENSE
98              
99             Copyright (C) 2017 binary.com
100              
101             =cut
102              
103 2     2   1149 use strict;
  2         5  
  2         50  
104 2     2   9 use warnings;
  2         4  
  2         44  
105              
106 2     2   567 use Data::Validate::IP;
  2         43962  
  2         345  
107              
108 2     2   16 use Mojo::Base 'Mojolicious::Plugin';
  2         6  
  2         17  
109              
110             our $VERSION = '0.01';
111              
112             # for tests only
113 2   100 2   421 use constant ALLOW_LOOPBACK => $ENV{CLIENTIP_PLUGGABLE_ALLOW_LOOPBACK} || 0;
  2         5  
  2         1546  
114              
115             sub _check_ipv4 {
116 10     10   17 my ($ip) = @_;
117 10   100     185 return Data::Validate::IP::is_public_ipv4($ip)
118             || (ALLOW_LOOPBACK && Data::Validate::IP::is_loopback_ipv4($ip));
119             }
120              
121             sub _check_ipv6 {
122 3     3   5 my ($ip) = @_;
123 3   33     62 return Data::Validate::IP::is_public_ipv6($ip)
124             || (ALLOW_LOOPBACK && Data::Validate::IP::is_loopback_ipv6($ip));
125             }
126              
127             sub _classify_ip {
128 17     17   33 my ($ip) = @_;
129             return
130 17 50       31 Data::Validate::IP::is_ipv4($ip) ? 'ipv4'
    100          
131             : Data::Validate::IP::is_ipv6($ip) ? 'ipv6'
132             : undef;
133             }
134              
135             sub _candidates_iterator {
136 11     11   19 my ($c, $analyzed_headers, $fallback_options) = @_;
137 11         31 my $headers = $c->tx->req->headers;
138 11   66     138 my @candidates = map { $headers->header($_) // () } @$analyzed_headers;
  30         211  
139 11         106 my $comma_re = qr/\s*,\s*/;
140 11         27 for my $fallback (map { lc } @$fallback_options) {
  33         73  
141 33 100       88 if ($fallback eq 'x-forwarded-for') {
    100          
    50          
142 11         25 my $xff = $headers->header('x-forwarded-for');
143 11 100       103 next unless $xff;
144 5         29 my @ips = split $comma_re, $xff;
145 5         14 push @candidates, @ips;
146             } elsif ($fallback eq 'remote_address') {
147 11         29 push @candidates, $c->tx->remote_address;
148             } elsif ($fallback eq 'rfc-7239') {
149 11         23 my $f = $headers->header('forwarded');
150 11 100       86 next unless $f;
151 4         13 my @pairs = map { split $comma_re, $_ } split ';', $f;
  8         38  
152             my @ips = map {
153 4         11 my $ipv4_mask = qr/\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}/;
  10         25  
154             # it is not completely valid ipv6 mask, but enough
155             # to extract address. It will be validated later
156 10         21 my $ipv6_mask = qr/[\w:]+/;
157 10 100       171 if (/for=($ipv4_mask)|(?:"?\[($ipv6_mask)\].*"?)/i) {
158 6   66     40 ($1 // $2);
159             } else {
160 4         9 ();
161             }
162             } @pairs;
163 4         13 push @candidates, @ips;
164             } else {
165 0         0 warn "Unknown fallback option $fallback, ignoring";
166             }
167             }
168 11         181 my $idx = 0;
169             return sub {
170 18 50   18   222 if ($idx < @candidates) {
171 18         55 return $candidates[$idx++];
172             }
173 0         0 return (undef);
174 11         57 };
175             }
176              
177             sub register {
178 2     2 1 97 my ($self, $app, $conf) = @_;
179 2   50     9 my $analyzed_headers = $conf->{analyze_headers} // die "Please, specify 'analyzed_headers' option";
180 2         8 my %validator_for = (
181             ipv4 => \&_check_ipv4,
182             ipv6 => \&_check_ipv6,
183             );
184 2         5 my $restrict_family = $conf->{restrict_family};
185 2   50     6 my $fallback_options = $conf->{fallbacks} // [qw/remote_address/];
186              
187             $app->helper(
188             client_ip => sub {
189 11     11   96891 my ($c) = @_;
190              
191 11         30 my $next_candidate = _candidates_iterator($c, $analyzed_headers, $fallback_options);
192 11         28 while (my $ip = $next_candidate->()) {
193             # generic check
194 18 100       51 next unless Data::Validate::IP::is_ip($ip);
195              
196             # classify & check
197 17         403 my $address_family = _classify_ip($ip);
198 17 50       290 next unless $address_family;
199              
200             # possibly limit to acceptable address family
201 17 100 66     70 next if $restrict_family && $restrict_family ne $address_family;
202              
203             # validate by family
204 13         29 my $validator = $validator_for{$address_family};
205 13 100       25 next unless $validator->($ip);
206              
207             # address seems valid, return its textual representation
208 11         718 return $ip;
209             }
210 0         0 return '';
211 2         24 });
212              
213 2         65 return;
214             }
215              
216             1;