Three changes targeting the root cause of production up/down alerting: 1. Skip BruteForceProtection for /health and /health/live paths (removes unnecessary Repo.get_by(IpBlock) DB query from every k8s probe, matching the existing /socket/agent exemption). 2. Relax k8s readiness probe: periodSeconds 5→10, timeoutSeconds 2→5, failureThreshold 2→3, successThreshold 3→2 so transient DB/Redis blips don't cascade into full service unavailability. 3. Use the persistent Towerops.Redix connection for health checks instead of opening a brand-new TCP connection (handshake→AUTH→ PING→close) on every probe. Towerops.Redix.Fake gains a set_ping_response/2 toggle for testing failure paths.
98 lines
2.9 KiB
Elixir
98 lines
2.9 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
|
|
|
|
# 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
|