towerops/lib/towerops_web/controllers/api/v1/geoip_controller.ex
Graham McIntire 6ef6b3d61d fix: comprehensive security audit fixes (#108)
This commit addresses multiple CRITICAL, HIGH, and MEDIUM severity security vulnerabilities identified in the security audit:

CRITICAL FIXES:
- Fix weak RNG for recovery codes - replaced Enum.random() with :crypto.strong_rand_bytes/1 for cryptographically secure token generation
- Fix subscription limit race conditions - moved free org and device quota checks inside transactions with FOR UPDATE locks to prevent concurrent bypass
- Fix default organization race condition - moved is_default check inside transaction to prevent multiple defaults per user

HIGH SEVERITY FIXES:
- Fix agent token deletion race condition - moved PubSub broadcast inside transaction to ensure agents only receive notification after successful deletion

MEDIUM SEVERITY FIXES:
- Fix LIKE wildcard injection in search - applied sanitize_like() to all user-facing search queries in devices.ex, sites.ex, and gaiia.ex to prevent enumeration attacks
- Fix Jason.decode! DoS - replaced with safe Jason.decode/1 with error handling in device_live/index.ex
- Fix SSRF vulnerability - added URL validation in HTTP executor to block requests to private/internal IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16)
- Fix error information leakage - replaced inspect() in API responses with generic error messages, logging details server-side only
- Fix atom table pollution - HTTP method normalization now uses whitelist mapping instead of String.to_atom()

SECURITY IMPROVEMENTS:
- All quota checks now use pessimistic locking (SELECT FOR UPDATE) to prevent TOCTOU race conditions
- Private IP validation prevents cloud metadata service access (169.254.169.254)
- DNS resolution performed before HTTP requests to detect IP spoofing
- Error details logged server-side but not exposed to clients

Files changed:
- lib/towerops/accounts/user_recovery_code.ex
- lib/towerops/organizations.ex
- lib/towerops/devices.ex
- lib/towerops/sites.ex
- lib/towerops/gaiia.ex
- lib/towerops/agents.ex
- lib/towerops/monitoring/executors/http_executor.ex
- lib/towerops_web/live/device_live/index.ex
- lib/towerops_web/controllers/api/v1/mib_controller.ex
- lib/towerops_web/controllers/api/v1/agent_release_webhook_controller.ex
- lib/towerops_web/controllers/api/v1/geoip_controller.ex

Reviewed-on: graham/towerops-web#108
2026-03-22 10:10:27 -05:00

201 lines
5.3 KiB
Elixir

defmodule ToweropsWeb.Api.V1.GeoipController do
@moduledoc """
API controller for managing GeoIP database imports.
Allows superusers to trigger GeoIP database imports from MaxMind CSV files.
All endpoints require superuser API token authentication.
"""
use ToweropsWeb, :controller
require Logger
@doc """
Import GeoIP database batch from processed CSV data.
Requires superuser API token.
## Request
POST /admin/api/geoip/import
Content-Type: application/json
{
"batch_type": "locations", // or "blocks"
"data": [...], // Array of location or block records
"truncate": true // Optional: truncate table before inserting (first batch only)
}
## Response
200 OK
{
"status": "ok",
"inserted": 5000
}
"""
def import_database(conn, %{"batch_type" => batch_type, "data" => data} = params) do
with :ok <- require_superuser(conn) do
truncate = Map.get(params, "truncate", false)
Logger.info(
"GeoIP import batch requested by #{conn.assigns[:current_user].email}: #{batch_type}, #{length(data)} records, truncate=#{truncate}"
)
case import_batch(batch_type, data, truncate) do
{:ok, inserted_count} ->
conn
|> put_status(:ok)
|> json(%{
status: "ok",
inserted: inserted_count
})
{:error, reason} when is_binary(reason) ->
# Validation error - safe to return to user
Logger.warning("GeoIP batch import validation error: #{reason}")
conn
|> put_status(:internal_server_error)
|> json(%{error: reason})
{:error, reason} ->
# Internal error - log details but return generic message
Logger.error("GeoIP batch import failed: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Import failed"})
end
end
end
def import_database(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{
error: "Missing required parameters: batch_type and data"
})
end
defp require_superuser(conn) do
user = conn.assigns[:current_user]
if !user || !user.is_superuser do
Logger.warning("GeoIP import attempted by non-superuser: #{inspect(user && user.email)}")
conn
|> put_status(:forbidden)
|> json(%{
error: "Superuser access required. Only API tokens created by superusers can import GeoIP data."
})
|> halt()
else
:ok
end
end
defp import_batch("locations", data, truncate) do
alias Towerops.GeoIP.Block
alias Towerops.GeoIP.Location
alias Towerops.Repo
if truncate do
# Must delete blocks first due to foreign key constraint
Repo.delete_all(Block)
Repo.delete_all(Location)
end
now = DateTime.truncate(DateTime.utc_now(), :second)
entries =
Enum.map(data, fn location ->
%{
geoname_id: location["geoname_id"],
country_code: location["country_code"],
country_name: location["country_name"],
city_name: location["city_name"],
subdivision_1_name: location["subdivision_1_name"],
subdivision_2_name: location["subdivision_2_name"],
latitude: location["latitude"],
longitude: location["longitude"],
inserted_at: now,
updated_at: now
}
end)
# Use upsert to avoid duplicates - update specific fields on conflict
# Don't use replace_all_except because it triggers DELETE+INSERT which violates foreign keys
{inserted, _} =
Repo.insert_all(Location, entries,
on_conflict:
{:replace,
[
:country_code,
:country_name,
:city_name,
:subdivision_1_name,
:subdivision_2_name,
:latitude,
:longitude,
:updated_at
]},
conflict_target: :geoname_id
)
{:ok, inserted}
rescue
e ->
{:error, Exception.message(e)}
end
defp import_batch("blocks", data, truncate) do
alias Towerops.GeoIP.Block
alias Towerops.Repo
if truncate do
Repo.delete_all(Block)
end
now = DateTime.truncate(DateTime.utc_now(), :second)
entries =
Enum.map(data, fn block ->
%{
id: Ecto.UUID.generate(),
network: block["network"],
start_ip_int: block["start_ip_int"],
end_ip_int: block["end_ip_int"],
geoname_id: block["geoname_id"],
registered_country_geoname_id: block["registered_country_geoname_id"],
inserted_at: now,
updated_at: now
}
end)
# Use upsert to avoid duplicates - update specific fields on conflict
{inserted, _} =
Repo.insert_all(Block, entries,
on_conflict:
{:replace,
[
:start_ip_int,
:end_ip_int,
:geoname_id,
:registered_country_geoname_id,
:updated_at
]},
conflict_target: :network
)
{:ok, inserted}
rescue
e ->
{:error, Exception.message(e)}
end
defp import_batch(_type, _data, _truncate) do
{:error, "Invalid batch_type. Must be 'locations' or 'blocks'"}
end
end