123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199 |
- # -*- 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;
- }
|