From b12c174229fc21f50c8fda201916febaacd7ed71 Mon Sep 17 00:00:00 2001 From: David Cook Date: Mon, 17 Feb 2025 00:11:30 +0000 Subject: [PATCH] Bug 39109: Add Plack middleware to throttle requests This patch includes a configurable Plack middleware which limits the number of requests for an IP address range for a moving time window. For example, 192.168.1.0/24 can only make 5 requests over the last 1 minute. --- Koha/Middleware/Throttle.pm | 262 ++++++++++++++++++ .../bootstrap/en/modules/opac-throttled.tt | 42 +++ 2 files changed, 304 insertions(+) create mode 100644 Koha/Middleware/Throttle.pm create mode 100644 koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-throttled.tt diff --git a/Koha/Middleware/Throttle.pm b/Koha/Middleware/Throttle.pm new file mode 100644 index 0000000000..2d61d52c98 --- /dev/null +++ b/Koha/Middleware/Throttle.pm @@ -0,0 +1,262 @@ +package Koha::Middleware::Throttle; + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use parent qw(Plack::Middleware); +use Plack::Response; +use Plack::Util::Accessor qw(debug interface allow_list paths request_threshold last_n_minutes cidr_block); +use Net::Netmask; + +use C4::Templates; +use Koha::Caches; + +=head1 Functions + +=head2 prepare_app + +Called at startup time + +=cut + +sub prepare_app { + my $self = shift; + + if ( !$self->request_threshold ) { + + #NOTE: Give a high fallback request threshold + $self->request_threshold(50); + } + if ( !( $self->last_n_minutes && $self->last_n_minutes > 1 ) ) { + + #NOTE: Give a reasonable fallback number of last N minutes to check for visits + $self->last_n_minutes(5); + } + my $allowed_cidr_blocks = { + 8 => 1, + 16 => 1, + 24 => 1, + 32 => 1, + }; + if ( $self->cidr_block ) { + if ( !$allowed_cidr_blocks->{ $self->cidr_block } ) { + $self->cidr_block(24); + } + } else { + $self->cidr_block(24); + } +} + +=head2 call + +Called at request time + +=cut + +sub call { + my ( $self, $env ) = @_; + + #NOTE: Like the CSRF middleware, we only apply this once in the request cycle. + #NOTE: We don't apply it to errordocument subrequests. + if ( !$env->{'plack.middleware.Koha.Throttle'} ) { + + my $ip_address = $env->{REMOTE_ADDR}; + + my $throttle_check = 0; + my $threshold = $self->request_threshold; + + my $req = Plack::Request->new($env); + my $request_path = $req->path // q{}; + my $user_agent = $req->user_agent // q{-}; + + my $paths = $self->paths(); + if ( $paths && ref $paths && ref $paths eq 'ARRAY' ) { + foreach my $path (@$paths) { + + #NOTE: Since we're using flexible regex, we have to iterate through a list rather than using a more efficient hash lookup + if ( $request_path =~ qr{$path} ) { + $throttle_check = 1; + last; + } + } + } + + if ($throttle_check) { + + #NOTE: If this request requires a throttle check, we first check out allow_list for exceptions + if ( $self->allow_list + && ref $self->allow_list + && ref $self->allow_list eq 'ARRAY' ) + { + foreach my $ip_range ( @{ $self->allow_list } ) { + if ( $self->debug ) { + warn "Checking IP address '$ip_address' against IP range '$ip_range'\n"; + } + my $mask = Net::Netmask->new2($ip_range); + if ($mask) { + if ( $mask->match($ip_address) ) { + if ( $self->debug ) { + warn "IP address '$ip_address' matched IP range '$ip_range'. Skip.\n"; + } + $throttle_check = 0; + last; + } + } + } + } + } + + if ($throttle_check) { + my $total_visits = $self->_get_total_visits( + { + ip_address => $ip_address, + } + ); + if ( $self->debug ) { + warn "Total visits during time period: $total_visits\n"; + warn "Visit threshold: $threshold\n"; + } + if ( $total_visits > $threshold ) { + my $error = sprintf( "Throttled! IPADDRESS{{%s}} USERAGENT{{%s}}\n", $ip_address, $user_agent ); + warn $error; + my $output; + my $template = $self->_prepare_template( { template_filename => 'opac-throttled.tt' } ); + if ($template) { + $output = $template->output(); + } + my $res = Plack::Response->new( 200, [ 'Content-Type' => 'text/plain' ], ["Throttled!"] ); + if ($output) { + $res->content_type('text/html'); + $res->body($output); + } + return $res->finalize; + } + } + + } else { + $env->{'plack.middleware.Koha.Throttle'} = 'checked'; + } + return $self->app->($env); +} + +sub _get_total_visits { + my ( $self, $args ) = @_; + my $total_visits = 0; + my $ip_address = $args->{ip_address}; + + #NOTE: `memcdump --servers=memcached | grep "throttle"` should show you all the throttle cache keys but it's not... + my $cache = Koha::Caches->get_instance('throttle'); + if ( $ip_address && $cache ) { + my $cache_obj = $cache->{memcached_cache}; + if ( $cache_obj && ref $cache_obj && ref $cache_obj eq 'Cache::Memcached::Fast::Safe' ) { + my $cidr = sprintf( "%s/%d", $ip_address, $self->cidr_block ); + my ($ip_range) = Net::CIDR::cidr2octets($cidr); + + #my $ip_range = join('.',(split /\./, $ip_address)[0..2]); + my $cache_keys = $self->_generate_cache_keys( + { + ip_range => $ip_range, + } + ); + my $current_cache_key = $cache_keys->[0]; + my $get = $cache_obj->get($current_cache_key); + if ( !$get ) { + + #FIXME: Improve expiry time configuration + #FIXME: Should this time be relative to the time of the cache key's minute-truncated time rather based from now? + my $expiry_secs = $self->last_n_minutes * 60; + if ( $self->debug ) { + warn sprintf( + "Cache key %s expires in %s seconds (%s minutes)\n", $current_cache_key, $expiry_secs, + $self->last_n_minutes + ); + } + $cache_obj->set( $current_cache_key, 0, $expiry_secs ); + } + my $rv = $cache_obj->incr($current_cache_key); + my $results = $cache_obj->get_multi(@$cache_keys); + my @sorted_keys = sort { $b cmp $a } keys %$results; + foreach my $key (@sorted_keys) { + my $value = $results->{$key}; + if ($value) { + if ( $self->debug ) { + my ( $key_ip_range, $key_time ) = split( ':', $key ); + warn sprintf( + "IP range '%s' Time '%s' -> %s visits\n", $key_ip_range, + scalar localtime($key_time), $value + ); + } + $total_visits += int($value); + } + } + } + } + return $total_visits; +} + +sub _get_truncated_timestamp { + my ( $self, $args ) = @_; + my $time = time; # Get the current time as a Unix timestamp + $time -= $time % 60; # Truncate by minute interval + return $time; +} + +sub _generate_cache_keys { + my ( $self, $args ) = @_; + my $ip_range = $args->{ip_range}; + my @cache_keys = (); + if ($ip_range) { + my $now = $self->_get_truncated_timestamp(); + if ($now) { + + #Default to last 5 minutes + my $last_n_minutes = $self->last_n_minutes; + my $max_bound = ( $last_n_minutes - 1 ); + if ( $self->debug ) { + warn "Check visits over the last $last_n_minutes minutes\n"; + } + foreach my $minus_minutes ( 0 .. $max_bound ) { + my $timestamp = $now; + if ($minus_minutes) { + $timestamp -= ( $minus_minutes * 60 ); + } + my $expanded_time = scalar localtime($timestamp); + if ( $self->debug ) { + warn "Considered time slot: $expanded_time\n"; + } + my $cache_key = sprintf( "%s:%s", $ip_range, $timestamp ); + push( @cache_keys, $cache_key ); + } + } + } + return \@cache_keys; +} + +sub _prepare_template { + my ( $self, $args ) = @_; + my $template; + my $interface = $self->interface; + my $template_filename = $args->{template_filename}; + if ( $interface && $template_filename ) { + + #NOTE: See Koha::Template in BZ 31380 + $template = C4::Templates::gettemplate( $template_filename, $interface ); + } + return $template; +} + +1; diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-throttled.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-throttled.tt new file mode 100644 index 0000000000..5aa7a7601d --- /dev/null +++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-throttled.tt @@ -0,0 +1,42 @@ +[% USE raw %] +[% INCLUDE 'doc-head-open.inc' %] +Too many requests › [% IF ( LibraryNameTitle ) %][% LibraryNameTitle | html %][% ELSE %]Koha online[% END %] catalogue +[% INCLUDE 'doc-head-close.inc' %] +[% BLOCK cssinclude %][% END %] + + +[% INCLUDE 'masthead.inc' %] +
+ [% WRAPPER breadcrumbs %] + [% WRAPPER breadcrumb_item bc_active = 1 %] + Too many requests + [% END %] + [% END #/ WRAPPER breadcrumbs %] + +
+
+
+

Sorry, the requested page is not available

+

Too many requests

+

+ This message can have the following reason(s): +

+
    +
  • Too many requests have been received from your device. Please try again after a few minutes.
  • +
  • If you believe there has been an error, please contact the Koha administrator.
  • +
+
+
+
+
+[% INCLUDE 'opac-bottom.inc' %] +[% BLOCK jsinclude %] +[% END %] +[% BLOCK cssinclude %] + +[% END %] + -- 2.39.5