network-blocker.pl 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. # -*- mode: perl -*-
  2. # Copyright (C) 2023 Alex Schroeder <alex@gnu.org>
  3. # This program is free software: you can redistribute it and/or modify it under
  4. # the terms of the GNU Affero General Public License as published by the Free
  5. # Software Foundation, either version 3 of the License, or (at your option) any
  6. # later version.
  7. #
  8. # This program is distributed in the hope that it will be useful, but WITHOUT
  9. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  10. # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
  11. # details.
  12. #
  13. # You should have received a copy of the GNU Affero General Public License along
  14. # with this program. If not, see <https://www.gnu.org/licenses/>.
  15. =encoding utf8
  16. =head1 NAME
  17. Oddmuse Network Blocker
  18. =head1 DESCRIPTION
  19. This module hooks into regular Oddmuse Surge Protection. It adds the following
  20. features:
  21. Repeated offenders are blocked for increasingly longer times.
  22. For every offender, we record the CIDR their IP number belongs to. Everytime an
  23. IP number is blocked, all the CIDRs of the other blocked IPs are checked: if
  24. there are three or more blocked IP numbers sharing the same CIDRs, the CIDR
  25. itself is blocked.
  26. CIDR blocking works the same way: Repeated offenders are blocked for
  27. increasingly longer times.
  28. =head2 Behind a reverse proxy
  29. Make sure your config file copies the IP number to the correct environment
  30. variable:
  31. $ENV{REMOTE_ADDR} = $ENV{HTTP_X_FORWARDED_FOR};
  32. =head1 SEE ALSO
  33. <Oddmuse Surge Protection|https://oddmuse.org/wiki/Surge_Protection>
  34. =cut
  35. use strict;
  36. use v5.10;
  37. use Net::IP qw(:PROC);
  38. use Net::DNS qw(rr);
  39. AddModuleDescription('network-blocker.pl', 'Network Blocker Extension');
  40. our ($Now, $DataDir, $SurgeProtectionViews, $SurgeProtectionTime);
  41. {
  42. no warnings 'redefine';
  43. *OldNetworkBlockerDelayRequired = \&DelayRequired;
  44. *DelayRequired = \&NewNetworkBlockerDelayRequired;
  45. }
  46. # Block for at least this many seconds.
  47. my $NetworkBlockerMinimumPeriod = 30;
  48. # Every violation doubles the current period until this maximum is reached (four weeks).
  49. my $NetworkBlockerMaximumPeriod = 60 * 60 * 24 * 7 * 4;
  50. # All the blocked networks. Maps CIDR to an array [expiry timestamp, expiry
  51. # period].
  52. my %NetworkBlockerList;
  53. # Candidates are remembered for this many seconds.
  54. my $NetworkBlockerCachePeriod = 600;
  55. # All the candidate networks for a block. Maps IP to an array [ts, cidr, ...].
  56. # Candidates are removed after $NetworkBlockerCachePeriod.
  57. my %NetworkBlockerCandidates;
  58. sub NetworkBlockerRead {
  59. my ($status, $data) = ReadFile("$DataDir/network-blocks");
  60. return unless $status;
  61. my @lines = split(/\n/, $data);
  62. while ($_ = shift(@lines)) {
  63. my @items = split(/,/);
  64. $NetworkBlockerList{shift(@items)} = \@items;
  65. }
  66. # an empty line separates the two sections
  67. while ($_ = shift(@lines)) {
  68. my @items = split(/,/);
  69. $NetworkBlockerCandidates{shift(@items)} = \@items;
  70. }
  71. return 1;
  72. }
  73. sub NetworkBlockerWrite {
  74. RequestLockDir('network-blocks') or return '';
  75. WriteStringToFile(
  76. "$DataDir/network-blocks",
  77. join("\n\n",
  78. join("\n", map {
  79. join(",", $_, @{$NetworkBlockerList{$_}})
  80. } keys %NetworkBlockerList),
  81. join("\n", map {
  82. join(",", $_, @{$NetworkBlockerCandidates{$_}})
  83. } keys %NetworkBlockerCandidates)));
  84. ReleaseLockDir('network-blocks');
  85. }
  86. sub NewNetworkBlockerDelayRequired {
  87. my $ip = shift;
  88. # If $ip is a name and not an IP number, parsing fails. In this case, run the
  89. # regular code.
  90. my $ob = new Net::IP($ip);
  91. return OldNetworkBlockerDelayRequired($ip) unless $ob;
  92. # Read the file. If the file does not exist, no problem.
  93. NetworkBlockerRead();
  94. # See if the current IP number is one of the blocked CIDR ranges.
  95. for my $cidr (keys %NetworkBlockerList) {
  96. # Perhaps this CIDR block can be expired.
  97. if ($NetworkBlockerList{$cidr}->[0] < $Now) {
  98. delete $NetworkBlockerList{$cidr};
  99. next;
  100. }
  101. # Forget the CIDR if it cannot be turned into a range.
  102. my $range = new Net::IP($cidr);
  103. if (not $range) {
  104. warn "CIDR $cidr is blocked but has no range: " . Net::IP::Error();
  105. delete $NetworkBlockerList{$cidr};
  106. next;
  107. }
  108. # If the CIDR overlaps with the remote IP number, it's a block.
  109. warn "Checking whether $ip is in $cidr\n";
  110. my $overlap = $range->overlaps($ob);
  111. # $IP_PARTIAL_OVERLAP (ranges overlap) $IP_NO_OVERLAP (no overlap)
  112. # $IP_A_IN_B_OVERLAP (range2 contains range1) $IP_B_IN_A_OVERLAP (range1
  113. # contains range2) $IP_IDENTICAL (ranges are identical) undef (problem)
  114. if (defined $overlap and $overlap != $IP_NO_OVERLAP) {
  115. # Double the block period unless it has reached $NetworkBlockerMaximumPeriod.
  116. if ($NetworkBlockerList{$cidr}->[1] < $NetworkBlockerMaximumPeriod / 2) {
  117. $NetworkBlockerList{$cidr}->[1] *= 2;
  118. } else {
  119. $NetworkBlockerList{$cidr}->[1] = $NetworkBlockerMaximumPeriod;
  120. }
  121. $NetworkBlockerList{$cidr}->[0] = $Now + $NetworkBlockerList{$cidr}->[1];
  122. # And we're done!
  123. NetworkBlockerWrite();
  124. ReportError(Ts('Too many connections by %s', $cidr)
  125. . ': ' . Tss('Please do not fetch more than %1 pages in %2 seconds.',
  126. $SurgeProtectionViews, $SurgeProtectionTime),
  127. '503 SERVICE UNAVAILABLE');
  128. }
  129. }
  130. # If the CIDR isn't blocked, let's see if Surge Protection wants to block it.
  131. my $result = OldNetworkBlockerDelayRequired($ip);
  132. warn "$ip was blocked\n" if $result;
  133. # If the IP is to be blocked, determine its CIDRs and put them on a list. Sadly,
  134. # routeviews does not support IPv6 at the moment!
  135. if ($result and not ip_is_ipv6($ip) and not $NetworkBlockerCandidates{$ip}) {
  136. my $reverse = $ob->reverse_ip();
  137. $reverse =~ s/in-addr\.arpa\.$/asn.routeviews.org/;
  138. my @candidates;
  139. for my $rr (rr($reverse, "TXT")) {
  140. next unless $rr->type eq "TXT";
  141. my @data = $rr->txtdata;
  142. push(@candidates, join("/", @data[1..2]));
  143. }
  144. warn "$ip is in @candidates\n";
  145. $NetworkBlockerCandidates{$ip} = [$Now, @candidates];
  146. # Expire any of the other candidates
  147. for my $other_ip (keys %NetworkBlockerCandidates) {
  148. if ($NetworkBlockerCandidates{$other_ip}->[0] < $Now - $NetworkBlockerCachePeriod) {
  149. delete $NetworkBlockerCandidates{$other_ip};
  150. }
  151. }
  152. # Determine if any of the CIDRs is to be blocked.
  153. my $save;
  154. for my $cidr (@candidates) {
  155. # Count how often the candidate CIDRs show up for other IP numbers.
  156. my $count = 0;
  157. for my $other_ip (keys %NetworkBlockerCandidates) {
  158. my @data = $NetworkBlockerCandidates{$other_ip};
  159. for my $other_cidr (@data[1 .. $#data]) {
  160. $count++ if $cidr eq $other_cidr;
  161. }
  162. }
  163. if ($count >= 3) {
  164. $NetworkBlockerList{$cidr} = [$Now + $NetworkBlockerMinimumPeriod, $NetworkBlockerMinimumPeriod];
  165. $save = 1;
  166. }
  167. }
  168. NetworkBlockerWrite() if $save;
  169. }
  170. return $result;
  171. }