CI / Build and Test (gcc, g++, ubuntu-latest) (push) Failing after 5m2s
CI / Code Coverage (push) Skipped
Build and Publish Docker Image / build-and-test (push) Failing after 6m13s
Build and Publish Docker Image / build-and-push-image (push) Skipped
Build and Publish Docker Image / security-scan (push) Skipped
The per-IP ban tracker treats every globally-routable client equally, but an aggregating front-end like the finger-web proxy funnels the whole internet's federated lookups through a single IP. A burst from any one client of the proxy (or a load test) is then attributed to the proxy's IP and, once it crosses the failure threshold, the daemon blocks the proxy — taking out finger lookups for everyone. Per-client abuse protection for the proxied path belongs in the proxy (which now rate-limits per real client IP), so the daemon should trust it. Add a FINGER_BAN_ALLOWLIST env var (comma-separated IPs). Allowlisted addresses are marked non-trackable in the listener, so their connections are never blocked and never recorded as offenses. Unset = unchanged behaviour. - parse_ip_allowlist() in ban.cpp (trims entries, skips blanks) + unit tests - listener() consults the set when computing 'trackable' - documented in docker-compose.yml and DOCKER.md
153 lines
5.3 KiB
C++
153 lines
5.3 KiB
C++
#include "ban.hpp"
|
|
#include <boost/asio/ip/address.hpp>
|
|
#include <gtest/gtest.h>
|
|
|
|
using namespace std::chrono_literals;
|
|
using clock_t_ = BanTracker::clock;
|
|
|
|
// Work well away from the steady_clock epoch so that subtracting the window
|
|
// never underflows and default-constructed time_points are unambiguous.
|
|
static const clock_t_::time_point kBase = clock_t_::time_point{} + 1000h;
|
|
|
|
TEST(BanTracker, UnknownIpIsNotBlocked) {
|
|
BanTracker bt;
|
|
EXPECT_FALSE(bt.is_blocked("1.2.3.4", kBase));
|
|
}
|
|
|
|
TEST(BanTracker, BlocksOnlyAfterMoreThanThreshold) {
|
|
BanTracker bt; // default threshold = 3, so block on the 4th failure
|
|
EXPECT_FALSE(bt.record_offense("1.2.3.4", kBase).blocked); // 1
|
|
EXPECT_FALSE(bt.record_offense("1.2.3.4", kBase).blocked); // 2
|
|
EXPECT_FALSE(bt.record_offense("1.2.3.4", kBase).blocked); // 3
|
|
EXPECT_FALSE(bt.is_blocked("1.2.3.4", kBase));
|
|
auto r = bt.record_offense("1.2.3.4", kBase); // 4
|
|
EXPECT_TRUE(r.blocked);
|
|
EXPECT_EQ(r.count, 4);
|
|
EXPECT_TRUE(bt.is_blocked("1.2.3.4", kBase));
|
|
}
|
|
|
|
TEST(BanTracker, TracksEachIpIndependently) {
|
|
BanTracker bt;
|
|
for (int i = 0; i < 4; ++i) {
|
|
bt.record_offense("1.1.1.1", kBase);
|
|
}
|
|
EXPECT_TRUE(bt.is_blocked("1.1.1.1", kBase));
|
|
EXPECT_FALSE(bt.is_blocked("2.2.2.2", kBase));
|
|
}
|
|
|
|
TEST(BanTracker, OffensesAgeOutOfRollingWindow) {
|
|
BanTracker bt;
|
|
// Four failures spread over a couple of hours -> blocked.
|
|
for (int i = 0; i < 4; ++i) {
|
|
bt.record_offense("1.2.3.4", kBase + i * 1h);
|
|
}
|
|
EXPECT_TRUE(bt.is_blocked("1.2.3.4", kBase + 3h));
|
|
|
|
// 24h after the first failure, that one drops out of the window: only 3
|
|
// remain, so the IP is no longer blocked.
|
|
EXPECT_FALSE(bt.is_blocked("1.2.3.4", kBase + 24h + 1min));
|
|
}
|
|
|
|
TEST(BanTracker, WindowBoundaryIsExclusiveAtCutoff) {
|
|
BanTracker bt;
|
|
// Exactly window-old timestamps are pruned (cutoff is inclusive of <=).
|
|
bt.record_offense("1.2.3.4", kBase);
|
|
auto r = bt.record_offense("1.2.3.4", kBase + 24h);
|
|
EXPECT_EQ(r.count, 1); // the kBase entry was pruned before appending
|
|
}
|
|
|
|
TEST(BanTracker, SweepRemovesFullyExpiredIp) {
|
|
BanTracker bt;
|
|
for (int i = 0; i < 4; ++i) {
|
|
bt.record_offense("1.2.3.4", kBase);
|
|
}
|
|
EXPECT_EQ(bt.tracked(), 1u);
|
|
bt.sweep(kBase + 24h + 1min); // all offenses aged out
|
|
EXPECT_EQ(bt.tracked(), 0u);
|
|
}
|
|
|
|
TEST(BanTracker, SweepKeepsStillActiveIp) {
|
|
BanTracker bt;
|
|
for (int i = 0; i < 4; ++i) {
|
|
bt.record_offense("1.2.3.4", kBase);
|
|
}
|
|
bt.sweep(kBase + 1h); // still inside the window
|
|
EXPECT_EQ(bt.tracked(), 1u);
|
|
EXPECT_TRUE(bt.is_blocked("1.2.3.4", kBase + 1h));
|
|
}
|
|
|
|
TEST(BanTracker, RespectsCustomConfig) {
|
|
BanTracker bt(BanTracker::Config{/*threshold=*/1, /*window=*/1h});
|
|
EXPECT_FALSE(bt.record_offense("9.9.9.9", kBase).blocked); // 1, not > 1
|
|
EXPECT_TRUE(bt.record_offense("9.9.9.9", kBase).blocked); // 2 > 1
|
|
EXPECT_TRUE(bt.is_blocked("9.9.9.9", kBase));
|
|
EXPECT_FALSE(bt.is_blocked("9.9.9.9", kBase + 1h + 1min)); // window elapsed
|
|
}
|
|
|
|
static bool bannable(const char *ip) {
|
|
return is_bannable_address(boost::asio::ip::make_address(ip));
|
|
}
|
|
|
|
TEST(BannableAddress, GlobalIpv4IsBannable) {
|
|
EXPECT_TRUE(bannable("8.8.8.8"));
|
|
EXPECT_TRUE(bannable("192.184.167.198")); // a real scanner seen in the logs
|
|
EXPECT_TRUE(bannable("1.2.3.4"));
|
|
}
|
|
|
|
TEST(BannableAddress, PrivateAndLocalIpv4AreNotBannable) {
|
|
EXPECT_FALSE(bannable("127.0.0.1")); // loopback
|
|
EXPECT_FALSE(bannable("10.1.2.3")); // 10/8
|
|
EXPECT_FALSE(bannable("172.20.0.1")); // Docker bridge gateway (172.16/12)
|
|
EXPECT_FALSE(bannable("172.31.255.1"));
|
|
EXPECT_FALSE(bannable("192.168.1.104")); // the finger jail's own LAN IP
|
|
EXPECT_FALSE(bannable("169.254.10.1")); // link-local
|
|
EXPECT_FALSE(bannable("224.0.0.1")); // multicast
|
|
}
|
|
|
|
TEST(BannableAddress, CgnatRangeIsNotBannable) {
|
|
EXPECT_FALSE(bannable("100.64.0.1")); // bottom of 100.64/10 (CGNAT/Tailscale)
|
|
EXPECT_FALSE(bannable("100.127.255.1")); // top of the range
|
|
EXPECT_TRUE(bannable("100.63.255.1")); // just below the range -> public
|
|
EXPECT_TRUE(bannable("100.128.0.1")); // just above the range -> public
|
|
}
|
|
|
|
TEST(BannableAddress, Ipv6Classification) {
|
|
EXPECT_TRUE(bannable("2001:4860:4860::8888")); // global
|
|
EXPECT_FALSE(bannable("::1")); // loopback
|
|
EXPECT_FALSE(bannable("fe80::1")); // link-local
|
|
EXPECT_FALSE(bannable("fc00::1")); // unique-local
|
|
EXPECT_FALSE(bannable("fd12:3456::1")); // unique-local
|
|
}
|
|
|
|
TEST(IpAllowlist, ParsesCommaSeparatedTrimmedEntries) {
|
|
auto a = parse_ip_allowlist("147.182.255.203, 10.0.0.1 ,\t2a01:4f8:190:7447::2");
|
|
EXPECT_EQ(a.size(), 3u);
|
|
EXPECT_TRUE(a.count("147.182.255.203"));
|
|
EXPECT_TRUE(a.count("10.0.0.1"));
|
|
EXPECT_TRUE(a.count("2a01:4f8:190:7447::2"));
|
|
}
|
|
|
|
TEST(IpAllowlist, SingleEntryNoCommas) {
|
|
auto a = parse_ip_allowlist("147.182.255.203");
|
|
EXPECT_EQ(a.size(), 1u);
|
|
EXPECT_TRUE(a.count("147.182.255.203"));
|
|
}
|
|
|
|
TEST(IpAllowlist, EmptyAndBlankYieldEmptySet) {
|
|
EXPECT_TRUE(parse_ip_allowlist("").empty());
|
|
EXPECT_TRUE(parse_ip_allowlist(" ").empty());
|
|
EXPECT_TRUE(parse_ip_allowlist(",, ,\t,").empty()); // only separators/blanks
|
|
}
|
|
|
|
TEST(IpAllowlist, IgnoresEmptyEntriesBetweenCommas) {
|
|
auto a = parse_ip_allowlist("8.8.8.8,,9.9.9.9,");
|
|
EXPECT_EQ(a.size(), 2u);
|
|
EXPECT_TRUE(a.count("8.8.8.8"));
|
|
EXPECT_TRUE(a.count("9.9.9.9"));
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
::testing::InitGoogleTest(&argc, argv);
|
|
return RUN_ALL_TESTS();
|
|
}
|