- 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
187 lines
5.7 KiB
Elixir
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
|