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 # Skip for Kubernetes health probes — these run every 5s and don't # need brute-force checking; a DB query here adds latency to readiness # checks and contributes to probe failures during DB contention. String.starts_with?(conn.request_path, "/health") -> 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