towerops/lib/towerops_web/controllers/user_sudo_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

126 lines
4 KiB
Elixir

defmodule ToweropsWeb.UserSudoController do
@moduledoc """
Controller for sudo mode verification.
Handles re-authentication for sensitive operations by requiring TOTP verification
(no recovery codes allowed).
"""
use ToweropsWeb, :controller
import ToweropsWeb.UserAuth, only: [require_authenticated_user: 2]
alias Towerops.Accounts
plug :require_authenticated_user
def new(conn, _params) do
user = conn.assigns.current_scope.user
cond do
# Already in sudo mode (verified within last 10 minutes), redirect to destination
recently_verified_sudo?(user) ->
return_to = get_session(conn, :user_return_to) || ~p"/users/settings"
conn
|> delete_session(:user_return_to)
|> redirect(to: return_to)
# No TOTP devices, must enroll first
!Accounts.totp_enabled?(user) ->
conn
|> put_flash(:error, t("Two-factor authentication is required for this action."))
|> redirect(to: ~p"/account/totp-enrollment")
# Show verification form
true ->
form = Phoenix.Component.to_form(%{}, as: "user")
render(conn, :verify, form: form)
end
end
# Check if user verified sudo mode within the last 10 minutes
# Uses last_sudo_at timestamp instead of authenticated_at to distinguish
# between regular login and explicit sudo verification
defp recently_verified_sudo?(%{last_sudo_at: nil}), do: false
defp recently_verified_sudo?(%{last_sudo_at: last_sudo_at}) do
ten_minutes_ago = DateTime.add(DateTime.utc_now(), -10, :minute)
DateTime.after?(last_sudo_at, ten_minutes_ago)
end
def verify(conn, %{"user" => %{"totp_code" => totp_code}}) do
user = conn.assigns.current_scope.user
case Accounts.verify_totp_only(user, totp_code) do
{:ok, _verified_user} ->
# Grant sudo mode (updates last_sudo_at in database)
case Accounts.grant_sudo_mode(user) do
{:ok, _updated_user} ->
# Redirect to return_to path or default
return_to = get_session(conn, :user_return_to) || ~p"/users/settings"
conn
|> put_flash(:info, t("Identity verified."))
|> delete_session(:user_return_to)
|> redirect(to: return_to)
{:error, _changeset} ->
# Shouldn't happen, but handle gracefully
form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
conn
|> put_flash(:error, t("Failed to grant sudo mode. Please try again."))
|> render(:verify, form: form)
end
{:error, :recovery_code_not_allowed} ->
# Log failed sudo attempt (recovery code used)
_ = log_failed_sudo_attempt(conn, user, "recovery_code_not_allowed")
form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
conn
|> put_flash(
:error,
"Recovery codes are not allowed for sudo mode verification. Please use your authenticator app."
)
|> render(:verify, form: form)
{:error, :invalid_code} ->
# Log failed sudo attempt (invalid code)
_ = log_failed_sudo_attempt(conn, user, "invalid_code")
form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
conn
|> put_flash(:error, t("Invalid authentication code. Please try again."))
|> render(:verify, form: form)
end
end
def verify(conn, _params) do
# Handle missing totp_code parameter
form = Phoenix.Component.to_form(%{}, as: "user")
render(conn, :verify, form: form)
end
defp log_failed_sudo_attempt(conn, user, reason) do
Accounts.record_login_attempt(%{
email: user.email,
success: false,
failure_reason: "sudo_verification_failed",
method: "sudo_totp",
ip_address: ToweropsWeb.RemoteIp.from_conn(conn),
user_agent: extract_user_agent(conn),
metadata: %{sudo_failure_reason: reason}
})
end
defp extract_user_agent(conn) do
case Plug.Conn.get_req_header(conn, "user-agent") do
[ua | _] -> ua
[] -> nil
end
end
end