Preventing atom exhaustion attacks with `String.to_existing_atom`

almirsarajcic

almirsarajcic

yesterday

Using String.to_atom/1 on user input is a critical security vulnerability that can crash your entire BEAM VM through atom exhaustion. Here are two approaches to safely convert user input to atoms - from manual validation to automatic protection.

# ❌ DANGEROUS - Never do this with user input!
def process_filter(params) do
  params["filter_by"]
  |> String.to_atom()  # Atom table grows forever!
  |> query_by()
end

# ✅ SAFER - Use String.to_existing_atom (prevents new atom creation)
def convert_filter_param(user_input) when is_binary(user_input) do
  {:ok, String.to_existing_atom(user_input)}
rescue
  ArgumentError -> {:error, :invalid_atom}
end

# Usage: only works if atom already exists in your app
convert_filter_param("status")     # {:ok, :status} (if atom exists)
convert_filter_param("malicious")  # {:error, :invalid_atom} (atom doesn't exist)

String.to_existing_atom/1 only converts strings to atoms that already exist in the atom table - if the atom doesn’t exist, it raises ArgumentError. This prevents attackers from creating new atoms but still requires error handling.

Pro tip: For Phoenix apps, use Ecto.Enum for the cleanest solution:

defmodule MyApp.FilterParams do
  use Ecto.Schema

  import Ecto.Changeset

  @primary_key false
  embedded_schema do
    field :filter_by, Ecto.Enum, values: [:status, :priority, :category, :type]
    field :sort_by, Ecto.Enum, values: [:name, :created_at, :updated_at]
  end

  def changeset(attrs) do
    %__MODULE__{}
    |> cast(attrs, [:filter_by, :sort_by])
  end
end

# In your controller - completely safe, no error handling needed
def index(conn, params) do
  items = case FilterParams.changeset(params) do
    %{valid?: true, changes: filters} ->
      query_with_filters(filters) |> Repo.all()
    _ ->
      Repo.all(Item)
  end

  render(conn, :index, items: items)
end

Ecto.Enum automatically handles safe atom conversion and validation - no manual error handling required. You can also monitor atom usage in production with :erlang.system_info(:atom_count).