312 lines
8.7 KiB
Elixir
312 lines
8.7 KiB
Elixir
defmodule Towerops.Security.BruteForceTest do
|
|
use Towerops.DataCase
|
|
|
|
import Towerops.AccountsFixtures
|
|
|
|
alias Towerops.Security.BruteForce
|
|
alias Towerops.Security.IpBlock
|
|
alias Towerops.Security.IpWhitelist
|
|
|
|
describe "check_ban_status/1" do
|
|
test "returns :allowed for unknown IP" do
|
|
assert :allowed = BruteForce.check_ban_status("203.0.113.1")
|
|
end
|
|
|
|
test "returns :blocked for active temporary ban" do
|
|
banned_until = DateTime.utc_now() |> DateTime.add(300, :second) |> DateTime.truncate(:second)
|
|
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "203.0.113.2",
|
|
offense_count: 1,
|
|
banned_until: banned_until,
|
|
last_violation_at: DateTime.utc_now()
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
assert {:blocked, ^banned_until} = BruteForce.check_ban_status("203.0.113.2")
|
|
end
|
|
|
|
test "returns :allowed for expired temporary ban" do
|
|
banned_until = DateTime.add(DateTime.utc_now(), -300, :second)
|
|
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "203.0.113.3",
|
|
offense_count: 1,
|
|
banned_until: banned_until,
|
|
last_violation_at: DateTime.add(DateTime.utc_now(), -600, :second)
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
assert :allowed = BruteForce.check_ban_status("203.0.113.3")
|
|
end
|
|
|
|
test "returns :blocked with nil for permanent ban" do
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "203.0.113.4",
|
|
offense_count: 3,
|
|
banned_until: nil,
|
|
last_violation_at: DateTime.utc_now()
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
assert {:blocked, nil} = BruteForce.check_ban_status("203.0.113.4")
|
|
end
|
|
end
|
|
|
|
describe "create_or_escalate_ban/2" do
|
|
test "creates first offense with 5-minute ban" do
|
|
ip = "198.51.100.1"
|
|
assert {:ok, block} = BruteForce.create_or_escalate_ban(ip)
|
|
assert block.offense_count == 1
|
|
assert block.ip_address == ip
|
|
assert block.banned_until != nil
|
|
|
|
# Should be ~5 minutes from now
|
|
diff = DateTime.diff(block.banned_until, DateTime.utc_now(), :second)
|
|
assert diff > 250 and diff <= 300
|
|
end
|
|
|
|
test "escalates to 1-hour ban on second offense" do
|
|
ip = "198.51.100.2"
|
|
{:ok, _} = BruteForce.create_or_escalate_ban(ip)
|
|
{:ok, block} = BruteForce.create_or_escalate_ban(ip)
|
|
|
|
assert block.offense_count == 2
|
|
# banned_until is stored as a string in escalation, but the field is utc_datetime
|
|
# so it gets cast — let's check it's roughly 1 hour
|
|
assert block.banned_until != nil
|
|
end
|
|
|
|
test "escalates to permanent ban on third offense" do
|
|
ip = "198.51.100.3"
|
|
{:ok, _} = BruteForce.create_or_escalate_ban(ip)
|
|
{:ok, _} = BruteForce.create_or_escalate_ban(ip)
|
|
{:ok, block} = BruteForce.create_or_escalate_ban(ip)
|
|
|
|
assert block.offense_count == 3
|
|
assert block.banned_until == nil
|
|
end
|
|
|
|
test "resets offense count after forgiveness period" do
|
|
ip = "198.51.100.4"
|
|
|
|
# Create a block with old last_violation_at (> 30 days ago)
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: ip,
|
|
offense_count: 2,
|
|
banned_until: DateTime.add(DateTime.utc_now(), -86400, :second),
|
|
last_violation_at: DateTime.add(DateTime.utc_now(), -31 * 86400, :second)
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
{:ok, block} = BruteForce.create_or_escalate_ban(ip)
|
|
|
|
# Should reset to offense 1 instead of escalating to 3
|
|
assert block.offense_count == 1
|
|
end
|
|
|
|
test "uses custom reason" do
|
|
ip = "198.51.100.5"
|
|
{:ok, block} = BruteForce.create_or_escalate_ban(ip, "Custom reason")
|
|
assert block.reason == "Custom reason"
|
|
end
|
|
end
|
|
|
|
describe "manually_unblock/1" do
|
|
test "unblocks a banned IP" do
|
|
ip = "198.51.100.10"
|
|
{:ok, _} = BruteForce.create_or_escalate_ban(ip)
|
|
assert {:ok, _} = BruteForce.manually_unblock(ip)
|
|
assert :allowed = BruteForce.check_ban_status(ip)
|
|
end
|
|
|
|
test "returns error for unknown IP" do
|
|
assert {:error, :not_found} = BruteForce.manually_unblock("198.51.100.99")
|
|
end
|
|
end
|
|
|
|
describe "list_blocked_ips/1" do
|
|
setup do
|
|
now = DateTime.utc_now()
|
|
|
|
# Temporary ban
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "10.10.10.1",
|
|
offense_count: 1,
|
|
banned_until: DateTime.add(now, 300, :second),
|
|
last_violation_at: now
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
# Permanent ban
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "10.10.10.2",
|
|
offense_count: 3,
|
|
banned_until: nil,
|
|
last_violation_at: now
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
:ok
|
|
end
|
|
|
|
test "lists all blocks" do
|
|
blocks = BruteForce.list_blocked_ips(:all)
|
|
assert length(blocks) == 2
|
|
end
|
|
|
|
test "filters permanent bans" do
|
|
blocks = BruteForce.list_blocked_ips(:permanent)
|
|
assert length(blocks) == 1
|
|
assert hd(blocks).ip_address == "10.10.10.2"
|
|
end
|
|
|
|
test "filters temporary bans" do
|
|
blocks = BruteForce.list_blocked_ips(:temporary)
|
|
assert length(blocks) == 1
|
|
assert hd(blocks).ip_address == "10.10.10.1"
|
|
end
|
|
end
|
|
|
|
describe "check_whitelist/1" do
|
|
setup do
|
|
user = user_fixture()
|
|
|
|
%IpWhitelist{}
|
|
|> IpWhitelist.changeset(%{
|
|
ip_or_cidr: "203.0.113.50",
|
|
description: "Office",
|
|
added_by_id: user.id
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
%IpWhitelist{}
|
|
|> IpWhitelist.changeset(%{
|
|
ip_or_cidr: "10.0.0.0/8",
|
|
description: "Internal network",
|
|
added_by_id: user.id
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
# Clear any cached whitelist from previous tests
|
|
case :ets.whereis(:brute_force_whitelist_cache) do
|
|
:undefined -> :ok
|
|
_ -> :ets.delete_all_objects(:brute_force_whitelist_cache)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
test "returns true for exact IP match" do
|
|
assert BruteForce.check_whitelist("203.0.113.50")
|
|
end
|
|
|
|
test "returns false for non-whitelisted IP" do
|
|
refute BruteForce.check_whitelist("203.0.113.99")
|
|
end
|
|
|
|
test "returns true for IP within CIDR range" do
|
|
assert BruteForce.check_whitelist("10.1.2.3")
|
|
end
|
|
|
|
test "returns false for IP outside CIDR range" do
|
|
refute BruteForce.check_whitelist("11.0.0.1")
|
|
end
|
|
end
|
|
|
|
describe "add_to_whitelist/3" do
|
|
test "adds an IP to the whitelist" do
|
|
user = user_fixture()
|
|
assert {:ok, entry} = BruteForce.add_to_whitelist("1.2.3.4", "Test", user)
|
|
assert entry.ip_or_cidr == "1.2.3.4"
|
|
end
|
|
|
|
test "returns error for invalid IP" do
|
|
user = user_fixture()
|
|
assert {:error, changeset} = BruteForce.add_to_whitelist("not-valid", "Test", user)
|
|
refute changeset.valid?
|
|
end
|
|
end
|
|
|
|
describe "remove_from_whitelist/1" do
|
|
test "removes a whitelist entry" do
|
|
user = user_fixture()
|
|
{:ok, entry} = BruteForce.add_to_whitelist("5.6.7.8", "Test", user)
|
|
assert :ok = BruteForce.remove_from_whitelist(entry.id)
|
|
end
|
|
|
|
test "returns error for non-existent entry" do
|
|
assert {:error, :not_found} = BruteForce.remove_from_whitelist(Ecto.UUID.generate())
|
|
end
|
|
end
|
|
|
|
describe "delete_expired_bans/0" do
|
|
test "deletes expired temporary bans" do
|
|
now = DateTime.utc_now()
|
|
|
|
# Expired ban
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "100.100.100.1",
|
|
offense_count: 1,
|
|
banned_until: DateTime.add(now, -300, :second),
|
|
last_violation_at: DateTime.add(now, -600, :second)
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
# Active ban
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "100.100.100.2",
|
|
offense_count: 1,
|
|
banned_until: DateTime.add(now, 300, :second),
|
|
last_violation_at: now
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
assert :ok = BruteForce.delete_expired_bans()
|
|
|
|
blocks = BruteForce.list_blocked_ips(:all)
|
|
assert length(blocks) == 1
|
|
assert hd(blocks).ip_address == "100.100.100.2"
|
|
end
|
|
end
|
|
|
|
describe "delete_stale_violations/0" do
|
|
test "deletes old non-permanent violations" do
|
|
now = DateTime.utc_now()
|
|
|
|
# Old violation (> 90 days, offense < 3)
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "100.100.200.1",
|
|
offense_count: 1,
|
|
banned_until: DateTime.add(now, -100 * 86400, :second),
|
|
last_violation_at: DateTime.add(now, -100 * 86400, :second)
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
# Recent violation
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(%{
|
|
ip_address: "100.100.200.2",
|
|
offense_count: 1,
|
|
banned_until: DateTime.add(now, 300, :second),
|
|
last_violation_at: now
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
assert :ok = BruteForce.delete_stale_violations()
|
|
|
|
ips = BruteForce.list_blocked_ips(:all) |> Enum.map(& &1.ip_address)
|
|
refute "100.100.200.1" in ips
|
|
assert "100.100.200.2" in ips
|
|
end
|
|
end
|
|
end
|