CVE-2026-8469
Description
Allocation of Resources Without Limits or Throttling vulnerability in phenixdigital phoenix_storybook allows unauthenticated denial-of-service via BEAM atom table exhaustion.
Multiple LiveView event handlers convert user-supplied event parameter strings to atoms using String.to_atom/1 without validation: 'Elixir.PhoenixStorybook.ExtraAssignsHelpers':handle_set_variation_assign/3 interns every key of the psb-assign params map; 'Elixir.PhoenixStorybook.ExtraAssignsHelpers':handle_toggle_variation_assign/3 interns the "attr" value from psb-toggle events; 'Elixir.PhoenixStorybook.ExtraAssignsHelpers':to_variation_id/2 interns elements of "variation_id"; and 'Elixir.PhoenixStorybook.ExtraAssignsHelpers':to_value/4 interns raw string values for attributes declared as :atom or :boolean. BEAM atoms are never garbage-collected, so each unique attacker-controlled string is a permanent allocation. Once the atom table ceiling (~1,048,576 atoms) is reached, the entire BEAM node aborts, taking down all applications running on it.
This issue affects phoenix_storybook from 0.2.0 before 1.1.0.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Unauthenticated users can crash the entire BEAM node by sending crafted LiveView events to phoenix_storybook, exhausting the atom table.
Vulnerability
The vulnerability resides in the ExtraAssignsHelpers module of the phoenix_storybook library (versions from 0.2.0 before 1.1.0). Multiple LiveView event handlers call String.to_atom/1 on attacker-controlled strings without validation: handle_set_variation_assign/3 interns every key of the psb-assign params map; handle_toggle_variation_assign/3 interns the attr value from psb-toggle events; to_variation_id/2 interns elements of variation_id; and to_value/4 interns raw string values for attributes declared as :atom or :boolean. BEAM atoms are never garbage-collected, so each unique string permanently occupies a slot. When the atom table ceiling (~1,048,576 atoms) is reached, the VM aborts, crashing all applications on the node [1][2].
Exploitation
An unauthenticated attacker who can reach a mounted Phoenix Storybook playground route can deliver event messages such as psb-assign, psb-toggle, psb-set-theme, upper-tab-navigation, lower-tab-navigation, playground-change, or playground-toggle. No authentication or special network position is required beyond access to the storybook endpoint. The attacker repeatedly sends events with unique strings (e.g., unique keys in psb-assign maps or unique attr values in psb-toggle events), causing the handlers to create new atoms each time. Because the atom table is never garbage-collected, the node will eventually crash once the limit is reached [2][4].
Impact
A successful attack results in a denial-of-service (DoS) via BEAM node abort, affecting all applications running on that node. There is no confidentiality or integrity impact, but availability is completely compromised (CVSS v4.0 base score 8.2, HIGH) [1][4].
Mitigation
The vulnerability was fixed in phoenix_storybook version 1.1.0. The fix replaced String.to_atom/1 with String.to_existing_atom/1 (with rescue for unknown names) or looked up attributes/variations in the declared registry to reuse existing atoms [3]. Users should upgrade to phoenix_storybook 1.1.0 or later. No workarounds are currently available for unpatched versions; if upgrading is not possible, the storybook route should be restricted from untrusted access [1][2].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2(expand)+ 1 more
- (no CPE)
- (no CPE)range: >=0.2.0 <1.1.0
Patches
196d524690af0Fix atom exhaustion from playground LiveView params
9 files changed · +252 −49
lib/phoenix_storybook/helpers/extra_assigns_helpers.ex+41 −17 modified@@ -172,12 +172,21 @@ defmodule PhoenixStorybook.ExtraAssignsHelpers do defp to_value("nil", _attr_id, _attributes, _context), do: nil defp to_value(val, attr_id, attributes, context) when is_binary(val) do - case declared_attr_type(attr_id, attributes) do - :atom -> val |> existing_value_atom!(context) |> check_type!(:atom, context) - :boolean -> val |> parse_boolean!(context) |> check_type!(:boolean, context) - :integer -> val |> Integer.parse() |> check_type!(:integer, context) - :float -> val |> Float.parse() |> check_type!(:float, context) - _ -> val + case declared_attr(attr_id, attributes) do + %Attr{type: :atom, values: values} -> + val |> to_atom_value(values, context) |> check_type!(:atom, context) + + %Attr{type: :boolean} -> + val |> to_boolean_value(context) |> check_type!(:boolean, context) + + %Attr{type: :integer} -> + val |> Integer.parse() |> check_type!(:integer, context) + + %Attr{type: :float} -> + val |> Float.parse() |> check_type!(:float, context) + + _ -> + val end end @@ -188,8 +197,30 @@ defmodule PhoenixStorybook.ExtraAssignsHelpers do end end + defp to_atom_value("nil", _values, _context), do: nil + + defp to_atom_value(val, nil, context) do + existing_value_atom!(val, context) + end + + defp to_atom_value(val, values, context) do + Enum.find(values, &(to_string(&1) == val)) || + raise(RuntimeError, "unknown atom value in #{context}: #{val}") + end + + defp to_boolean_value("true", _context), do: true + defp to_boolean_value("false", _context), do: false + + defp to_boolean_value(val, context) do + raise(RuntimeError, "type mismatch in #{context}: #{val} is not a boolean") + end + + defp declared_attr(attr_id, attributes) do + Enum.find(attributes, fn %Attr{id: id} -> id == attr_id end) + end + defp declared_attr_type(attr_id, attributes) do - case Enum.find(attributes, fn %Attr{id: id} -> id == attr_id end) do + case declared_attr(attr_id, attributes) do %Attr{type: type} -> type _ -> nil end @@ -207,22 +238,15 @@ defmodule PhoenixStorybook.ExtraAssignsHelpers do ArgumentError -> raise(RuntimeError, "unknown atom value in #{context}: #{val}") end - defp parse_boolean!("true", _context), do: true - defp parse_boolean!("false", _context), do: false - - defp parse_boolean!(val, context) do - raise(RuntimeError, "type mismatch in #{context}: #{val} is not a boolean") - end - defp check_type!(nil, _type, _context), do: nil defp check_type!(atom, :atom, _context) when is_atom(atom), do: atom defp check_type!(boolean, :boolean, _context) when is_boolean(boolean), do: boolean - defp check_type!({integer, _}, :integer, _context) when is_integer(integer), do: integer + defp check_type!({integer, ""}, :integer, _context) when is_integer(integer), do: integer defp check_type!(integer, :integer, _context) when is_integer(integer), do: integer - defp check_type!({float, _}, :float, _context) when is_float(float), do: float + defp check_type!({float, ""}, :float, _context) when is_float(float), do: float defp check_type!(float, :float, _context) when is_float(float), do: float defp check_type!(value, type, context) do - raise(RuntimeError, "type mismatch in #{context}: #{value} is not a #{type}") + raise(RuntimeError, "type mismatch in #{context}: #{inspect(value)} is not a #{type}") end end
lib/phoenix_storybook/helpers/theme_helpers.ex+15 −0 modified@@ -16,6 +16,21 @@ defmodule PhoenixStorybook.ThemeHelpers do end end + def theme_from_param(_backend_module, theme) when theme in [nil, ""], do: nil + def theme_from_param(_backend_module, theme) when is_atom(theme), do: theme + + def theme_from_param(backend_module, theme) when is_binary(theme) do + case backend_module.config(:themes) do + nil -> + raise(RuntimeError, "unknown theme: #{theme}") + + themes -> + Enum.find_value(themes, fn {theme_id, _label} -> + if to_string(theme_id) == theme, do: theme_id + end) || raise(RuntimeError, "unknown theme: #{theme}") + end + end + def call_theme_function(backend_module, theme) do case theme_strategy(backend_module, :function) do nil -> nil
lib/phoenix_storybook/live/story_live.ex+25 −3 modified@@ -161,7 +161,7 @@ defmodule PhoenixStorybook.StoryLive do defp current_tab(params, story) do case Map.get(params, "tab") do nil -> default_tab(story) - tab -> String.to_atom(tab) + tab -> tab_from_param(story, tab) || built_in_tab_from_param(story, tab) || tab end end @@ -193,7 +193,7 @@ defmodule PhoenixStorybook.StoryLive do defp current_theme(params, socket) do case Map.get(params, "theme") do nil -> default_theme(socket) - theme -> String.to_atom(theme) + theme -> ThemeHelpers.theme_from_param(socket.assigns.backend_module, theme) end end @@ -204,6 +204,26 @@ defmodule PhoenixStorybook.StoryLive do end end + defp tab_from_param(story, tab) do + story + |> navigation_tabs() + |> Enum.find_value(fn + {tab_id, _label} -> if to_string(tab_id) == tab, do: tab_id + {tab_id, _label, _icon} -> if to_string(tab_id) == tab, do: tab_id + end) + end + + defp built_in_tab_from_param(story, tab) do + tabs = + case story.storybook_type() do + type when type in [:component, :live_component] -> ~w(variations playground source)a + :example -> ~w(example source)a + _ -> [] + end + + Enum.find(tabs, &(to_string(&1) == tab)) + end + defp close_sidebar(socket), do: push_event(socket, "psb:close-sidebar", %{"id" => "#psb-sidebar"}) @@ -756,10 +776,12 @@ defmodule PhoenixStorybook.StoryLive do end def handle_event("psb-set-theme", %{"theme" => theme}, socket) do + theme = ThemeHelpers.theme_from_param(socket.assigns.backend_module, theme) + PubSub.broadcast!( PhoenixStorybook.PubSub, socket.assigns.playground_topic, - {:set_theme, String.to_atom(theme)} + {:set_theme, theme} ) send_update(Playground, id: "psb-playground", new_theme: theme)
lib/phoenix_storybook/live/story/playground.ex+69 −25 modified@@ -14,6 +14,9 @@ defmodule PhoenixStorybook.Story.Playground do import PhoenixStorybook.NavigationHelpers + @upper_tabs ~w(preview code html)a + @lower_tabs ~w(attributes events)a + def mount(socket) do {:ok, socket @@ -136,12 +139,14 @@ defmodule PhoenixStorybook.Story.Playground do socket theme -> + theme = ThemeHelpers.theme_from_param(socket.assigns.backend_module, theme) + variations = for variation <- socket.assigns.variations do update_variation_attributes(variation, %{theme: theme}) end - fields = Map.put(socket.assigns.fields, :theme, String.to_existing_atom(theme)) + fields = Map.put(socket.assigns.fields, :theme, theme) socket |> assign(:fields, fields) @@ -1077,18 +1082,25 @@ defmodule PhoenixStorybook.Story.Playground do end def handle_event("upper-tab-navigation", %{"tab" => tab}, socket) do - {:noreply, assign(socket, :upper_tab, String.to_atom(tab))} + case tab_from_param(tab, @upper_tabs) do + nil -> {:noreply, socket} + tab -> {:noreply, assign(socket, :upper_tab, tab)} + end end def handle_event("lower-tab-navigation", %{"tab" => tab}, socket) do - tab = String.to_atom(tab) - - {:noreply, - socket - |> assign(:lower_tab, tab) - |> update(:event_logs_unread, fn current -> - if tab == :events, do: 0, else: current - end)} + case tab_from_param(tab, @lower_tabs) do + nil -> + {:noreply, socket} + + tab -> + {:noreply, + socket + |> assign(:lower_tab, tab) + |> update(:event_logs_unread, fn current -> + if tab == :events, do: 0, else: current + end)} + end end def handle_event("playground-change", %{"playground" => params}, socket = %{assigns: assigns}) do @@ -1097,15 +1109,18 @@ defmodule PhoenixStorybook.Story.Playground do fields = for {key, value} <- params, not String.starts_with?(key, "_unused_"), - key = String.to_atom(key), reduce: assigns.fields do acc -> - attr_definition = Enum.find(story.merged_attributes(), &(&1.id == key)) - - if (is_nil(value) || value == "") && !attr_definition.required do - Map.put(acc, key, nil) - else - Map.put(acc, key, cast_value(story, key, value)) + case attr_from_param(story, key) do + nil -> + acc + + attr_definition = %Attr{id: attr_id} -> + if (is_nil(value) || value == "") && !attr_definition.required do + Map.put(acc, attr_id, nil) + else + Map.put(acc, attr_id, cast_value(attr_definition, value)) + end end end @@ -1120,11 +1135,17 @@ defmodule PhoenixStorybook.Story.Playground do %{"toggled" => [key, value]}, socket = %{assigns: assigns} ) do - fields = Map.put(assigns.fields, String.to_atom(key), value) + case attr_from_param(assigns.story, key) do + nil -> + {:noreply, socket} - variations = update_variations_attributes(assigns.variations, fields) - send_attributes(assigns.topic, fields) - {:noreply, assign(socket, variations: variations, fields: fields)} + %Attr{id: attr_id} -> + fields = Map.put(assigns.fields, attr_id, value) + + variations = update_variations_attributes(assigns.variations, fields) + send_attributes(assigns.topic, fields) + {:noreply, assign(socket, variations: variations, fields: fields)} + end end def handle_event( @@ -1169,17 +1190,40 @@ defmodule PhoenixStorybook.Story.Playground do PubSub.broadcast!(PhoenixStorybook.PubSub, topic, {:set_variation, variation}) end - defp cast_value(story, attr_id, value) do - attr = story.merged_attributes() |> Enum.find(&(&1.id == attr_id)) + defp tab_from_param(tab, allowed_tabs) do + Enum.find(allowed_tabs, &(to_string(&1) == tab)) + end + defp attr_from_param(story, attr) do + Enum.find(story.merged_attributes(), &(to_string(&1.id) == attr)) + end + + defp cast_value(attr, value) do case attr.type do - :atom -> String.to_atom(value) - :boolean -> String.to_atom(value) + :atom -> atom_value(attr, value) + :boolean -> boolean_value(value) :integer -> String.to_integer(value) :float -> String.to_float(value) _ -> value end rescue _ -> value end + + defp atom_value(_attr, value) when is_atom(value), do: value + + defp atom_value(%Attr{values: nil}, value) when is_binary(value) do + String.to_existing_atom(value) + end + + defp atom_value(%Attr{values: values}, value) when is_binary(value) do + Enum.find(values, &(to_string(&1) == value)) || value + end + + defp atom_value(_attr, value), do: value + + defp boolean_value(value) when value in [true, false], do: value + defp boolean_value("true"), do: true + defp boolean_value("false"), do: false + defp boolean_value(value), do: value end
lib/phoenix_storybook/live/story/playground_preview_live.ex+2 −1 modified@@ -86,7 +86,8 @@ defmodule PhoenixStorybook.Story.PlaygroundPreviewLive do defp variation_id(%VariationGroup{id: group_id}, variation_id), do: {group_id, variation_id} defp variation_id(%Variation{}, variation_id), do: {:single, variation_id} - defp theme(theme) when is_binary(theme), do: String.to_atom(theme) + defp theme(theme) when theme in [nil, ""], do: nil + defp theme(theme) when is_binary(theme), do: String.to_existing_atom(theme) defp theme(theme) when is_atom(theme), do: theme def render(assigns = %{variation: nil}), do: ~H""
lib/phoenix_storybook/live/visual_test_live.ex+1 −1 modified@@ -119,7 +119,7 @@ defmodule PhoenixStorybook.VisualTestLive do defp current_theme(params, socket) do case Map.get(params, "theme") do nil -> default_theme(socket) - theme -> String.to_atom(theme) + theme -> ThemeHelpers.theme_from_param(socket.assigns.backend_module, theme) end end
test/phoenix_storybook/helpers/extra_assigns_helpers_test.exs+75 −1 modified@@ -65,6 +65,13 @@ defmodule PhoenixStorybook.ExtraAssignsHelpersTest do ) == {{:single, :variation_id}, %{atom: :foo}} + assert handle_set_variation_assign( + %{"variation_id" => "variation_id", "atom_with_values" => "opt1"}, + %{{:single, :variation_id} => %{}}, + story + ) == + {{:single, :variation_id}, %{atom_with_values: :opt1}} + assert handle_set_variation_assign( %{"variation_id" => "variation_id", "list" => ["foo", "bar"]}, %{{:single, :variation_id} => %{}}, @@ -100,13 +107,57 @@ defmodule PhoenixStorybook.ExtraAssignsHelpersTest do assert_raise RuntimeError, ~r/unknown atom value in assign/, fn -> handle_set_variation_assign( - %{"variation_id" => "variation_id", "atom" => "psb_unknown_atom_value"}, + %{"variation_id" => "variation_id", "atom" => unknown_string()}, + %{{:single, :variation_id} => %{}}, + story + ) + end + + assert_raise RuntimeError, ~r/unknown atom value in assign/, fn -> + handle_set_variation_assign( + %{"variation_id" => "variation_id", "atom_with_values" => "unknown"}, %{{:single, :variation_id} => %{}}, story ) end end + test "does not intern unknown assign params", %{story: story} do + unknown_attr = unknown_string() + unknown_variation = unknown_string() + unknown_atom_value = unknown_string() + + assert_raise RuntimeError, ~r/unknown attribute in assign/, fn -> + handle_set_variation_assign( + %{"variation_id" => "variation_id", unknown_attr => "foo"}, + %{{:single, :variation_id} => %{}}, + story + ) + end + + refute_existing_atom(unknown_attr) + + assert_raise RuntimeError, ~r/unknown variation_id in assign/, fn -> + handle_set_variation_assign( + %{"variation_id" => unknown_variation, "attribute" => "foo"}, + %{{:single, :variation_id} => %{}}, + story + ) + end + + refute_existing_atom(unknown_variation) + + assert_raise RuntimeError, ~r/unknown atom value in assign/, fn -> + handle_set_variation_assign( + %{"variation_id" => "variation_id", "atom" => unknown_atom_value}, + %{{:single, :variation_id} => %{}}, + story + ) + end + + refute_existing_atom(unknown_atom_value) + end + test "with nil typed attributes", %{story: story} do assert handle_set_variation_assign( %{"variation_id" => "variation_id", "boolean" => "nil"}, @@ -191,6 +242,20 @@ defmodule PhoenixStorybook.ExtraAssignsHelpersTest do handle_toggle_variation_assign(%{}, %{}, story) end end + + test "does not intern unknown toggle attr", %{story: story} do + unknown_attr = unknown_string() + + assert_raise RuntimeError, ~r/unknown attribute in toggle/, fn -> + handle_toggle_variation_assign( + %{"variation_id" => "variation_id", "attr" => unknown_attr}, + %{{:single, :variation_id} => %{}}, + story + ) + end + + refute_existing_atom(unknown_attr) + end end defp story(_context) do @@ -202,10 +267,19 @@ defmodule PhoenixStorybook.ExtraAssignsHelpersTest do %Attr{id: :integer, type: :integer}, %Attr{id: :float, type: :float}, %Attr{id: :atom, type: :atom}, + %Attr{id: :atom_with_values, type: :atom, values: [:opt1, :opt2]}, %Attr{id: :list, type: :list} ] end) [story: StoryMock] end + + defp unknown_string do + "psb_unknown_#{System.unique_integer([:positive])}" + end + + defp refute_existing_atom(value) do + assert_raise ArgumentError, fn -> String.to_existing_atom(value) end + end end
test/phoenix_storybook/helpers/theme_helpers_test.exs+19 −0 modified@@ -39,6 +39,10 @@ defmodule PhoenixStorybook.ThemeHelpersTest do def config(:themes_strategies, default), do: default end + defmodule ThemesBackend do + def config(:themes), do: [default: [name: "Default"], colorful: [name: "Colorful"]] + end + test "theme_sandbox_class returns nil when no sandbox strategy" do assert ThemeHelpers.theme_sandbox_class(SandboxNilBackend, :default) == nil end @@ -66,4 +70,19 @@ defmodule PhoenixStorybook.ThemeHelpersTest do test "call_theme_function applies configured function" do assert ThemeHelpers.call_theme_function(FunctionBackend, :default) == {:ok, :default} end + + test "theme_from_param resolves configured themes" do + assert ThemeHelpers.theme_from_param(ThemesBackend, "default") == :default + assert ThemeHelpers.theme_from_param(ThemesBackend, :colorful) == :colorful + end + + test "theme_from_param rejects unknown binary themes without interning atoms" do + unknown_theme = "psb_unknown_#{System.unique_integer([:positive])}" + + assert_raise RuntimeError, ~r/unknown theme/, fn -> + ThemeHelpers.theme_from_param(ThemesBackend, unknown_theme) + end + + assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_theme) end + end end
test/phoenix_storybook/live/story_live_test.exs+5 −1 modified@@ -137,9 +137,13 @@ defmodule PhoenixStorybook.StoryLiveTest do end test "navigate to unknown tab", %{conn: conn} do + unknown_tab = "psb_unknown_#{System.unique_integer([:positive])}" + assert_raise PhoenixStorybook.StoryTabNotFound, fn -> - get(conn, ~p"/storybook/component", tab: "unknown") + get(conn, ~p"/storybook/component", tab: unknown_tab) end + + assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_tab) end end end
Vulnerability mechanics
Root cause
"Multiple LiveView event handlers and helper functions convert attacker-controlled string parameters to BEAM atoms via String.to_atom/1 without validation, causing permanent atom table exhaustion."
Attack vector
An unauthenticated attacker sends crafted LiveView events (psb-assign, psb-toggle, playground-change, tab-navigation, psb-set-theme) containing arbitrary string values for parameters such as attribute keys, variation IDs, atom-type attribute values, tab names, or theme names. Each unique string is converted to a BEAM atom via String.to_atom/1 and permanently allocated in the atom table. By sending many distinct strings, the attacker exhausts the atom table ceiling (~1,048,576 atoms), causing the entire BEAM node to abort and all hosted applications to crash. No authentication or special privileges are required because the storybook routes are publicly accessible.
Affected code
The vulnerable code paths are in lib/phoenix_storybook/helpers/extra_assigns_helpers.ex (handle_set_variation_assign/3, handle_toggle_variation_assign/3, to_variation_id/2, to_value/4) and lib/phoenix_storybook/live/story/playground.ex (handle_event callbacks for tab-navigation, playground-change, psb-toggle, and psb-set-theme). Additional occurrences exist in lib/phoenix_storybook/live/story_live.ex (current_tab/2, current_theme/2) and lib/phoenix_storybook/live/visual_test_live.ex (current_theme/2).
What the fix does
The patch replaces all unsafe String.to_atom/1 calls with either String.to_existing_atom/1 (which raises on unknown atoms) or declared-value lookups via Enum.find on pre-defined allowed lists. In playground.ex, new helper functions tab_from_param/2 and attr_from_param/2 validate parameters against declared tabs and attribute definitions before any atom conversion. In extra_assigns_helpers.ex, to_atom_value/3 first checks the attribute's declared values list; if no values list exists, it falls back to String.to_existing_atom/1 so only previously-interned atoms are accepted. Theme parameters are resolved through ThemeHelpers.theme_from_param/2, which matches against configured theme IDs. These changes ensure that attacker-supplied strings never create new atoms in the BEAM atom table.
Preconditions
- networkAttacker must be able to send HTTP requests to the Phoenix endpoint hosting the storybook routes.
- inputAttacker must send LiveView event payloads containing arbitrary string values for parameters such as attribute keys, variation IDs, atom-type values, tab names, or theme names.
Generated on May 20, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.