security: fix critical vulnerabilities and atom exhaustion risks

Critical fixes:
- Add [:safe] option to binary_to_term to prevent RCE attacks
- Implement whitelist validation for String.to_atom conversions
- Add input validation before String.to_existing_atom usage

Changes:
- MIB compiler and cache: Use safe binary deserialization
- SNMP contexts: Whitelist protocol, device type, and source atoms
- API controllers: Validate error message keys before atom conversion
- Reduce function nesting to comply with Credo standards

All 6,145 tests passing with zero Credo issues.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Graham McIntire 2026-02-08 10:30:30 -06:00
parent 24236dbe30
commit fb1d4c564f
No known key found for this signature in database
7 changed files with 62 additions and 9 deletions

View file

@ -584,9 +584,11 @@ defmodule SnmpKit.SnmpLib.Cache do
end
defp decompress_value(compressed_value) do
# Use safe: true to prevent arbitrary code execution via malicious cached terms
# sobelow_skip ["Misc.BinToTerm"]
compressed_value
|> :zlib.uncompress()
|> :erlang.binary_to_term()
|> :erlang.binary_to_term([:safe])
end
# Pattern matching and invalidation

View file

@ -200,7 +200,9 @@ defmodule SnmpKit.SnmpLib.MIB.Compiler do
def load_compiled(compiled_path) do
case File.read(compiled_path) do
{:ok, binary_data} ->
case :erlang.binary_to_term(binary_data) do
# Use safe: true to prevent arbitrary code execution via malicious terms
# sobelow_skip ["Misc.BinToTerm"]
case :erlang.binary_to_term(binary_data, [:safe]) do
%{__type__: :compiled_mib} = compiled ->
{:ok, compiled}

View file

@ -302,10 +302,17 @@ defmodule Towerops.Devices do
community: device.snmp_community,
port: device.snmp_port || 161,
transport: device.snmp_transport || "udp",
source: String.to_atom(device.snmp_community_source || "organization")
source: safe_source_to_atom(device.snmp_community_source)
}
end
# Safely convert credential source to atom with whitelist to prevent atom exhaustion
defp safe_source_to_atom("device"), do: :device
defp safe_source_to_atom("site"), do: :site
defp safe_source_to_atom("organization"), do: :organization
defp safe_source_to_atom(nil), do: :organization
defp safe_source_to_atom(_unknown), do: :organization
@doc """
Gets MikroTik API configuration for a device with hierarchical fallback.

View file

@ -1245,7 +1245,7 @@ defmodule Towerops.Snmp do
neighbor_protocols =
neighbors
|> Enum.map(& &1.protocol)
|> Enum.map(&String.to_atom/1)
|> Enum.map(&safe_protocol_to_atom/1)
|> Enum.uniq()
protocols ++ neighbor_protocols
@ -1257,6 +1257,11 @@ defmodule Towerops.Snmp do
Enum.uniq(protocols)
end
# Safely convert protocol string to atom with whitelist to prevent atom exhaustion
defp safe_protocol_to_atom("lldp"), do: :lldp
defp safe_protocol_to_atom("cdp"), do: :cdp
defp safe_protocol_to_atom(_unknown), do: :unknown
# VLAN queries
@doc """
@ -1905,9 +1910,16 @@ defmodule Towerops.Snmp do
|> Enum.sort_by(& &1.device_count, :desc)
end
# Convert device type to atom (handles both string and atom)
# Convert device type to atom with whitelist to prevent atom exhaustion
defp device_type_atom(device) when is_atom(device), do: device
defp device_type_atom(device) when is_binary(device), do: String.to_atom(device)
defp device_type_atom("router"), do: :router
defp device_type_atom("switch"), do: :switch
defp device_type_atom("wireless"), do: :wireless
defp device_type_atom("server"), do: :server
defp device_type_atom("workstation"), do: :workstation
defp device_type_atom("firewall"), do: :firewall
defp device_type_atom("printer"), do: :printer
defp device_type_atom("phone"), do: :phone
defp device_type_atom(_), do: :unknown
# Calculate CIDR notation from IP and mask

View file

@ -177,8 +177,18 @@ defmodule ToweropsWeb.Api.MobileAuthController do
defp translate_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
safe_translate_key(key, opts)
end)
end)
end
# Safely translate error message keys to prevent atom exhaustion
defp safe_translate_key(key, opts) do
# Validate key length and format before converting to atom to prevent abuse
if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
else
key
end
end
end

View file

@ -333,8 +333,18 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
defp translate_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
safe_translate_key(key, opts)
end)
end)
end
# Safely translate error message keys to prevent atom exhaustion
defp safe_translate_key(key, opts) do
# Validate key length and format before converting to atom to prevent abuse
if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
else
key
end
end
end

View file

@ -221,8 +221,18 @@ defmodule ToweropsWeb.Api.V1.SitesController do
defp translate_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
safe_translate_key(key, opts)
end)
end)
end
# Safely translate error message keys to prevent atom exhaustion
defp safe_translate_key(key, opts) do
# Validate key length and format before converting to atom to prevent abuse
if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
else
key
end
end
end