SafeAtom

CIHex pmLicense: MIT

Whitelist-based casting of values to atoms without growing the VM atom table from untrusted input.

SafeAtom never calls String.to_atom/1 or String.to_existing_atom/1 on external data. Binary input is matched against Atom.to_string/1 for atoms you already listed in :allowed, and the returned atom always comes from that list.

Installation

Add safe_atom to your list of dependencies in mix.exs:

def deps do
[
{:safe_atom, "~> 0.3"}
]
end

Or depend on the Git repository:

{:safe_atom, git: "https://github.com/ivan-podgurskiy/safe_atom.git"}

Quick start

SafeAtom.cast("user", allowed: [:user, :guest])
# => {:ok, :user}
SafeAtom.cast(:guest, allowed: [:user, :guest])
# => {:ok, :guest}
SafeAtom.cast("admin", allowed: [:user, :guest])
# => {:error, :not_allowed}
SafeAtom.cast!("user", allowed: [:user, :guest])
# => :user

API

SafeAtom.cast/2

Casts a binary or atom to one of the atoms in allowed: [...].

Returns {:ok, atom()} or {:error, reason}.

SafeAtom.cast!/2

Same as cast/2, but raises SafeAtom.Error on failure. The exception carries value, reason, and allowed for debugging.

Error reasons

ReasonWhen
:missing_allowed:allowed was not provided
:invalid_allowed:allowed is not a list of atoms
:invalid_valueInput is neither a binary nor an atom
:not_allowedInput is valid but not in the whitelist

Ecto

SafeAtom.Ecto.Enum is a parameterized Ecto type for atom enum fields. It uses the same whitelist rules as SafeAtom.cast/2 for changeset input and database load/dump. Values are stored as strings (like Ecto.Enum).

Add both safe_atom and ecto to your app:

def deps do
[
{:safe_atom, "~> 0.3"},
{:ecto, "~> 3.11"}
]
end

Migration:

add :role, :string

Schema:

field :role, SafeAtom.Ecto.Enum, values: [:user, :guest]

Changeset casting accepts whitelisted strings and atoms; invalid values produce Ecto inclusion errors. On insert, whitelisted atoms dump to their string form; on load, DB strings are matched against values without String.to_atom/1.

Plug

SafeAtom.Plug casts selected top-level request params using a separate atom whitelist for each field. Plug is optional, so applications using this helper must include both dependencies:

def deps do
[
{:safe_atom, "~> 0.3"},
{:plug, "~> 1.0"}
]
end

Use it as a module plug in a router or controller pipeline:

plug SafeAtom.Plug,
fields: %{
status: [:active, :archived],
sort: [:asc, :desc]
}

Configured field names are atoms, but the helper reads and updates only string-keyed params. A successful cast produces values such as %{"status" => :active} and leaves unrelated params unchanged.

Rejected values are dropped by default. To return a generic 400 response and stop processing:

plug SafeAtom.Plug,
fields: %{status: [:active, :archived]},
on_reject: :halt

For application-specific handling in a module plug, use an external remote function capture. Plug.Builder must escape initialized plug options at compile time, so an anonymous closure cannot be used here:

defmodule MyApp.ParamPipeline do
use Plug.Builder
plug SafeAtom.Plug,
fields: %{status: [:active, :archived]},
on_reject: &__MODULE__.handle_rejection/2
def handle_rejection(conn, rejection) do
conn
|> Plug.Conn.assign(:safe_atom_rejection, rejection)
|> Plug.Conn.send_resp(422, "invalid #{rejection.field}")
|> Plug.Conn.halt()
end
end

The callback receives a SafeAtom.Plug.Rejection with field, value, and reason. The helper stops processing more fields if the callback returns a halted connection.

For ad-hoc use in a controller action:

conn =
SafeAtom.Plug.cast_params(
conn,
%{status: [:active, :archived]},
on_reject: fn conn, rejection ->
Plug.Conn.assign(conn, :safe_atom_rejection, rejection)
end
)

Direct cast_params/3 calls accept any two-arity function, including closures.

Why?

Atoms in the Erlang VM are not garbage-collected. Calling String.to_atom/1 on user-controlled strings can exhaust the atom table and crash the node. String.to_existing_atom/1 avoids creating new atoms but still walks the global atom table for every lookup.

SafeAtom keeps casting explicit: you declare the finite set of atoms you accept, and only those atoms can be returned.

Telemetry

SafeAtom emits one event whenever cast/2 returns an error:

EventMeasurementsMetadata
[:safe_atom, :cast, :rejected]%{system_time: integer()}%{reason, value, allowed}

Successful casts do not emit events. Attach a handler with :telemetry.attach/4 to log rejections or aggregate rates.

:telemetry.attach(
"safe-atom-rejections",
[:safe_atom, :cast, :rejected],
fn _event, _measurements, %{reason: reason, value: value}, _config ->
Logger.warning("SafeAtom rejected #{inspect(value)}: #{reason}")
end,
nil
)

Development

mix test
mix credo --strict
mix dialyzer
mix docs

License

MIT © Ivan Podgurskiy. See LICENSE.