File Coverage

blib/lib/Plack/Middleware/Greylist.pm
Criterion Covered Total %
statement 121 122 99.1
branch 38 50 76.0
condition 25 37 67.5
subroutine 17 17 100.0
pod 2 2 100.0
total 203 228 89.0


line stmt bran cond sub pod time code
1             package Plack::Middleware::Greylist;
2              
3             # ABSTRACT: throttle requests with different rates based on net blocks
4              
5             # RECOMMEND PREREQ: Cache::FastMmap 1.52
6             # RECOMMEND PREREQ: Ref::Util::XS
7              
8 5     5   2958901 use v5.20;
  5         20  
9 5     5   40 use warnings;
  5         20  
  5         445  
10              
11 5     5   35 use parent qw( Plack::Middleware );
  5         10  
  5         77  
12              
13 5     5   489 use HTTP::Status qw/ HTTP_FORBIDDEN HTTP_TOO_MANY_REQUESTS /;
  5         13  
  5         527  
14 5     5   41 use List::Util 1.29 qw/ pairs /;
  5         128  
  5         446  
15 5     5   2870 use Module::Load qw/ load /;
  5         8294  
  5         37  
16 5     5   2817 use Net::IP::LPM;
  5         37431  
  5         282  
17 5     5   46 use Plack::Util;
  5         16  
  5         230  
18 5     5   29 use Plack::Util::Accessor qw/ default_rate rules cache file _match greylist retry_after cache_config callback /;
  5         9  
  5         53  
19 5     5   3011 use Ref::Util qw/ is_plain_arrayref is_coderef /;
  5         13492  
  5         524  
20 5     5   2799 use Time::Seconds qw/ ONE_MINUTE /;
  5         9487  
  5         459  
21              
22 5     5   42 use experimental qw/ postderef signatures /;
  5         8  
  5         57  
