diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3498c83d..173daa95 120000 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1 +1 @@ -/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json \ No newline at end of file +/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json \ No newline at end of file diff --git a/lib/towerops/url_validator.ex b/lib/towerops/url_validator.ex index bec70017..1f5214e3 100644 --- a/lib/towerops/url_validator.ex +++ b/lib/towerops/url_validator.ex @@ -1,12 +1,12 @@ defmodule Towerops.URLValidator do - import Bitwise - @moduledoc """ Validates URLs to prevent SSRF (Server-Side Request Forgery) attacks. Blocks requests to internal/private IP ranges, localhost, and non-HTTP(S) schemes. """ + import Bitwise + @private_ranges [ # 127.0.0.0/8 {127, 0, 0, 0, 8}, @@ -31,9 +31,8 @@ defmodule Towerops.URLValidator do def validate(url) when is_binary(url) do with {:ok, uri} <- parse_url(url), :ok <- validate_scheme(uri), - :ok <- validate_host(uri), - :ok <- validate_ip(uri.host) do - :ok + :ok <- validate_host(uri) do + validate_ip(uri.host) end end @@ -77,17 +76,12 @@ defmodule Towerops.URLValidator do end defp resolve_host(host) do - case :inet.parse_address(String.to_charlist(host)) do - {:ok, ip} -> {:ok, ip} - {:error, _} -> - case :inet.getaddr(String.to_charlist(host), :inet) do - {:ok, ip} -> {:ok, ip} - {:error, _} -> - case :inet.getaddr(String.to_charlist(host), :inet6) do - {:ok, ip} -> {:ok, ip} - error -> error - end - end + charlist = String.to_charlist(host) + + with {:error, _} <- :inet.parse_address(charlist), + {:error, _} <- :inet.getaddr(charlist, :inet) do + error = :inet.getaddr(charlist, :inet6) + error end end @@ -95,9 +89,11 @@ defmodule Towerops.URLValidator do defp private_ip?({a, b, c, d}) do Enum.any?(@private_ranges, fn - :ipv6_loopback -> false + :ipv6_loopback -> + false + {net_a, net_b, net_c, net_d, prefix} -> - mask = bsl(0xFFFFFFFF, 32 - prefix) |> band(0xFFFFFFFF) + mask = 0xFFFFFFFF |> bsl(32 - prefix) |> band(0xFFFFFFFF) ip_int = bsl(a, 24) + bsl(b, 16) + bsl(c, 8) + d net_int = bsl(net_a, 24) + bsl(net_b, 16) + bsl(net_c, 8) + net_d band(ip_int, mask) == band(net_int, mask) @@ -105,6 +101,4 @@ defmodule Towerops.URLValidator do end defp private_ip?(_), do: false - - import Bitwise end diff --git a/lib/towerops_web/live/agent_live/index.ex b/lib/towerops_web/live/agent_live/index.ex index cc44a7e1..4dd3c36d 100644 --- a/lib/towerops_web/live/agent_live/index.ex +++ b/lib/towerops_web/live/agent_live/index.ex @@ -161,77 +161,12 @@ defmodule ToweropsWeb.AgentLive.Index do @impl true def handle_event("delete_agent", %{"id" => id}, socket) do - organization = socket.assigns.current_scope.organization - - # Verify the agent token belongs to the current org before deleting agent_token = Agents.get_agent_token!(id) - is_superuser = Scope.superuser?(socket.assigns.current_scope) - is_cloud_poller = is_nil(agent_token.organization_id) - belongs_to_org = agent_token.organization_id == organization.id - - if not belongs_to_org and not (is_superuser and is_cloud_poller) do - {:noreply, put_flash(socket, :error, t_equipment("Agent not found"))} + if agent_authorized_for_deletion?(socket, agent_token) do + handle_agent_deletion(socket, id) else - case Agents.delete_agent_token(id) do - {:ok, _} -> - - # If the deleted agent was the global default, clear it - if Scope.superuser?(socket.assigns.current_scope) && - socket.assigns.global_default_cloud_poller_id == id do - Settings.set_global_default_cloud_poller(nil) - end - - agent_tokens = Agents.list_organization_agent_tokens(organization.id) - - # Refresh cloud pollers list if superadmin (in case a cloud poller was deleted) - cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope) - - # Reload global default cloud poller ID - global_default_cloud_poller_id = - if Scope.superuser?(socket.assigns.current_scope) do - Settings.get_global_default_cloud_poller() - else - socket.assigns.global_default_cloud_poller_id - end - - # Recalculate device counts after deletion - equipment_counts = - Map.new(agent_tokens, fn token -> - direct = Agents.count_assigned_devices(token.id) - total = length(Agents.list_agent_polling_targets(token.id)) - {token.id, %{direct: direct, total: total}} - end) - - cloud_poller_counts = calculate_device_counts(cloud_pollers) - - # Refresh health statistics - agent_health_stats = Stats.get_organization_agent_health(organization.id) - assignment_breakdown = Stats.get_device_assignment_breakdown(organization.id) - offline_agents = Stats.get_offline_agents(organization.id) - - {:noreply, - socket - |> stream(:agent_tokens, agent_tokens, reset: true) - |> assign(:has_agents, agent_tokens != []) - |> stream(:cloud_pollers, cloud_pollers, reset: true) - |> assign(:cloud_pollers_list, cloud_pollers) - |> assign(:has_cloud_pollers, cloud_pollers != []) - |> assign(:global_default_cloud_poller_id, global_default_cloud_poller_id) - |> assign(:selected_global_default, global_default_cloud_poller_id || "") - |> assign(:device_counts, equipment_counts) - |> assign(:cloud_poller_counts, cloud_poller_counts) - |> assign(:agent_health_stats, agent_health_stats) - |> assign(:assignment_breakdown, assignment_breakdown) - |> assign(:offline_agents, offline_agents) - |> put_flash( - :info, - t_equipment("Agent deleted successfully. Devices now fall back to site/org defaults or cloud polling.") - )} - - {:error, _} -> - {:noreply, put_flash(socket, :error, t_equipment("Failed to delete agent"))} - end + {:noreply, put_flash(socket, :error, t_equipment("Agent not found"))} end end @@ -484,6 +419,76 @@ defmodule ToweropsWeb.AgentLive.Index do |> assign(:offline_agents, offline_agents) end + defp agent_authorized_for_deletion?(socket, agent_token) do + organization = socket.assigns.current_scope.organization + is_superuser = Scope.superuser?(socket.assigns.current_scope) + is_cloud_poller = is_nil(agent_token.organization_id) + belongs_to_org = agent_token.organization_id == organization.id + + belongs_to_org or (is_superuser and is_cloud_poller) + end + + defp handle_agent_deletion(socket, id) do + case Agents.delete_agent_token(id) do + {:ok, _} -> + handle_agent_deletion_success(socket, id) + + {:error, _} -> + {:noreply, put_flash(socket, :error, t_equipment("Failed to delete agent"))} + end + end + + defp handle_agent_deletion_success(socket, deleted_id) do + maybe_clear_global_default(socket, deleted_id) + + organization = socket.assigns.current_scope.organization + agent_tokens = Agents.list_organization_agent_tokens(organization.id) + cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope) + + global_default_cloud_poller_id = reload_global_default(socket.assigns.current_scope, socket.assigns) + + equipment_counts = calculate_device_counts(agent_tokens) + cloud_poller_counts = calculate_device_counts(cloud_pollers) + + agent_health_stats = Stats.get_organization_agent_health(organization.id) + assignment_breakdown = Stats.get_device_assignment_breakdown(organization.id) + offline_agents = Stats.get_offline_agents(organization.id) + + {:noreply, + socket + |> stream(:agent_tokens, agent_tokens, reset: true) + |> assign(:has_agents, agent_tokens != []) + |> stream(:cloud_pollers, cloud_pollers, reset: true) + |> assign(:cloud_pollers_list, cloud_pollers) + |> assign(:has_cloud_pollers, cloud_pollers != []) + |> assign(:global_default_cloud_poller_id, global_default_cloud_poller_id) + |> assign(:selected_global_default, global_default_cloud_poller_id || "") + |> assign(:device_counts, equipment_counts) + |> assign(:cloud_poller_counts, cloud_poller_counts) + |> assign(:agent_health_stats, agent_health_stats) + |> assign(:assignment_breakdown, assignment_breakdown) + |> assign(:offline_agents, offline_agents) + |> put_flash( + :info, + t_equipment("Agent deleted successfully. Devices now fall back to site/org defaults or cloud polling.") + )} + end + + defp maybe_clear_global_default(socket, deleted_id) do + if Scope.superuser?(socket.assigns.current_scope) && + socket.assigns.global_default_cloud_poller_id == deleted_id do + Settings.set_global_default_cloud_poller(nil) + end + end + + defp reload_global_default(scope, assigns) do + if Scope.superuser?(scope) do + Settings.get_global_default_cloud_poller() + else + assigns.global_default_cloud_poller_id + end + end + @impl true def terminate(_reason, socket) do if timer_ref = socket.assigns[:timer_ref] do diff --git a/lib/towerops_web/live/network_map_live.html.heex b/lib/towerops_web/live/network_map_live.html.heex index 8a6fe435..cc2eb056 100644 --- a/lib/towerops_web/live/network_map_live.html.heex +++ b/lib/towerops_web/live/network_map_live.html.heex @@ -286,8 +286,8 @@ data-topology={Jason.encode!(@topology)} > - - + +