View | Details | Raw Unified | Return to bug 39109
Collapse All | Expand All

(-)a/Koha/Middleware/Throttle.pm (+262 lines)
Line 0 Link Here
1
package Koha::Middleware::Throttle;
2
3
# This file is part of Koha.
4
#
5
# Koha is free software; you can redistribute it and/or modify it
6
# under the terms of the GNU General Public License as published by
7
# the Free Software Foundation; either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# Koha is distributed in the hope that it will be useful, but
11
# WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
# GNU General Public License for more details.
14
#
15
# You should have received a copy of the GNU General Public License
16
# along with Koha; if not, see <http://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
20
use parent qw(Plack::Middleware);
21
use Plack::Response;
22
use Plack::Util::Accessor qw(debug interface allow_list paths request_threshold last_n_minutes cidr_block);
23
use Net::Netmask;
24
25
use C4::Templates;
26
use Koha::Caches;
27
28
=head1 Functions
29
30
=head2 prepare_app
31
32
Called at startup time
33
34
=cut
35
36
sub prepare_app {
37
    my $self = shift;
38
39
    if ( !$self->request_threshold ) {
40
41
        #NOTE: Give a high fallback request threshold
42
        $self->request_threshold(50);
43
    }
44
    if ( !( $self->last_n_minutes && $self->last_n_minutes > 1 ) ) {
45
46
        #NOTE: Give a reasonable fallback number of last N minutes to check for visits
47
        $self->last_n_minutes(5);
48
    }
49
    my $allowed_cidr_blocks = {
50
        8  => 1,
51
        16 => 1,
52
        24 => 1,
53
        32 => 1,
54
    };
55
    if ( $self->cidr_block ) {
56
        if ( !$allowed_cidr_blocks->{ $self->cidr_block } ) {
57
            $self->cidr_block(24);
58
        }
59
    } else {
60
        $self->cidr_block(24);
61
    }
62
}
63
64
=head2 call
65
66
Called at request time
67
68
=cut
69
70
sub call {
71
    my ( $self, $env ) = @_;
72
73
    #NOTE: Like the CSRF middleware, we only apply this once in the request cycle.
74
    #NOTE: We don't apply it to errordocument subrequests.
75
    if ( !$env->{'plack.middleware.Koha.Throttle'} ) {
76
77
        my $ip_address = $env->{REMOTE_ADDR};
78
79
        my $throttle_check = 0;
80
        my $threshold      = $self->request_threshold;
81
82
        my $req          = Plack::Request->new($env);
83
        my $request_path = $req->path       // q{};
84
        my $user_agent   = $req->user_agent // q{-};
85
86
        my $paths = $self->paths();
87
        if ( $paths && ref $paths && ref $paths eq 'ARRAY' ) {
88
            foreach my $path (@$paths) {
89
90
                #NOTE: Since we're using flexible regex, we have to iterate through a list rather than using a more efficient hash lookup
91
                if ( $request_path =~ qr{$path} ) {
92
                    $throttle_check = 1;
93
                    last;
94
                }
95
            }
96
        }
97
98
        if ($throttle_check) {
99
100
            #NOTE: If this request requires a throttle check, we first check out allow_list for exceptions
101
            if (   $self->allow_list
102
                && ref $self->allow_list
103
                && ref $self->allow_list eq 'ARRAY' )
104
            {
105
                foreach my $ip_range ( @{ $self->allow_list } ) {
106
                    if ( $self->debug ) {
107
                        warn "Checking IP address '$ip_address' against IP range '$ip_range'\n";
108
                    }
109
                    my $mask = Net::Netmask->new2($ip_range);
110
                    if ($mask) {
111
                        if ( $mask->match($ip_address) ) {
112
                            if ( $self->debug ) {
113
                                warn "IP address '$ip_address' matched IP range '$ip_range'. Skip.\n";
114
                            }
115
                            $throttle_check = 0;
116
                            last;
117
                        }
118
                    }
119
                }
120
            }
121
        }
122
123
        if ($throttle_check) {
124
            my $total_visits = $self->_get_total_visits(
125
                {
126
                    ip_address => $ip_address,
127
                }
128
            );
129
            if ( $self->debug ) {
130
                warn "Total visits during time period: $total_visits\n";
131
                warn "Visit threshold: $threshold\n";
132
            }
133
            if ( $total_visits > $threshold ) {
134
                my $error = sprintf( "Throttled! IPADDRESS{{%s}} USERAGENT{{%s}}\n", $ip_address, $user_agent );
135
                warn $error;
136
                my $output;
137
                my $template = $self->_prepare_template( { template_filename => 'opac-throttled.tt' } );
138
                if ($template) {
139
                    $output = $template->output();
140
                }
141
                my $res = Plack::Response->new( 200, [ 'Content-Type' => 'text/plain' ], ["Throttled!"] );
142
                if ($output) {
143
                    $res->content_type('text/html');
144
                    $res->body($output);
145
                }
146
                return $res->finalize;
147
            }
148
        }
149
150
    } else {
151
        $env->{'plack.middleware.Koha.Throttle'} = 'checked';
152
    }
153
    return $self->app->($env);
154
}
155
156
sub _get_total_visits {
157
    my ( $self, $args ) = @_;
158
    my $total_visits = 0;
159
    my $ip_address   = $args->{ip_address};
160
161
    #NOTE: `memcdump --servers=memcached | grep "throttle"` should show you all the throttle cache keys but it's not...
162
    my $cache = Koha::Caches->get_instance('throttle');
163
    if ( $ip_address && $cache ) {
164
        my $cache_obj = $cache->{memcached_cache};
165
        if ( $cache_obj && ref $cache_obj && ref $cache_obj eq 'Cache::Memcached::Fast::Safe' ) {
166
            my $cidr = sprintf( "%s/%d", $ip_address, $self->cidr_block );
167
            my ($ip_range) = Net::CIDR::cidr2octets($cidr);
168
169
            #my $ip_range = join('.',(split /\./, $ip_address)[0..2]);
170
            my $cache_keys = $self->_generate_cache_keys(
171
                {
172
                    ip_range => $ip_range,
173
                }
174
            );
175
            my $current_cache_key = $cache_keys->[0];
176
            my $get               = $cache_obj->get($current_cache_key);
177
            if ( !$get ) {
178
179
                #FIXME: Improve expiry time configuration
180
                #FIXME: Should this time be relative to the time of the cache key's minute-truncated time rather based from now?
181
                my $expiry_secs = $self->last_n_minutes * 60;
182
                if ( $self->debug ) {
183
                    warn sprintf(
184
                        "Cache key %s expires in %s seconds (%s minutes)\n", $current_cache_key, $expiry_secs,
185
                        $self->last_n_minutes
186
                    );
187
                }
188
                $cache_obj->set( $current_cache_key, 0, $expiry_secs );
189
            }
190
            my $rv          = $cache_obj->incr($current_cache_key);
191
            my $results     = $cache_obj->get_multi(@$cache_keys);
192
            my @sorted_keys = sort { $b cmp $a } keys %$results;
193
            foreach my $key (@sorted_keys) {
194
                my $value = $results->{$key};
195
                if ($value) {
196
                    if ( $self->debug ) {
197
                        my ( $key_ip_range, $key_time ) = split( ':', $key );
198
                        warn sprintf(
199
                            "IP range '%s' Time '%s' -> %s visits\n", $key_ip_range,
200
                            scalar localtime($key_time),              $value
201
                        );
202
                    }
203
                    $total_visits += int($value);
204
                }
205
            }
206
        }
207
    }
208
    return $total_visits;
209
}
210
211
sub _get_truncated_timestamp {
212
    my ( $self, $args ) = @_;
213
    my $time = time;        # Get the current time as a Unix timestamp
214
    $time -= $time % 60;    # Truncate by minute interval
215
    return $time;
216
}
217
218
sub _generate_cache_keys {
219
    my ( $self, $args ) = @_;
220
    my $ip_range   = $args->{ip_range};
221
    my @cache_keys = ();
222
    if ($ip_range) {
223
        my $now = $self->_get_truncated_timestamp();
224
        if ($now) {
225
226
            #Default to last 5 minutes
227
            my $last_n_minutes = $self->last_n_minutes;
228
            my $max_bound      = ( $last_n_minutes - 1 );
229
            if ( $self->debug ) {
230
                warn "Check visits over the last $last_n_minutes minutes\n";
231
            }
232
            foreach my $minus_minutes ( 0 .. $max_bound ) {
233
                my $timestamp = $now;
234
                if ($minus_minutes) {
235
                    $timestamp -= ( $minus_minutes * 60 );
236
                }
237
                my $expanded_time = scalar localtime($timestamp);
238
                if ( $self->debug ) {
239
                    warn "Considered time slot: $expanded_time\n";
240
                }
241
                my $cache_key = sprintf( "%s:%s", $ip_range, $timestamp );
242
                push( @cache_keys, $cache_key );
243
            }
244
        }
245
    }
246
    return \@cache_keys;
247
}
248
249
sub _prepare_template {
250
    my ( $self, $args ) = @_;
251
    my $template;
252
    my $interface         = $self->interface;
253
    my $template_filename = $args->{template_filename};
254
    if ( $interface && $template_filename ) {
255
256
        #NOTE: See Koha::Template in BZ 31380
257
        $template = C4::Templates::gettemplate( $template_filename, $interface );
258
    }
259
    return $template;
260
}
261
262
1;
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-throttled.tt (-1 / +42 lines)
Line 0 Link Here
0
- 
1
[% USE raw %]
2
[% INCLUDE 'doc-head-open.inc' %]
3
<title>Too many requests &rsaquo; [% IF ( LibraryNameTitle ) %][% LibraryNameTitle | html %][% ELSE %]Koha online[% END %] catalogue</title>
4
[% INCLUDE 'doc-head-close.inc' %]
5
[% BLOCK cssinclude %][% END %]
6
</head>
7
<body id="error[% errno | html %]" class="error">
8
[% INCLUDE 'masthead.inc' %]
9
    <div class="main">
10
        [% WRAPPER breadcrumbs %]
11
            [% WRAPPER breadcrumb_item bc_active = 1 %]
12
                <span>Too many requests</span>
13
            [% END %]
14
        [% END #/ WRAPPER breadcrumbs %]
15
16
        <div class="container-fluid">
17
            <div class="row">
18
                <div class="col order-first order-md-first order-lg-2">
19
                    <h1>Sorry, the requested page is not available</h1>
20
                    <h2>Too many requests</h2>
21
                    <p>
22
                        <strong>This message can have the following reason(s):</strong>
23
                    </p>
24
                    <ul>
25
                        <li>Too many requests have been received from your device. Please try again after a few minutes.</li>
26
                        <li>If you believe there has been an error, please contact the Koha administrator.</li>
27
                    </ul>
28
                </div>
29
            </div> <!-- / .row -->
30
        </div> <!-- / .container-fluid -->
31
    </div> <!-- / .main -->
32
[% INCLUDE 'opac-bottom.inc' %]
33
[% BLOCK jsinclude %]
34
[% END %]
35
[% BLOCK cssinclude %]
36
<style>
37
    ul#members {
38
        display: none;
39
    }
40
</style>
41
[% END %]
42

Return to bug 39109