File Coverage

lib/CGI/Application/Plugin/Throttle.pm
Criterion Covered Total %
statement 15 62 24.1
branch 1 24 4.1
condition 0 13 0.0
subroutine 4 10 40.0
pod 5 5 100.0
total 25 114 21.9


line stmt bran cond sub pod time code
1              
2             =head1 NAME
3            
4             CGI::Application::Plugin::Throttle - Rate-Limiting for CGI::Application-based applications, using Redis for persistence.
5            
6             =head1 SYNOPSIS
7            
8             use CGI::Application::Plugin::Throttle;
9            
10            
11             # Your application
12             sub setup {
13             ...
14            
15             # Create a redis handle
16             my $redis = Redis->new();
17            
18             # Configure throttling
19             $self->throttle()->configure( redis => $redis,
20             prefix => "REDIS:KEY:PREFIX",
21             limit => 100,
22             period => 60,
23             exceeded => "slow_down_champ" );
24            
25            
26             =cut
27              
28              
29             =head1 DESCRIPTION
30            
31             This module allows you to enforce a throttle on incoming requests to
32             your application, based upon the remote IP address.
33            
34             This module stores a count of accesses in a Redis key-store, and
35             once hits from a particular source exceed the specified threshold
36             the user will be redirected to the run-mode you've specified.
37            
38             =cut
39              
40              
41             =head1 POTENTIAL ISSUES / CONCERNS
42            
43             Users who share IP addresses, because they are behind a common-gateway
44             for example, will all suffer if the threshold is too low. We attempt to
45             mitigate this by building the key using a combination of the remote
46             IP address, and the remote user-agent.
47            
48             This module will apply to all run-modes, because it seems likely that
49             this is the most common case. If you have a preference for some modes
50             to be excluded please do contact the author.
51            
52             =cut
53              
54              
55              
56              
57 1     1   50224 use strict;
  1         3  
  1         36  
58 1     1   6 use warnings;
  1         2  
  1         224  
59              
60             package CGI::Application::Plugin::Throttle;
61              
62              
63             our $VERSION = '0.6';
64              
65              
66             =head1 METHODS
67            
68            
69             =head2 import
70            
71             Force the C<throttle> method into the caller's namespace, and
72             configure the prerun hook which is used by L<CGI::Application>.
73            
74             =cut
75              
76             sub import
77             {
78 1     1   10     my $pkg = shift;
79 1         2     my $callpkg = caller;
80              
81                 {
82             ## no critic
83 1     1   5         no strict qw(refs);
  1         7  
  1         1555  
  1         2  
84             ## use critic
85 1         2         *{ $callpkg . '::throttle' } = \&throttle;
  1         6  
86                 }
87              
88 1 50       20     if ( UNIVERSAL::can( $callpkg, "add_callback" ) )
89                 {
90 0                   $callpkg->add_callback( 'prerun' => \&throttle_callback );
91                 }
92              
93             }
94              
95              
96             =head2 new
97            
98             This method is used internally, and not expected to be invoked externally.
99            
100             The defaults are setup here, although they can be overridden in the
101             L</"configure"> method.
102            
103             =cut
104              
105             sub new
106             {
107 0     0 1       my ( $proto, %supplied ) = (@_);
108 0   0           my $class = ref($proto) || $proto;
109              
110 0               my $self = {};
111              
112             #
113             # Configure defaults.
114             #
115 0               $self->{ 'limit' } = 100;
116 0               $self->{ 'period' } = 60;
117              
118             #
119             # The redis key-prefix.
120             #
121 0   0           $self->{ 'prefix' } = $supplied{ 'prefix' } || "THROTTLE";
122              
123             #
124             # Run mode to redirect to on exceed.
125             #
126 0               $self->{ 'exceeded' } = "slow_down";
127              
128              
129 0               bless( $self, $class );
130 0               return $self;
131             }
132              
133              
134              
135             =head2 throttle
136            
137             Gain access to an instance of this class. This is the method by which you
138             can call methods on this plugin from your L<CGI::Application> derived-class.
139            
140             =cut
141              
142             sub throttle
143             {
144 0     0 1       my $cgi_app = shift;
145 0 0             return $cgi_app->{ __throttle_obj } if $cgi_app->{ __throttle_obj };
146              
147             #
148             # Setup the prefix of the Redis keys to default to the name of
149             # the CGI::Application.
150             #
151             # This avoids collisions if multiple applications are running on
152             # the same host, and the developer won't need to explicitly setup
153             # distinct prefixes.
154             #
155                 my $throttle = $cgi_app->{ __throttle_obj } =
156 0                 __PACKAGE__->new( prefix => ref $cgi_app );
157              
158 0               return $throttle;
159             }
160              
161              
162              
163             =head2 _get_redis_key
164            
165             Build and return the Redis key to use for this particular remote
166             request.
167            
168             The key is built from the C<prefix> string set in L</"configure"> method,
169             along with:
170            
171             =over 8
172            
173             =item *
174            
175             The remote IP address of the client.
176            
177             =item *
178            
179             The remote HTTP Basic-Auth username of the client.
180            
181             =item *
182            
183             The remote User-Agent.
184            
185             =back
186            
187             =cut
188              
189             sub _get_redis_key
190             {
191 0     0         my $self = shift;
192 0               my $key = $self->{ 'prefix' };
193              
194             #
195             # Build up the key based on the:
196             #
197             # 1. User using HTTP Basic-Auth, if present.
198             # 2. The remote IP address.
199             # 3. The remote user-agent.
200             #
201 0               foreach my $env (qw! REMOTE_USER REMOTE_ADDR HTTP_USER_AGENT !)
202                 {
203 0 0                 if ( $ENV{ $env } )
204                     {
205 0                       $key .= ":";
206 0                       $key .= $ENV{ $env };
207                     }
208                 }
209              
210 0               return ($key);
211             }
212              
213              
214             =head2 count
215            
216             Return the number of times the remote client has hit a run mode, along
217             with the maximum allowed visits:
218            
219             =for example begin
220            
221             sub your_run_mode
222             {
223             my ($self) = (@_);
224            
225             my( $count, $max ) = $self->throttle()->count();
226             return( "$count visits seen - maximum is $max." );
227             }
228            
229             =for example end
230            
231             =cut
232              
233             sub count
234             {
235 0     0 1       my ($self) = (@_);
236              
237 0               my $visits = 0;
238 0               my $max = $self->{ 'limit' };
239              
240 0 0             if ( $self->{ 'redis' } )
241                 {
242 0                   my $key = $self->_get_redis_key();
243 0                   $visits = $self->{ 'redis' }->get($key);
244                 }
245 0               return ( $visits, $max );
246             }
247              
248              
249             =head2 throttle_callback
250            
251             This method is invoked by L<CGI::Application>, as a hook.
252            
253             The method is responsible for determining whether the remote client
254             which triggered the current request has exceeded their request
255             threshold.
256            
257             If the client has made too many requests their intended run-mode will
258             be changed to to redirect them.
259            
260             =cut
261              
262             sub throttle_callback
263             {
264 0     0 1       my $cgi_app = shift;
265 0               my $self = $cgi_app->throttle();
266              
267             #
268             # Get the redis handle
269             #
270 0   0           my $redis = $self->{ 'redis' } || return;
271              
272             #
273             # The key relating to this user.
274             #
275 0               my $key = $self->_get_redis_key();
276              
277             # Increase the count, and set the expiry.
278             #
279 0               $redis->incr($key);
280 0               $redis->expire( $key, $self->{ 'period' } );
281              
282             #
283             # Get the current hit-count.
284             #
285 0               my $cur = $redis->get($key);
286              
287             #
288             # If too many redirect.
289             #
290 0 0 0           if ( ($cur) && ( $self->{ 'exceeded' } ) && ( $cur > $self->{ 'limit' } ) )
      0        
291                 {
292              
293             #
294             # Redirect to a different run-mode..
295             #
296 0 0                 if ( $self->{ 'exceeded' } )
297                     {
298 0                       $cgi_app->prerun_mode( $self->{ 'exceeded' } );
299 0                       return;
300                     }
301                 }
302              
303             #
304             # Otherwise if we've been called with a mode merge it in
305             #
306 0 0             if ( $cgi_app->query->url_param( $cgi_app->mode_param ) )
307                 {
308 0                   $cgi_app->prerun_mode(
309                                        $cgi_app->query->url_param( $cgi_app->mode_param ) );
310                 }
311              
312             }
313              
314              
315             =head2 configure
316            
317             This method is what the user will invoke to configure the throttle-limits.
318            
319             It is expected that within the users L<CGI::Application> L<CGI::Application/setup> method there will be code similar to this:
320            
321             =for example begin
322            
323             sub setup {
324             my $self = shift;
325            
326             my $r = Redis->new();
327            
328             $self->throttle()->configure( redis => $r,
329             # .. other options here
330             )
331             }
332            
333             =for example end
334            
335             The arguments hash contains the following known keys:
336            
337             =over 8
338            
339             =item C<redis>
340            
341             A L<Redis> handle object.
342            
343             =item C<limit>
344            
345             The maximum number of requests that the remote client may make, in the given period of time.
346            
347             =item C<period>
348            
349             The period of time which requests are summed for. The period is specified in seconds and if more than C<limit> requests are sent then the client will be redirected.
350            
351             =item C<prefix>
352            
353             This module uses L<Redis> to store the counts of client requests. Redis is a key-value store, and each key used by this module is given a prefix to avoid collisions. You may specify your prefix here.
354            
355             The prefix will default to the name of your application class if it isn't set explicitly, which should avoid collisions if you're running multiple applications on the same host.
356            
357             =item C<exceeded>
358            
359             The C<run_mode> to redirect the client to, when their request-count has exceeded the specified limit.
360            
361             =back
362            
363             =cut
364              
365             sub configure
366             {
367 0     0 1       my ( $self, %args ) = (@_);
368              
369             #
370             # Default rate-limiting period:
371             #
372             # 100 requests in 60 seconds.
373             #
374 0 0             $self->{ 'limit' } = $args{ 'limit' } if ( $args{ 'limit' } );
375 0 0             $self->{ 'period' } = $args{ 'period' } if ( $args{ 'period' } );
376              
377             #
378             # Redis key-prefix
379             #
380 0 0             $self->{ 'prefix' } = $args{ 'prefix' } if ( $args{ 'prefix' } );
381              
382             #
383             # The handle to Redis for state-tracking
384             #
385 0 0             $self->{ 'redis' } = $args{ 'redis' } if ( $args{ 'redis' } );
386              
387             #
388             # The run-mode to redirect to on violation.
389             #
390 0 0             $self->{ 'exceeded' } = $args{ 'exceeded' } if ( $args{ 'exceeded' } );
391              
392             }
393              
394              
395              
396             =head1 AUTHOR
397            
398             Steve Kemp <steve@steve.org.uk>
399            
400             =cut
401              
402             =head1 COPYRIGHT AND LICENSE
403            
404             Copyright (C) 2014 Steve Kemp <steve@steve.org.uk>.
405            
406             This library is free software. You can modify and or distribute it under
407             the same terms as Perl itself.
408            
409             =cut
410              
411              
412              
413             1;
414