- Escape HEEx template braces in GraphQL/API docs with raw(~S[...]) - Fix test assertions for updated marketing copy and UI text - Extract helper functions in GraphQL resolvers to reduce nesting depth - Create shared ErrorHelpers module for API controllers - Fix ETS race condition in brute force whitelist cache for async tests - Fix property test generators to use ASCII instead of printable unicode - Add alert_severity helper to site_live/show - Update accounts fixtures for explicit user confirmation
409 lines
10 KiB
Elixir
409 lines
10 KiB
Elixir
defmodule Towerops.Security.BruteForce do
|
|
@moduledoc """
|
|
Context module for brute force protection and IP blocking.
|
|
|
|
Handles detection of scanning behavior (5+ unique 404s within 1 minute),
|
|
progressive ban escalation, whitelist management, and integration with
|
|
Cloudflare WAF for permanent bans.
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
|
|
alias Phoenix.PubSub
|
|
alias Towerops.Repo
|
|
alias Towerops.Security.FourOhFourTracker
|
|
alias Towerops.Security.IpBlock
|
|
alias Towerops.Security.IpWhitelist
|
|
alias Towerops.Workers.CloudflareBanWorker
|
|
|
|
require Logger
|
|
|
|
@forgiveness_period_days 30
|
|
|
|
## Whitelist Functions
|
|
|
|
@doc """
|
|
Checks if an IP address is whitelisted.
|
|
|
|
Supports both individual IPs and CIDR ranges.
|
|
Uses ETS cache for performance.
|
|
"""
|
|
def check_whitelist(ip_address) do
|
|
whitelist = get_cached_whitelist()
|
|
Enum.any?(whitelist, &ip_matches_entry?(ip_address, &1))
|
|
end
|
|
|
|
defp ip_matches_entry?(ip_address, entry) do
|
|
if String.contains?(entry.ip_or_cidr, "/") do
|
|
# CIDR range - need to parse both the range and IP address
|
|
with {:ok, range} <- InetCidr.parse_cidr(entry.ip_or_cidr),
|
|
{:ok, addr} <- :inet.parse_address(String.to_charlist(ip_address)) do
|
|
InetCidr.contains?(range, addr)
|
|
else
|
|
_ -> false
|
|
end
|
|
else
|
|
# Exact IP match
|
|
entry.ip_or_cidr == ip_address
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Lists all whitelisted IPs and CIDR ranges.
|
|
"""
|
|
def list_whitelist do
|
|
Repo.all(from w in IpWhitelist, preload: [:added_by], order_by: [desc: w.inserted_at])
|
|
end
|
|
|
|
@doc """
|
|
Adds an IP address or CIDR range to the whitelist.
|
|
"""
|
|
def add_to_whitelist(ip_or_cidr, description, user) do
|
|
attrs = %{
|
|
ip_or_cidr: ip_or_cidr,
|
|
description: description,
|
|
added_by_id: user.id
|
|
}
|
|
|
|
result =
|
|
%IpWhitelist{}
|
|
|> IpWhitelist.changeset(attrs)
|
|
|> Repo.insert()
|
|
|
|
case result do
|
|
{:ok, _} ->
|
|
clear_whitelist_cache()
|
|
PubSub.broadcast(Towerops.PubSub, "security:whitelist", :whitelist_updated)
|
|
|
|
{:error, _} ->
|
|
:ok
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
@doc """
|
|
Removes an IP or CIDR range from the whitelist.
|
|
"""
|
|
def remove_from_whitelist(id) do
|
|
case Repo.get(IpWhitelist, id) do
|
|
nil ->
|
|
{:error, :not_found}
|
|
|
|
entry ->
|
|
Repo.delete(entry)
|
|
clear_whitelist_cache()
|
|
PubSub.broadcast(Towerops.PubSub, "security:whitelist", :whitelist_updated)
|
|
:ok
|
|
end
|
|
end
|
|
|
|
## Ban Management Functions
|
|
|
|
@doc """
|
|
Checks if an IP address is currently banned.
|
|
|
|
Returns `:allowed` if not banned or ban expired.
|
|
Returns `{:blocked, banned_until}` if currently banned.
|
|
"""
|
|
def check_ban_status(ip_address) do
|
|
case get_by_ip(ip_address) do
|
|
nil ->
|
|
:allowed
|
|
|
|
%IpBlock{banned_until: nil, offense_count: count} when count >= 3 ->
|
|
# Permanent ban
|
|
{:blocked, nil}
|
|
|
|
%IpBlock{banned_until: banned_until} ->
|
|
if DateTime.after?(DateTime.utc_now(), banned_until) do
|
|
# Ban expired
|
|
:allowed
|
|
else
|
|
{:blocked, banned_until}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Creates a new ban or escalates an existing ban.
|
|
|
|
Escalation schedule:
|
|
- 1st offense: 5 minutes
|
|
- 2nd offense (within 30 days): 1 hour
|
|
- 3rd+ offense (within 30 days): Permanent + Cloudflare push
|
|
|
|
Resets to 1st offense if 30 days have passed since last violation.
|
|
"""
|
|
def create_or_escalate_ban(ip_address, reason \\ "Automated scanning - 5 unique 404s") do
|
|
case get_by_ip(ip_address) do
|
|
nil ->
|
|
# First offense
|
|
create_ban(ip_address, 1, DateTime.add(DateTime.utc_now(), 5, :minute), reason)
|
|
|
|
block ->
|
|
if DateTime.diff(DateTime.utc_now(), block.last_violation_at, :day) > @forgiveness_period_days do
|
|
# Reset after forgiveness period
|
|
update_ban(block, 1, DateTime.add(DateTime.utc_now(), 5, :minute), reason)
|
|
else
|
|
# Escalate
|
|
escalate_ban(block, reason)
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Manually unblocks an IP address.
|
|
|
|
Returns `{:ok, ip_block}` if successful.
|
|
Returns `{:error, :not_found}` if IP is not blocked.
|
|
"""
|
|
def manually_unblock(ip_address) do
|
|
case get_by_ip(ip_address) do
|
|
nil ->
|
|
{:error, :not_found}
|
|
|
|
block ->
|
|
# Delete the block record entirely
|
|
Repo.delete(block)
|
|
Logger.info("Manually unblocked IP: #{ip_address}")
|
|
PubSub.broadcast(Towerops.PubSub, "security:blocks", :blocks_updated)
|
|
{:ok, block}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Lists blocked IPs with optional filtering.
|
|
|
|
Filter options: `:all`, `:permanent`, `:temporary`
|
|
"""
|
|
def list_blocked_ips(filter \\ :all) do
|
|
query =
|
|
case filter do
|
|
:permanent ->
|
|
from b in IpBlock,
|
|
where: is_nil(b.banned_until) and b.offense_count >= 3,
|
|
order_by: [desc: b.last_violation_at]
|
|
|
|
:temporary ->
|
|
from b in IpBlock,
|
|
where: not is_nil(b.banned_until),
|
|
order_by: [desc: b.last_violation_at]
|
|
|
|
:all ->
|
|
from b in IpBlock, order_by: [desc: b.last_violation_at]
|
|
end
|
|
|
|
Repo.all(query)
|
|
end
|
|
|
|
## 404 Tracking Functions
|
|
|
|
@doc """
|
|
Records a 404 path for an IP address.
|
|
|
|
Tracks unique paths in Redis with 60-second TTL.
|
|
Triggers ban creation/escalation if threshold (5) is reached.
|
|
"""
|
|
def record_404_path(ip_address, path) do
|
|
FourOhFourTracker.record_404(ip_address, path)
|
|
end
|
|
|
|
@doc """
|
|
Gets the count of unique 404 paths for an IP address within the tracking window.
|
|
"""
|
|
def get_unique_404_count(ip_address) do
|
|
FourOhFourTracker.get_count(ip_address)
|
|
end
|
|
|
|
## Cleanup Functions
|
|
|
|
@doc """
|
|
Deletes expired temporary bans.
|
|
|
|
Called by ExpiredBanCleanupWorker hourly.
|
|
"""
|
|
def delete_expired_bans do
|
|
{count, _} =
|
|
Repo.delete_all(
|
|
from(b in IpBlock,
|
|
where: not is_nil(b.banned_until),
|
|
where: b.banned_until < ^DateTime.utc_now()
|
|
)
|
|
)
|
|
|
|
if count > 0 do
|
|
Logger.info("Deleted #{count} expired ban(s)")
|
|
PubSub.broadcast(Towerops.PubSub, "security:blocks", :blocks_updated)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
@doc """
|
|
Deletes old violation records that are not permanent bans.
|
|
|
|
Removes records older than 90 days with offense_count < 3.
|
|
Called by StaleViolationCleanupWorker daily.
|
|
"""
|
|
def delete_stale_violations do
|
|
cutoff = DateTime.add(DateTime.utc_now(), -90, :day)
|
|
|
|
{count, _} = Repo.delete_all(from(b in IpBlock, where: b.last_violation_at < ^cutoff, where: b.offense_count < 3))
|
|
|
|
if count > 0 do
|
|
Logger.info("Deleted #{count} stale violation record(s)")
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
## Private Functions
|
|
|
|
defp get_by_ip(ip_address) do
|
|
Repo.get_by(IpBlock, ip_address: ip_address)
|
|
end
|
|
|
|
defp create_ban(ip_address, offense_count, banned_until, reason) do
|
|
attrs = %{
|
|
ip_address: ip_address,
|
|
offense_count: offense_count,
|
|
banned_until: banned_until,
|
|
last_violation_at: DateTime.utc_now(),
|
|
reason: reason
|
|
}
|
|
|
|
result =
|
|
%IpBlock{}
|
|
|> IpBlock.changeset(attrs)
|
|
|> Repo.insert()
|
|
|
|
case result do
|
|
{:ok, block} ->
|
|
Logger.warning("IP banned (offense #{offense_count}): #{ip_address} until #{banned_until}")
|
|
PubSub.broadcast(Towerops.PubSub, "security:blocks", :blocks_updated)
|
|
{:ok, block}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
defp update_ban(block, offense_count, banned_until, reason) do
|
|
attrs = %{
|
|
offense_count: offense_count,
|
|
banned_until: banned_until,
|
|
last_violation_at: DateTime.utc_now(),
|
|
reason: reason
|
|
}
|
|
|
|
result =
|
|
block
|
|
|> IpBlock.changeset(attrs)
|
|
|> Repo.update()
|
|
|
|
case result do
|
|
{:ok, updated} ->
|
|
Logger.warning("IP ban reset (offense #{offense_count}): #{block.ip_address} until #{banned_until}")
|
|
PubSub.broadcast(Towerops.PubSub, "security:blocks", :blocks_updated)
|
|
{:ok, updated}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
defp escalate_ban(block, reason) do
|
|
new_count = block.offense_count + 1
|
|
|
|
{banned_until, log_msg} =
|
|
case new_count do
|
|
2 ->
|
|
until = DateTime.add(DateTime.utc_now(), 1, :hour)
|
|
{"#{until}", "1 hour"}
|
|
|
|
_ ->
|
|
# Permanent ban
|
|
{nil, "permanent"}
|
|
end
|
|
|
|
attrs = %{
|
|
offense_count: new_count,
|
|
banned_until: banned_until,
|
|
last_violation_at: DateTime.utc_now(),
|
|
reason: reason
|
|
}
|
|
|
|
result =
|
|
block
|
|
|> IpBlock.changeset(attrs)
|
|
|> Repo.update()
|
|
|
|
case result do
|
|
{:ok, updated} ->
|
|
Logger.warning("IP ban escalated to #{log_msg} (offense #{new_count}): #{block.ip_address}")
|
|
PubSub.broadcast(Towerops.PubSub, "security:blocks", :blocks_updated)
|
|
|
|
# Push to Cloudflare for permanent bans
|
|
if new_count >= 3 do
|
|
CloudflareBanWorker.enqueue(block.ip_address)
|
|
end
|
|
|
|
{:ok, updated}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
# Whitelist caching using ETS
|
|
@whitelist_cache_table :brute_force_whitelist_cache
|
|
|
|
defp get_cached_whitelist do
|
|
case :ets.whereis(@whitelist_cache_table) do
|
|
:undefined ->
|
|
create_cache_table()
|
|
load_whitelist()
|
|
|
|
_ ->
|
|
try do
|
|
case :ets.lookup(@whitelist_cache_table, :whitelist) do
|
|
[{:whitelist, entries}] -> entries
|
|
[] -> load_whitelist()
|
|
end
|
|
rescue
|
|
ArgumentError ->
|
|
create_cache_table()
|
|
load_whitelist()
|
|
end
|
|
end
|
|
end
|
|
|
|
defp create_cache_table do
|
|
:ets.new(@whitelist_cache_table, [:set, :public, :named_table, read_concurrency: true])
|
|
rescue
|
|
ArgumentError ->
|
|
:ets.whereis(@whitelist_cache_table)
|
|
end
|
|
|
|
defp load_whitelist do
|
|
entries = Repo.all(IpWhitelist)
|
|
|
|
try do
|
|
:ets.insert(@whitelist_cache_table, {:whitelist, entries})
|
|
rescue
|
|
ArgumentError -> :ok
|
|
end
|
|
|
|
entries
|
|
end
|
|
|
|
defp clear_whitelist_cache do
|
|
case :ets.whereis(@whitelist_cache_table) do
|
|
:undefined -> :ok
|
|
_ -> :ets.delete(@whitelist_cache_table, :whitelist)
|
|
end
|
|
rescue
|
|
ArgumentError -> :ok
|
|
end
|
|
end
|