towerops/test/towerops/security/brute_force_test.exs
Graham McIntire 4d73e77f3a
Add 404-based brute force protection system
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>
2026-02-10 16:31:48 -06:00

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