towerops/lib/towerops/security/cloudflare_client.ex
Graham McIntire 4d73e77f3a
Add 404-based brute force protection system
Implements automated detection and blocking of IPs exhibiting scanning behavior (5+ unique 404s within 1 minute).

Key features:
- Progressive ban escalation (5 min → 1 hour → permanent)
- CIDR range and exact IP whitelisting
- Redis-backed 404 path tracking with 60s TTL
- Cloudflare WAF integration for permanent bans
- Admin UI for whitelist and blocked IP management
- Oban cron jobs for cleanup (expired bans, stale violations)
- ETS caching for whitelist performance

Components:
- Plug: ToweropsWeb.Plugs.BruteForceProtection
- Context: Towerops.Security.BruteForce
- Schemas: IpBlock, IpWhitelist
- Workers: CloudflareBanWorker, ExpiredBanCleanupWorker, StaleViolationCleanupWorker
- Admin UI: /admin/security (superuser only)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 16:31:48 -06:00

133 lines
4.1 KiB
Elixir

defmodule Towerops.Security.CloudflareClient do
@moduledoc """
Client for interacting with Cloudflare's API to manage IP Access Rules (WAF).
Used to push permanent IP bans to Cloudflare's edge network for
pre-application blocking of brute force scanners.
"""
require Logger
@base_url "https://api.cloudflare.com/client/v4"
@doc """
Blocks an IP address at the Cloudflare edge using WAF IP Access Rules.
Returns `{:ok, response}` on success.
Returns `{:error, reason}` on failure.
"""
def block_ip(ip_address) do
with {:ok, zone_id} <- get_config(:cloudflare_zone_id),
{:ok, api_token} <- get_config(:cloudflare_api_token) do
body = %{
mode: "block",
configuration: %{
target: "ip",
value: ip_address
},
notes: "Towerops brute force protection - permanent ban"
}
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules"
case Req.post(url,
json: body,
headers: [
{"authorization", "Bearer #{api_token}"},
{"content-type", "application/json"}
]
) do
{:ok, %{status: status, body: response}} when status in 200..299 ->
Logger.info("Successfully blocked IP at Cloudflare: #{ip_address}")
{:ok, response}
{:ok, %{status: status, body: body}} ->
Logger.error("Cloudflare API error (#{status}): #{inspect(body)}")
{:error, "API returned status #{status}"}
{:error, reason} ->
Logger.error("Failed to call Cloudflare API: #{inspect(reason)}")
{:error, reason}
end
else
{:error, :missing_config} ->
Logger.warning("Cloudflare credentials not configured, skipping edge block for #{ip_address}")
{:error, :not_configured}
end
end
@doc """
Unblocks an IP address by finding and deleting the corresponding WAF rule.
Note: This requires listing all rules to find the matching one, which may be slow.
"""
def unblock_ip(ip_address) do
with {:ok, zone_id} <- get_config(:cloudflare_zone_id),
{:ok, api_token} <- get_config(:cloudflare_api_token),
{:ok, rule_id} <- find_rule_by_ip(zone_id, api_token, ip_address) do
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules/#{rule_id}"
case Req.delete(url,
headers: [
{"authorization", "Bearer #{api_token}"}
]
) do
{:ok, %{status: status}} when status in 200..299 ->
Logger.info("Successfully unblocked IP at Cloudflare: #{ip_address}")
{:ok, :deleted}
{:ok, %{status: status, body: body}} ->
Logger.error("Cloudflare API error (#{status}): #{inspect(body)}")
{:error, "API returned status #{status}"}
{:error, reason} ->
Logger.error("Failed to call Cloudflare API: #{inspect(reason)}")
{:error, reason}
end
else
{:error, :not_found} ->
Logger.warning("No Cloudflare rule found for IP: #{ip_address}")
{:error, :not_found}
{:error, :missing_config} ->
{:error, :not_configured}
error ->
error
end
end
defp find_rule_by_ip(zone_id, api_token, ip_address) do
url = "#{@base_url}/zones/#{zone_id}/firewall/access_rules/rules"
case Req.get(url,
headers: [
{"authorization", "Bearer #{api_token}"}
],
params: [
{"configuration.value", ip_address},
{"mode", "block"}
]
) do
{:ok, %{status: 200, body: %{"result" => [rule | _]}}} ->
{:ok, rule["id"]}
{:ok, %{status: 200, body: %{"result" => []}}} ->
{:error, :not_found}
{:ok, %{status: status, body: body}} ->
Logger.error("Cloudflare API error (#{status}): #{inspect(body)}")
{:error, "API returned status #{status}"}
{:error, reason} ->
{:error, reason}
end
end
defp get_config(key) do
case Application.get_env(:towerops, key) do
nil -> {:error, :missing_config}
value -> {:ok, value}
end
end
end