Implements automated detection and blocking of IPs exhibiting scanning behavior (5+ unique 404s within 1 minute). Key features: - Progressive ban escalation (5 min → 1 hour → permanent) - CIDR range and exact IP whitelisting - Redis-backed 404 path tracking with 60s TTL - Cloudflare WAF integration for permanent bans - Admin UI for whitelist and blocked IP management - Oban cron jobs for cleanup (expired bans, stale violations) - ETS caching for whitelist performance Components: - Plug: ToweropsWeb.Plugs.BruteForceProtection - Context: Towerops.Security.BruteForce - Schemas: IpBlock, IpWhitelist - Workers: CloudflareBanWorker, ExpiredBanCleanupWorker, StaleViolationCleanupWorker - Admin UI: /admin/security (superuser only) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
269 lines
8.6 KiB
Elixir
269 lines
8.6 KiB
Elixir
defmodule Towerops.Security.BruteForceTest do
|
|
use Towerops.DataCase, async: true
|
|
|
|
alias Towerops.Security.BruteForce
|
|
alias Towerops.Security.IpBlock
|
|
|
|
describe "whitelist management" do
|
|
test "check_whitelist/1 returns false for non-whitelisted IP" do
|
|
refute BruteForce.check_whitelist("192.168.1.100")
|
|
end
|
|
|
|
test "check_whitelist/1 returns true for exact IP match" do
|
|
user = user_fixture()
|
|
{:ok, _} = BruteForce.add_to_whitelist("192.168.1.100", "Test IP", user)
|
|
|
|
assert BruteForce.check_whitelist("192.168.1.100")
|
|
end
|
|
|
|
test "check_whitelist/1 returns true for IP in CIDR range" do
|
|
user = user_fixture()
|
|
{:ok, _} = BruteForce.add_to_whitelist("192.168.1.0/24", "Test network", user)
|
|
|
|
assert BruteForce.check_whitelist("192.168.1.50")
|
|
assert BruteForce.check_whitelist("192.168.1.1")
|
|
assert BruteForce.check_whitelist("192.168.1.254")
|
|
refute BruteForce.check_whitelist("192.168.2.1")
|
|
end
|
|
|
|
test "add_to_whitelist/3 creates whitelist entry" do
|
|
user = user_fixture()
|
|
|
|
{:ok, entry} = BruteForce.add_to_whitelist("10.0.0.0/8", "Private network", user)
|
|
|
|
assert entry.ip_or_cidr == "10.0.0.0/8"
|
|
assert entry.description == "Private network"
|
|
assert entry.added_by_id == user.id
|
|
end
|
|
|
|
test "add_to_whitelist/3 validates IP format" do
|
|
user = user_fixture()
|
|
|
|
{:error, changeset} = BruteForce.add_to_whitelist("invalid-ip", "Bad IP", user)
|
|
|
|
assert "is not a valid IP address or CIDR range" in errors_on(changeset).ip_or_cidr
|
|
end
|
|
|
|
test "add_to_whitelist/3 validates CIDR format" do
|
|
user = user_fixture()
|
|
|
|
{:error, changeset} = BruteForce.add_to_whitelist("192.168.1.0/99", "Invalid CIDR", user)
|
|
|
|
assert "is not a valid CIDR range" in errors_on(changeset).ip_or_cidr
|
|
end
|
|
|
|
test "remove_from_whitelist/1 deletes entry" do
|
|
user = user_fixture()
|
|
{:ok, entry} = BruteForce.add_to_whitelist("192.168.1.100", "Test", user)
|
|
|
|
assert :ok = BruteForce.remove_from_whitelist(entry.id)
|
|
refute BruteForce.check_whitelist("192.168.1.100")
|
|
end
|
|
|
|
test "list_whitelist/0 returns all entries" do
|
|
user = user_fixture()
|
|
{:ok, _} = BruteForce.add_to_whitelist("192.168.1.1", "IP 1", user)
|
|
{:ok, _} = BruteForce.add_to_whitelist("192.168.1.0/24", "Network", user)
|
|
|
|
entries = BruteForce.list_whitelist()
|
|
|
|
assert length(entries) == 2
|
|
end
|
|
end
|
|
|
|
describe "ban management" do
|
|
test "check_ban_status/1 returns :allowed for non-banned IP" do
|
|
assert :allowed = BruteForce.check_ban_status("192.168.1.100")
|
|
end
|
|
|
|
test "check_ban_status/1 returns :blocked for currently banned IP" do
|
|
# Create a temporary ban
|
|
banned_until = DateTime.add(DateTime.utc_now(), 300, :second)
|
|
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "192.168.1.100",
|
|
offense_count: 1,
|
|
banned_until: banned_until,
|
|
last_violation_at: DateTime.utc_now(),
|
|
reason: "Test ban"
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
assert {:blocked, returned_until} = BruteForce.check_ban_status("192.168.1.100")
|
|
# Check that ban is active (within 1 second of expected time due to DB precision)
|
|
assert DateTime.diff(returned_until, banned_until, :second) in -1..1
|
|
end
|
|
|
|
test "check_ban_status/1 returns :allowed for expired ban" do
|
|
# Create an expired ban
|
|
banned_until = DateTime.add(DateTime.utc_now(), -60, :second)
|
|
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "192.168.1.100",
|
|
offense_count: 1,
|
|
banned_until: banned_until,
|
|
last_violation_at: DateTime.add(DateTime.utc_now(), -120, :second),
|
|
reason: "Test ban"
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
assert :allowed = BruteForce.check_ban_status("192.168.1.100")
|
|
end
|
|
|
|
test "create_or_escalate_ban/1 creates first offense with 5-minute ban" do
|
|
{:ok, block} = BruteForce.create_or_escalate_ban("192.168.1.100")
|
|
|
|
assert block.ip_address == "192.168.1.100"
|
|
assert block.offense_count == 1
|
|
assert DateTime.diff(block.banned_until, DateTime.utc_now(), :second) in 295..305
|
|
end
|
|
|
|
test "create_or_escalate_ban/1 escalates to 1-hour ban on second offense" do
|
|
# Create first offense
|
|
{:ok, _} = BruteForce.create_or_escalate_ban("192.168.1.100")
|
|
|
|
# Trigger escalation
|
|
{:ok, block} = BruteForce.create_or_escalate_ban("192.168.1.100")
|
|
|
|
assert block.offense_count == 2
|
|
assert DateTime.diff(block.banned_until, DateTime.utc_now(), :second) in 3595..3605
|
|
end
|
|
|
|
test "create_or_escalate_ban/1 creates permanent ban on third offense" do
|
|
# Create first two offenses
|
|
{:ok, _} = BruteForce.create_or_escalate_ban("192.168.1.100")
|
|
{:ok, _} = BruteForce.create_or_escalate_ban("192.168.1.100")
|
|
|
|
# Trigger permanent ban
|
|
{:ok, block} = BruteForce.create_or_escalate_ban("192.168.1.100")
|
|
|
|
assert block.offense_count == 3
|
|
assert is_nil(block.banned_until)
|
|
end
|
|
|
|
test "create_or_escalate_ban/1 resets after 30 days" do
|
|
# Create old violation (31 days ago)
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "192.168.1.100",
|
|
offense_count: 2,
|
|
banned_until: DateTime.add(DateTime.utc_now(), -86_400 * 30, :second),
|
|
last_violation_at: DateTime.add(DateTime.utc_now(), -86_400 * 31, :second),
|
|
reason: "Old violation"
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
# New violation should reset to first offense
|
|
{:ok, block} = BruteForce.create_or_escalate_ban("192.168.1.100")
|
|
|
|
assert block.offense_count == 1
|
|
assert DateTime.diff(block.banned_until, DateTime.utc_now(), :second) in 295..305
|
|
end
|
|
|
|
test "manually_unblock/1 removes ban" do
|
|
{:ok, _} = BruteForce.create_or_escalate_ban("192.168.1.100")
|
|
|
|
{:ok, _} = BruteForce.manually_unblock("192.168.1.100")
|
|
|
|
assert :allowed = BruteForce.check_ban_status("192.168.1.100")
|
|
end
|
|
|
|
test "list_blocked_ips/1 filters by permanent status" do
|
|
# Create temporary ban
|
|
{:ok, _} = BruteForce.create_or_escalate_ban("192.168.1.1")
|
|
|
|
# Create permanent ban
|
|
{:ok, _} = BruteForce.create_or_escalate_ban("192.168.1.2")
|
|
{:ok, _} = BruteForce.create_or_escalate_ban("192.168.1.2")
|
|
{:ok, _} = BruteForce.create_or_escalate_ban("192.168.1.2")
|
|
|
|
permanent = BruteForce.list_blocked_ips(:permanent)
|
|
temporary = BruteForce.list_blocked_ips(:temporary)
|
|
|
|
assert length(permanent) == 1
|
|
assert length(temporary) == 1
|
|
assert hd(permanent).ip_address == "192.168.1.2"
|
|
assert hd(temporary).ip_address == "192.168.1.1"
|
|
end
|
|
end
|
|
|
|
describe "cleanup" do
|
|
test "delete_expired_bans/0 removes expired temporary bans" do
|
|
# Create expired ban
|
|
banned_until = DateTime.add(DateTime.utc_now(), -60, :second)
|
|
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "192.168.1.100",
|
|
offense_count: 1,
|
|
banned_until: banned_until,
|
|
last_violation_at: DateTime.add(DateTime.utc_now(), -120, :second),
|
|
reason: "Test"
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
BruteForce.delete_expired_bans()
|
|
|
|
assert [] = Repo.all(IpBlock)
|
|
end
|
|
|
|
test "delete_expired_bans/0 keeps active bans" do
|
|
# Create active ban
|
|
banned_until = DateTime.add(DateTime.utc_now(), 300, :second)
|
|
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "192.168.1.100",
|
|
offense_count: 1,
|
|
banned_until: banned_until,
|
|
last_violation_at: DateTime.utc_now(),
|
|
reason: "Test"
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
BruteForce.delete_expired_bans()
|
|
|
|
assert [_] = Repo.all(IpBlock)
|
|
end
|
|
|
|
test "delete_stale_violations/0 removes old non-permanent bans" do
|
|
# Create old violation (91 days ago)
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "192.168.1.100",
|
|
offense_count: 2,
|
|
banned_until: DateTime.add(DateTime.utc_now(), -86_400 * 90, :second),
|
|
last_violation_at: DateTime.add(DateTime.utc_now(), -86_400 * 91, :second),
|
|
reason: "Old"
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
BruteForce.delete_stale_violations()
|
|
|
|
assert [] = Repo.all(IpBlock)
|
|
end
|
|
|
|
test "delete_stale_violations/0 keeps permanent bans" do
|
|
# Create old permanent ban
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "192.168.1.100",
|
|
offense_count: 3,
|
|
banned_until: nil,
|
|
last_violation_at: DateTime.add(DateTime.utc_now(), -86_400 * 91, :second),
|
|
reason: "Permanent"
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
BruteForce.delete_stale_violations()
|
|
|
|
assert [_] = Repo.all(IpBlock)
|
|
end
|
|
end
|
|
|
|
defp user_fixture do
|
|
Towerops.AccountsFixtures.user_fixture()
|
|
end
|
|
end
|