VYPR
High severityNVD Advisory· Published May 20, 2026· Updated May 20, 2026

CVE-2026-8469

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

Patches

1
96d524690af0

Fix atom exhaustion from playground LiveView params

https://github.com/phenixdigital/phoenix_storybookChristian BlavierMay 19, 2026via nvd-ref
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

4

News mentions

0

No linked articles in our index yet.