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.
157 lines
4.7 KiB
Elixir
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
|