towerops/lib/towerops_web/plugs/brute_force_protection.ex
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

92 lines
2.6 KiB
Elixir

defmodule ToweropsWeb.Plugs.BruteForceProtection do
@moduledoc """
Plug that detects and blocks brute force scanning behavior.
## Detection Strategy
Tracks unique 404 paths per IP address using Redis. When an unauthenticated
IP hits 5+ unique 404s within 60 seconds, a ban is created/escalated.
## Ban Escalation
- 1st offense: 5-minute ban
- 2nd offense (within 30 days): 1-hour ban
- 3rd+ offense (within 30 days): Permanent ban + Cloudflare WAF push
## Exemptions
- Authenticated users (checked in before_send callback, since auth runs after this plug)
- Agent WebSocket connections (/socket/agent - authenticated at channel join)
- Whitelisted IPs (checked via BruteForce context)
## Placement
Must be placed in the endpoint.ex pipeline AFTER:
- RemoteIpLogger (needs remote_ip in Logger metadata)
- Static file serving (no need to track static assets)
Must be placed BEFORE:
- Router (needs to intercept requests before routing)
"""
import Plug.Conn
alias Towerops.Security.BruteForce
alias Towerops.Security.FourOhFourTracker
alias ToweropsWeb.RemoteIp
require Logger
def init(opts), do: opts
def call(conn, _opts) do
ip_address = RemoteIp.from_conn(conn)
cond do
# Skip for agent WebSocket connections (authenticated at channel join)
String.starts_with?(conn.request_path, "/socket/agent") ->
conn
# Check if IP is whitelisted
BruteForce.check_whitelist(ip_address) ->
conn
# Check if IP is currently banned
true ->
case BruteForce.check_ban_status(ip_address) do
:allowed ->
register_404_tracker(conn, ip_address)
{:blocked, banned_until} ->
handle_banned_request(conn, ip_address, banned_until)
end
end
end
defp register_404_tracker(conn, ip_address) do
register_before_send(conn, fn response_conn ->
# Skip tracking for authenticated users (auth runs after this plug,
# but before_send runs after the full pipeline including auth).
if !response_conn.assigns[:current_user] do
track_404_if_needed(response_conn, ip_address, conn.request_path)
end
response_conn
end)
end
defp track_404_if_needed(response_conn, ip_address, request_path) do
if response_conn.status == 404 do
FourOhFourTracker.record_404(ip_address, request_path)
end
end
defp handle_banned_request(conn, ip_address, banned_until) do
Logger.warning("Blocked request from banned IP: #{ip_address}, banned until: #{inspect(banned_until)}")
conn
|> put_resp_content_type("text/plain")
|> send_resp(403, "Access denied")
|> halt()
end
end