towerops/test/towerops_web/plugs/brute_force_protection_test.exs
Graham McIntire ca7bb75472 fix: 11 critical security/correctness bugs from code audit
- C1: Replace innerHTML template literals with DOM textContent in sites_map.ts
- C2: Derive user_id from current_scope instead of client params in policy consent
- C3: Fix broken halt() in GDPR data export (orphaned _ = ... halt())
- C4: Add owner/admin authorization check to MembersController update/delete
- C5: Require HMAC signature on agent release webhook (was optional)
- C6: Fix Repo.all_by/2 (not a real Ecto function) → Repo.all with query
- C7: Move auth exemption to before_send callback in BruteForceProtection
- C8: Block </style>/<script injection in status page custom_css validation
- C9: Create secrets.example.yaml with placeholders; secrets.yaml already gitignored
- C10: Load Stripe key from STRIPE_SECRET_KEY env var instead of hardcoded value
- C13: Remove credentials from PubSub backup broadcast; channel resolves them

C11 (no force_ssl): by design — Cloudflared terminates TLS
C12 (device quota race): false positive — FOR UPDATE serializes correctly
2026-05-12 10:20:52 -05:00

187 lines
5.7 KiB
Elixir

defmodule ToweropsWeb.Plugs.BruteForceProtectionTest do
use Towerops.DataCase, async: false
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
@tag :skip
test "blocks requests from banned IPs with 403 and retry-after header" do
# SKIP: Brute force protection is currently disabled (handled by Cloudflare at edge)
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([])
# When enabled, should halt and return 403
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 "call/2 skip branches" do
test "passes through immediately for authenticated users" do
user = Towerops.AccountsFixtures.user_fixture()
conn =
build_conn()
|> assign(:current_user, user)
|> BruteForceProtection.call([])
refute conn.halted
# before_send callback is registered but will skip tracking for
# authenticated users (auth check moved into the callback since
# this plug runs before authentication).
assert is_list(conn.private[:before_send])
end
test "passes through immediately for agent socket paths" do
conn =
:get
|> Plug.Test.conn("/socket/agent/websocket")
|> BruteForceProtection.call([])
refute conn.halted
assert conn.private[:before_send] in [nil, []]
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