23              
24             our $VERSION = 'v0.8.1';
25              
26              
27 5     5 1 637 sub prepare_app($self) {
  5         14  
  5         11  
28              
29 5 50       57 $self->default_rate(-1) unless defined $self->default_rate;
30              
31 5 50       304 die "default_rate must be a positive integer" unless $self->default_rate =~ /^[1-9][0-9]*$/;
32              
33 5         81 my $config = $self->cache_config;
34 5 50 0     28 $self->cache_config( $config //= {} ) unless defined $config;
35              
36 5   50     54 $config->{init_file} //= 0;
37 5   33     32 $config->{unlink_on_exit} //= !$config->{init_file};
38 5   50     37 $config->{serializer} //= '';
39 5   100     18 my $expiry = $config->{expire_time} //= ONE_MINUTE;
40              
41 5 100       15 $self->retry_after( $config->{expire_time} + 1 ) unless defined $self->retry_after;
42 5 50 33     54 die "retry_after must be a positive integer greater than $expiry seconds"
43             unless $self->retry_after =~ /^[1-9][0-9]*$/ && $self->retry_after > $expiry;
44              
45 5 50       73 unless ( $self->cache ) {
46              
47 5   66     36 my $file = $self->file // $config->{share_file};
48 5 50       68 die "No cache was set" unless defined $file;
49 5         35 $config->{share_file} = "$file";
50              
51 5         108 load Cache::FastMmap;
52 5 50       39310 die "Cache::FastMmap version 1.52 or newer is required" if Cache::FastMmap->VERSION < 1.52;
53              
54 5         42 my $cache = Cache::FastMmap->new(%$config);
55              
56 89         156 $self->cache(
57 89     89   421 sub($ip) {
  89         381  
58             return $cache->get_and_set(
59             $ip,
60             sub( $, $count, $opts ) {
61 89   100     290 $count //= 0;
62 89         461 return ( $count + 1, { expire_on => $opts->{expire_on} } );
63             }
64 89         702 );
65             }
66 5         141656 );
67              
68             }
69              
70 5         161 my $match = Net::IP::LPM->new;
71              
72 5     101   191 $self->_match( sub($ip) { $match->lookup($ip) } );
  101         459  
  101         560  
  101         196  
  101         166  
73              
74 5         45 my @blocks;
75              
76 5 50       28 if ( my $greylist = $self->greylist ) {
77 5         98 push @blocks, ( $greylist->%* );
78             }
79              
80 5         35 $self->rules( my $rules = {} );
81              
82 5         72 my %codes = ( whitelist => -1, allowed => -1, blacklist => 0, rejected => 0, norobots => 0 );
83 5         30 my %types = ( ip => '', netblock => 1 );
84              
85 5         94 for my $line ( pairs @blocks ) {
86              
87 15         462 my ( $block, $rule ) = $line->@*;
88 15 100       75 $rule = [ split /\s+/, $rule ] unless is_plain_arrayref($rule);
89              
90 15         50 my ( $rate, $type ) = $rule->@*;
91              
92 15   100     63 $type //= "ip";
93 15   66     67 my $mask = $types{$type} // $type;
94 15 100       52 $mask = $block if $mask eq "1";
95              
96 15   50     45 $rate //= "rejected";
97 15 100       51 if ( exists $codes{$rate} ) {
98 4 50       18 $mask = $rate if $mask eq "";
99 4         14 $rate = $codes{$rate};
100             }
101              
102 15         63 $rules->{$block} = [ $rate, $mask ];
103 15         64 $match->add( $block => $block );
104             }
105              
106 5 100       281 if ( my $fn = $self->callback ) {
107 1 50       24 die "callback must be a code reference" unless is_coderef($fn);
108             }
109             else {
110              
111 15         27 $self->callback(
112 15     15   31 sub($info) {
  15         26  
113 15         33 my $env = $info->{env};
114 15         35 my $msg = $info->{message};
115 15 50       48 if ( my $log = $env->{'psgix.logger'} ) {
116 15         150 $log->( { message => $msg, level => 'warn' } );
117             }
118             else {
119 0         0 $env->{'psgi.errors'}->print($msg);
120             }
121 15         99 return 1;
122             }
123 4         70 );
124             }
125              
126             }
127              
128 101     101 1 451272 sub call( $self, $env ) {
  101         226  
  101         190  
  101         176  
129              
130 101         237 my $ip = $env->{REMOTE_ADDR};
131 101         432 my $name = $self->_match->($ip);
132 101 100       2070 my $rule = $name ? $self->rules->{$name} : [ $self->default_rate ];
133              
134 101         617 my $rate = $rule->[0];
135              
136 101 100 100     374 if ( $rate == 0 && $rule->[1] && $rule->[1] eq "norobots" ) {
      100        
137 3 100       11 if ( $env->{PATH_INFO} eq "/robots.txt" ) {
138 2         4 $rate = ONE_MINUTE; # one request/second
139             }
140             }
141              
142 101 100       281 if ( $rate >= 0 ) {
143              
144 89         189 my $limit = $rate == 0;
145              
146 89   66     480 my ($hits) = $self->cache->( $rule->[1] || $ip );
147 89 100       9816 $limit = $hits > $rate ? $hits : 0;
148              
149 89 100       344 if ($limit) {
150              
151 17   100     73 my $block = $name || "default";
152              
153 17 50       96 if ( my $fn = $self->callback ) {
154 17 100       383 $fn->(
155             {
156             env => $env,
157             ip => $ip,
158             hits => $limit,
159             rate => $rate,
160             block => $block,
161             message => "Rate limiting ${ip} after ${limit}/${rate} for ${block}",
162             }
163             ) or return $self->app->($env);
164             }
165              
166 16 100       99 if ( $rate == 0 ) {
167              
168 5         63 return [ HTTP_FORBIDDEN, [], ["Forbbidden"] ];
169              
170             }
171             else {
172              
173             return [
174 11         58 HTTP_TOO_MANY_REQUESTS,
175             [
176             "Retry-After" => $self->retry_after,
177             ],
178             ["Too Many Requests"]
179             ];
180              
181             }
182             }
183              
184             }
185              
186 84         342 return $self->app->($env);
187             }
188              
189              
190             1;
191              
192             __END__