forked from github/kensanata.oddmuse
This allows the use of whitespace to separate terms when using page matching. This seems more natural than requiring underscores to match the space between words in a page title. So now, a page with id "one_two" is matched by terms such as "one_two", "two_one", "one two" and "two one" (notice the word order).
199 lines
6.8 KiB
Perl
199 lines
6.8 KiB
Perl
# -*- mode: perl -*-
|
|
# Copyright (C) 2023 Alex Schroeder <alex@gnu.org>
|
|
|
|
# This program is free software: you can redistribute it and/or modify it under
|
|
# the terms of the GNU Affero General Public License as published by the Free
|
|
# Software Foundation, either version 3 of the License, or (at your option) any
|
|
# later version.
|
|
#
|
|
# This program 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 Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along
|
|
# with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
=encoding utf8
|
|
|
|
=head1 NAME
|
|
|
|
Oddmuse Network Blocker
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
This module hooks into regular Oddmuse Surge Protection. It adds the following
|
|
features:
|
|
|
|
Repeated offenders are blocked for increasingly longer times.
|
|
|
|
For every offender, we record the CIDR their IP number belongs to. Everytime an
|
|
IP number is blocked, all the CIDRs of the other blocked IPs are checked: if
|
|
there are three or more blocked IP numbers sharing the same CIDRs, the CIDR
|
|
itself is blocked.
|
|
|
|
CIDR blocking works the same way: Repeated offenders are blocked for
|
|
increasingly longer times.
|
|
|
|
=head2 Behind a reverse proxy
|
|
|
|
Make sure your config file copies the IP number to the correct environment
|
|
variable:
|
|
|
|
$ENV{REMOTE_ADDR} = $ENV{HTTP_X_FORWARDED_FOR};
|
|
|
|
=head1 SEE ALSO
|
|
|
|
<Oddmuse Surge Protection|https://oddmuse.org/wiki/Surge_Protection>
|
|
|
|
=cut
|
|
|
|
use strict;
|
|
use v5.10;
|
|
use Net::IP qw(:PROC);
|
|
use Net::DNS qw(rr);
|
|
|
|
AddModuleDescription('network-blocker.pl', 'Network Blocker Extension');
|
|
|
|
our ($Now, $DataDir, $SurgeProtectionViews, $SurgeProtectionTime);
|
|
|
|
{
|
|
no warnings 'redefine';
|
|
*OldNetworkBlockerDelayRequired = \&DelayRequired;
|
|
*DelayRequired = \&NewNetworkBlockerDelayRequired;
|
|
}
|
|
|
|
# Block for at least this many seconds.
|
|
my $NetworkBlockerMinimumPeriod = 30;
|
|
|
|
# Every violation doubles the current period until this maximum is reached (four weeks).
|
|
my $NetworkBlockerMaximumPeriod = 60 * 60 * 24 * 7 * 4;
|
|
|
|
# All the blocked networks. Maps CIDR to an array [expiry timestamp, expiry
|
|
# period].
|
|
my %NetworkBlockerList;
|
|
|
|
# Candidates are remembered for this many seconds.
|
|
my $NetworkBlockerCachePeriod = 600;
|
|
|
|
# All the candidate networks for a block. Maps IP to an array [ts, cidr, ...].
|
|
# Candidates are removed after $NetworkBlockerCachePeriod.
|
|
my %NetworkBlockerCandidates;
|
|
|
|
sub NetworkBlockerRead {
|
|
my ($status, $data) = ReadFile("$DataDir/network-blocks");
|
|
return unless $status;
|
|
my @lines = split(/\n/, $data);
|
|
while ($_ = shift(@lines)) {
|
|
my @items = split(/,/);
|
|
$NetworkBlockerList{shift(@items)} = \@items;
|
|
}
|
|
# an empty line separates the two sections
|
|
while ($_ = shift(@lines)) {
|
|
my @items = split(/,/);
|
|
$NetworkBlockerCandidates{shift(@items)} = \@items;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
sub NetworkBlockerWrite {
|
|
RequestLockDir('network-blocks') or return '';
|
|
WriteStringToFile(
|
|
"$DataDir/network-blocks",
|
|
join("\n\n",
|
|
join("\n", map {
|
|
join(",", $_, @{$NetworkBlockerList{$_}})
|
|
} keys %NetworkBlockerList),
|
|
join("\n", map {
|
|
join(",", $_, @{$NetworkBlockerCandidates{$_}})
|
|
} keys %NetworkBlockerCandidates)));
|
|
ReleaseLockDir('network-blocks');
|
|
}
|
|
|
|
sub NewNetworkBlockerDelayRequired {
|
|
my $ip = shift;
|
|
# If $ip is a name and not an IP number, parsing fails. In this case, run the
|
|
# regular code.
|
|
my $ob = new Net::IP($ip);
|
|
return OldNetworkBlockerDelayRequired($ip) unless $ob;
|
|
# Read the file. If the file does not exist, no problem.
|
|
NetworkBlockerRead();
|
|
# See if the current IP number is one of the blocked CIDR ranges.
|
|
for my $cidr (keys %NetworkBlockerList) {
|
|
# Perhaps this CIDR block can be expired.
|
|
if ($NetworkBlockerList{$cidr}->[0] < $Now) {
|
|
delete $NetworkBlockerList{$cidr};
|
|
next;
|
|
}
|
|
# Forget the CIDR if it cannot be turned into a range.
|
|
my $range = new Net::IP($cidr);
|
|
if (not $range) {
|
|
warn "CIDR $cidr is blocked but has no range: " . Net::IP::Error();
|
|
delete $NetworkBlockerList{$cidr};
|
|
next;
|
|
}
|
|
# If the CIDR overlaps with the remote IP number, it's a block.
|
|
warn "Checking whether $ip is in $cidr\n";
|
|
my $overlap = $range->overlaps($ob);
|
|
# $IP_PARTIAL_OVERLAP (ranges overlap) $IP_NO_OVERLAP (no overlap)
|
|
# $IP_A_IN_B_OVERLAP (range2 contains range1) $IP_B_IN_A_OVERLAP (range1
|
|
# contains range2) $IP_IDENTICAL (ranges are identical) undef (problem)
|
|
if (defined $overlap and $overlap != $IP_NO_OVERLAP) {
|
|
# Double the block period unless it has reached $NetworkBlockerMaximumPeriod.
|
|
if ($NetworkBlockerList{$cidr}->[1] < $NetworkBlockerMaximumPeriod / 2) {
|
|
$NetworkBlockerList{$cidr}->[1] *= 2;
|
|
} else {
|
|
$NetworkBlockerList{$cidr}->[1] = $NetworkBlockerMaximumPeriod;
|
|
}
|
|
$NetworkBlockerList{$cidr}->[0] = $Now + $NetworkBlockerList{$cidr}->[1];
|
|
# And we're done!
|
|
NetworkBlockerWrite();
|
|
ReportError(Ts('Too many connections by %s', $cidr)
|
|
. ': ' . Tss('Please do not fetch more than %1 pages in %2 seconds.',
|
|
$SurgeProtectionViews, $SurgeProtectionTime),
|
|
'503 SERVICE UNAVAILABLE');
|
|
}
|
|
}
|
|
# If the CIDR isn't blocked, let's see if Surge Protection wants to block it.
|
|
my $result = OldNetworkBlockerDelayRequired($ip);
|
|
warn "$ip was blocked\n" if $result;
|
|
# If the IP is to be blocked, determine its CIDRs and put them on a list. Sadly,
|
|
# routeviews does not support IPv6 at the moment!
|
|
if ($result and not ip_is_ipv6($ip) and not $NetworkBlockerCandidates{$ip}) {
|
|
my $reverse = $ob->reverse_ip();
|
|
$reverse =~ s/in-addr\.arpa\.$/asn.routeviews.org/;
|
|
my @candidates;
|
|
for my $rr (rr($reverse, "TXT")) {
|
|
next unless $rr->type eq "TXT";
|
|
my @data = $rr->txtdata;
|
|
push(@candidates, join("/", @data[1..2]));
|
|
}
|
|
warn "$ip is in @candidates\n";
|
|
$NetworkBlockerCandidates{$ip} = [$Now, @candidates];
|
|
# Expire any of the other candidates
|
|
for my $other_ip (keys %NetworkBlockerCandidates) {
|
|
if ($NetworkBlockerCandidates{$other_ip}->[0] < $Now - $NetworkBlockerCachePeriod) {
|
|
delete $NetworkBlockerCandidates{$other_ip};
|
|
}
|
|
}
|
|
# Determine if any of the CIDRs is to be blocked.
|
|
my $save;
|
|
for my $cidr (@candidates) {
|
|
# Count how often the candidate CIDRs show up for other IP numbers.
|
|
my $count = 0;
|
|
for my $other_ip (keys %NetworkBlockerCandidates) {
|
|
my @data = $NetworkBlockerCandidates{$other_ip};
|
|
for my $other_cidr (@data[1 .. $#data]) {
|
|
$count++ if $cidr eq $other_cidr;
|
|
}
|
|
}
|
|
if ($count >= 3) {
|
|
$NetworkBlockerList{$cidr} = [$Now + $NetworkBlockerMinimumPeriod, $NetworkBlockerMinimumPeriod];
|
|
$save = 1;
|
|
}
|
|
}
|
|
NetworkBlockerWrite() if $save;
|
|
}
|
|
return $result;
|
|
}
|