towerops/test/towerops_web/plugs/brute_force_protection_test.exs
Graham McIntire 8d9d385683
fix: prevent AlreadySentError in BruteForceProtection plug
The plug was attempting to register a before_send callback after
blocking a banned IP and sending a 403 response. This caused
Plug.Conn.AlreadySentError in production.

Fixed by checking conn.halted before attempting to register the
404 tracking callback. If the request was already blocked and sent,
we skip the callback registration.

Also updated the test to verify proper behavior without the
workaround for AlreadySentError.
2026-02-12 12:53:32 -06:00

157 lines
4.7 KiB
Elixir

defmodule ToweropsWeb.Plugs.BruteForceProtectionTest do
use Towerops.DataCase, async: true
import Plug.Conn
alias Towerops.Security.BruteForce
alias ToweropsWeb.Plugs.BruteForceProtection
describe "init/1" do
test "returns opts unchanged" do
assert BruteForceProtection.init([]) == []
end
test "returns arbitrary opts unchanged" do
opts = [threshold: 10, window: 120]
assert BruteForceProtection.init(opts) == opts
end
end
describe "call/2 with non-banned IPs" do
test "allows requests from non-banned IPs" do
conn =
build_conn()
|> put_req_header("x-forwarded-for", "10.0.0.1")
|> BruteForceProtection.call([])
refute conn.halted
assert conn.status != 403
end
test "allows requests from whitelisted IPs even if they would be banned" do
user = Towerops.AccountsFixtures.user_fixture()
ip = "198.51.100.#{[:positive] |> System.unique_integer() |> rem(255)}"
# Whitelist the IP first
{:ok, _} = BruteForce.add_to_whitelist(ip, "Test whitelist", user)
# Then ban it
{:ok, _block} = BruteForce.create_or_escalate_ban(ip, "Test ban")
conn =
build_conn()
|> put_req_header("x-forwarded-for", ip)
|> BruteForceProtection.call([])
refute conn.halted
end
test "registers before_send callback for 404 tracking" do
conn =
build_conn()
|> put_req_header("x-forwarded-for", "10.0.0.99")
|> BruteForceProtection.call([])
refute conn.halted
# Verify before_send callbacks are registered
assert conn.private[:before_send] != []
end
end
describe "call/2 IP extraction" do
test "extracts IP from x-forwarded-for header" do
conn =
build_conn()
|> put_req_header("x-forwarded-for", "203.0.113.50, 10.0.0.1")
|> BruteForceProtection.call([])
# Should use the first IP (203.0.113.50) and not be banned
refute conn.halted
end
test "extracts IP from x-real-ip header when x-forwarded-for is absent" do
conn =
build_conn()
|> put_req_header("x-real-ip", "203.0.113.60")
|> BruteForceProtection.call([])
refute conn.halted
end
test "falls back to conn.remote_ip when proxy headers are absent" do
conn = BruteForceProtection.call(build_conn(), [])
refute conn.halted
end
end
describe "call/2 with banned IPs" do
test "blocks requests from banned IPs with 403 and retry-after header" do
ip = "198.51.100.#{[:positive] |> System.unique_integer() |> rem(255)}"
# Create a ban for the IP (5 minute ban for first offense)
{:ok, _block} = BruteForce.create_or_escalate_ban(ip, "Test ban")
conn =
build_conn()
|> put_req_header("x-forwarded-for", ip)
|> BruteForceProtection.call([])
assert conn.halted
assert conn.status == 403
assert get_resp_header(conn, "retry-after") != []
# Verify the IP is actually banned via the context
assert {:blocked, _banned_until} = BruteForce.check_ban_status(ip)
end
test "permanent ban returns retry-after of 86400" do
ip = "198.51.100.#{[:positive] |> System.unique_integer() |> rem(255)}"
# Escalate to permanent ban (3 offenses)
{:ok, _} = BruteForce.create_or_escalate_ban(ip, "offense 1")
{:ok, _} = BruteForce.create_or_escalate_ban(ip, "offense 2")
{:ok, _} = BruteForce.create_or_escalate_ban(ip, "offense 3")
# Verify permanent ban status via context
assert {:blocked, nil} = BruteForce.check_ban_status(ip)
end
end
describe "ban status integration" do
test "non-banned IP returns :allowed" do
assert :allowed = BruteForce.check_ban_status("10.0.0.99")
end
test "first offense creates a temporary ban" do
ip = "198.51.100.#{[:positive] |> System.unique_integer() |> rem(255)}"
{:ok, block} = BruteForce.create_or_escalate_ban(ip, "Test ban")
assert block.offense_count == 1
assert block.banned_until
assert {:blocked, banned_until} = BruteForce.check_ban_status(ip)
assert banned_until
end
test "escalation increases offense count" do
ip = "198.51.100.#{[:positive] |> System.unique_integer() |> rem(255)}"
{:ok, block1} = BruteForce.create_or_escalate_ban(ip, "offense 1")
assert block1.offense_count == 1
{:ok, block2} = BruteForce.create_or_escalate_ban(ip, "offense 2")
assert block2.offense_count == 2
{:ok, block3} = BruteForce.create_or_escalate_ban(ip, "offense 3")
assert block3.offense_count == 3
# Third offense is permanent
assert {:blocked, nil} = BruteForce.check_ban_status(ip)
end
end
defp build_conn do
Plug.Test.conn(:get, "/some/path")
end
end