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

CVE-2026-8467

CVE-2026-8467

Description

Code Injection vulnerability in phenixdigital phoenix_storybook allows unauthenticated remote code execution via unsanitized attribute value interpolation in HEEx template generation.

The psb-assign WebSocket event handler in 'Elixir.PhoenixStorybook.Story.PlaygroundPreviewLive':handle_event/3 accepts arbitrary attribute names and values from unauthenticated clients. These values are passed to 'Elixir.PhoenixStorybook.Helpers.ExtraAssignsHelpers':handle_set_variation_assign/3, which stores them verbatim. When rendering, 'Elixir.PhoenixStorybook.Rendering.ComponentRenderer':attributes_markup/1 interpolates binary attribute values directly into a HEEx template string as name="" without escaping double quotes or HEEx expression delimiters. An attacker can supply a value containing a closing quote followed by a HEEx expression block (e.g. foo" injected={EXPR} bar="), which causes EXPR to be treated as an inline Elixir expression. The resulting template is compiled via EEx.compile_string/2 and executed via Code.eval_quoted_with_env/3 with full Kernel imports and no sandbox, giving the attacker arbitrary code execution on the server.

This issue affects phoenix_storybook from 0.5.0 before 1.1.0.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Unauthenticated remote code execution via unsanitized attribute interpolation in phoenix_storybook's HEEx template rendering.

Vulnerability

A code injection vulnerability in phenixdigital/phoenix_storybook versions 0.5.0 up to but not including 1.1.0 allows unauthenticated remote code execution. The psb-assign WebSocket event handler in Elixir.PhoenixStorybook.Story.PlaygroundPreviewLive accepts arbitrary attribute names and values from unauthenticated clients [1][4]. These values are stored verbatim by ExtraAssignsHelpers.handle_set_variation_assign/3. During rendering, ComponentRenderer.attributes_markup/1 interpolates binary attribute values directly into a HEEx template string as name="" without escaping double quotes or HEEx expression delimiters [1][4]. An attacker can supply a value containing a closing quote followed by a HEEx expression block (e.g., foo" injected={EXPR} bar="), causing EXPR to be treated as an inline Elixir expression [2][3]. The resulting template is compiled via EEx.compile_string/2 and executed via Code.eval_quoted_with_env/3 with full Kernel imports and no sandbox [1][4].

Exploitation

An attacker needs only network access to the storybook endpoint; no authentication or special configuration is required [2][4]. The attacker identifies any story URL with a Playground tab, connects to the Phoenix LiveView WebSocket, joins the story's LiveView channel, and sends a psb-assign event with an attribute value that escapes the HEEx attribute context and embeds an Elixir expression (e.g., a System.cmd/2 call) [4]. The server evaluates the injected expression and returns its output in the rendered response [4].

Impact

Successful exploitation gives the attacker arbitrary code execution on the server with full Elixir Kernel access [1][2][3]. This results in complete compromise of confidentiality, integrity, and availability. The vulnerability is rated CVSS 9.5 (Critical) [2].

Mitigation

The vulnerability is fixed in phoenix_storybook version 1.1.0, released on 2026-05-20 [1][2][3][4]. The fix introduces input validation for attribute names and atoms in ExtraAssignsHelpers (commit 56ab8464) [1]. Users running affected versions should upgrade immediately. No workarounds are documented; if upgrading is not possible, the storybook endpoint should be restricted to trusted networks. This CVE is not listed in the CISA KEV catalog.

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
56ab8464d437

Fix unsafe HEEx attribute generation in playground rendering

https://github.com/phenixdigital/phoenix_storybookChristian BlavierMay 18, 2026via nvd-ref
6 files changed · +268 79
  • .credo.exs+1 1 modified
    @@ -138,7 +138,6 @@
               {Credo.Check.Warning.IoInspect, []},
               {Credo.Check.Warning.OperationOnSameValues, []},
               {Credo.Check.Warning.OperationWithConstantResult, []},
    -          {Credo.Check.Warning.RaiseInsideRescue, []},
               {Credo.Check.Warning.SpecWithStruct, []},
               {Credo.Check.Warning.WrongTestFileExtension, []},
               {Credo.Check.Warning.UnusedEnumOperation, []},
    @@ -189,6 +188,7 @@
               {Credo.Check.Warning.LeakyEnvironment, []},
               {Credo.Check.Warning.MapGetUnsafePass, []},
               {Credo.Check.Warning.MixEnv, []},
    +          {Credo.Check.Warning.RaiseInsideRescue, []},
               {Credo.Check.Warning.UnsafeToAtom, []}
     
               # {Credo.Check.Refactor.MapInto, []},
    
  • lib/phoenix_storybook/helpers/extra_assigns_helpers.ex+93 12 modified
    @@ -5,6 +5,9 @@ defmodule PhoenixStorybook.ExtraAssignsHelpers do
       alias PhoenixStorybook.Stories.{Variation, VariationGroup}
       alias PhoenixStorybook.ThemeHelpers
     
    +  @assign_attr_name_regex ~r/^[A-Za-z_:][A-Za-z0-9_:\.-]*[!?]?$/
    +  @reserved_assign_attrs ~w(__changed__ __struct__)a
    +
       def init_variation_extra_assigns(type, story) when type in [:component, :live_component] do
         extra_assigns =
           for %Variation{id: variation_id} <- story.variations(),
    @@ -48,15 +51,15 @@ defmodule PhoenixStorybook.ExtraAssignsHelpers do
     
       def handle_set_variation_assign(params, extra_assigns, story) do
         context = "assign"
    -    variation_id = to_variation_id(params, context)
    +    variation_id = to_variation_id(params, extra_assigns, context)
         params = Map.delete(params, "variation_id")
     
         variation_extra_assigns = to_variation_extra_assigns(extra_assigns, variation_id)
     
         variation_extra_assigns =
           for {attr, value} <- params, reduce: variation_extra_assigns do
             acc ->
    -          attr = String.to_atom(attr)
    +          attr = to_attr!(attr, story.attributes(), context)
               value = to_value(value, attr, story.attributes(), context)
               Map.put(acc, attr, value)
           end
    @@ -70,9 +73,9 @@ defmodule PhoenixStorybook.ExtraAssignsHelpers do
         attr =
           params
           |> Map.get_lazy("attr", fn -> raise "missing attr in #{context}" end)
    -      |> String.to_atom()
    +      |> to_attr!(story.attributes(), context)
     
    -    variation_id = to_variation_id(params, context)
    +    variation_id = to_variation_id(params, extra_assigns, context)
         variation_extra_assigns = to_variation_extra_assigns(extra_assigns, variation_id)
         current_value = Map.get(variation_extra_assigns, attr)
         check_type!(current_value, :boolean, context)
    @@ -95,24 +98,83 @@ defmodule PhoenixStorybook.ExtraAssignsHelpers do
         {variation_id, variation_extra_assigns}
       end
     
    -  defp to_variation_id(%{"variation_id" => [group_id, variation_id]}, _ctx),
    -    do: {String.to_atom(group_id), String.to_atom(variation_id)}
    +  defp to_variation_id(%{"variation_id" => [group_id, variation_id]}, extra_assigns, context) do
    +    find_variation_id!(extra_assigns, {group_id, variation_id}, context)
    +  end
    +
    +  defp to_variation_id(%{"variation_id" => variation_id}, extra_assigns, context) do
    +    find_variation_id!(extra_assigns, {:single, variation_id}, context)
    +  end
    +
    +  defp to_variation_id(_, _extra_assigns, context),
    +    do: raise("missing variation_id in #{context}")
    +
    +  defp find_variation_id!(extra_assigns, expected_id, context) when is_map(extra_assigns) do
    +    Enum.find(Map.keys(extra_assigns), &same_variation_id?(&1, expected_id)) ||
    +      raise("unknown variation_id in #{context}")
    +  end
    +
    +  defp find_variation_id!(_extra_assigns, _expected_id, context) do
    +    raise("unknown variation_id in #{context}")
    +  end
     
    -  defp to_variation_id(%{"variation_id" => variation_id}, _ctx),
    -    do: {:single, String.to_atom(variation_id)}
    +  defp same_variation_id?({group_id, variation_id}, {expected_group_id, expected_variation_id}) do
    +    to_string(group_id) == to_string(expected_group_id) &&
    +      to_string(variation_id) == to_string(expected_variation_id)
    +  end
     
    -  defp to_variation_id(_, context), do: raise("missing variation_id in #{context}")
    +  defp same_variation_id?(_variation_id, _expected_id), do: false
     
       defp to_variation_extra_assigns(extra_assigns, id = {_group_id, _variation_id}) do
    -    Map.get(extra_assigns, id)
    +    Map.fetch!(extra_assigns, id)
    +  end
    +
    +  defp to_attr!(attr, attributes, context) when is_atom(attr) do
    +    attr
    +    |> Atom.to_string()
    +    |> validate_attr_name!(context)
    +
    +    validate_attr!(attr, attributes, context)
    +  end
    +
    +  defp to_attr!(attr, attributes, context) when is_binary(attr) do
    +    validate_attr_name!(attr, context)
    +
    +    attr =
    +      declared_attr_id(attr, attributes) ||
    +        existing_attr_atom!(attr, context)
    +
    +    validate_attr!(attr, attributes, context)
    +  end
    +
    +  defp to_attr!(attr, _attributes, context) do
    +    raise(RuntimeError, "invalid attribute name in #{context}: #{inspect(attr)}")
    +  end
    +
    +  defp validate_attr_name!(attr, context) do
    +    unless Regex.match?(@assign_attr_name_regex, attr) do
    +      raise(RuntimeError, "invalid attribute name in #{context}: #{attr}")
    +    end
    +  end
    +
    +  defp validate_attr!(attr, _attributes, context) when attr in @reserved_assign_attrs do
    +    raise(RuntimeError, "invalid attribute name in #{context}: #{attr}")
    +  end
    +
    +  defp validate_attr!(attr, _attributes, _context), do: attr
    +
    +  defp existing_attr_atom!(attr, context) do
    +    String.to_existing_atom(attr)
    +  rescue
    +    ArgumentError -> raise(RuntimeError, "unknown attribute in #{context}: #{attr}")
       end
     
       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 |> String.to_atom() |> check_type!(:atom, context)
    -      :boolean -> val |> String.to_atom() |> check_type!(:boolean, context)
    +      :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
    @@ -133,6 +195,25 @@ defmodule PhoenixStorybook.ExtraAssignsHelpers do
         end
       end
     
    +  defp declared_attr_id(attr, attributes) do
    +    Enum.find_value(attributes, fn %Attr{id: id} ->
    +      if to_string(id) == attr, do: id
    +    end)
    +  end
    +
    +  defp existing_value_atom!(val, context) do
    +    String.to_existing_atom(val)
    +  rescue
    +    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
    
  • lib/phoenix_storybook/rendering/component_renderer.ex+108 64 modified
    @@ -23,95 +23,124 @@ defmodule PhoenixStorybook.Rendering.ComponentRenderer do
       end
     
       def render(fun_or_mod, context = %RenderingContext{}) do
    -    heex =
    +    {heex, attrs} =
           cond do
             TemplateHelpers.variation_template?(context.template) ->
    -          for variation = %RenderingVariation{} <- context.variations, into: "" do
    -            template_heex(
    -              fun_or_mod,
    -              {context.group_id, variation.id},
    -              context.template,
    -              variation,
    -              context.options[:playground_topic]
    -            )
    -          end
    +          context.variations
    +          |> Enum.with_index()
    +          |> Enum.map_reduce(%{}, fn {variation = %RenderingVariation{}, index}, attrs ->
    +            {heex, runtime_attrs} =
    +              template_heex(
    +                fun_or_mod,
    +                {context.group_id, variation.id},
    +                context.template,
    +                variation,
    +                index,
    +                context.options[:playground_topic]
    +              )
    +
    +            {heex, Map.put(attrs, index, runtime_attrs)}
    +          end)
    +          |> then(fn {heex, attrs} -> {Enum.join(heex, ""), attrs} end)
     
             TemplateHelpers.variation_group_template?(context.template) ->
    -          heex =
    -            for variation = %RenderingVariation{} <- context.variations, into: "" do
    +          {components_heex, attrs} =
    +            context.variations
    +            |> Enum.with_index()
    +            |> Enum.map_reduce(%{}, fn {variation = %RenderingVariation{}, index}, attrs ->
                   extra_attributes =
                     extract_placeholder_attributes(
                       context.template,
                       variation.id,
                       context.options[:playground_topic]
                     )
     
    -              component_heex(
    -                fun_or_mod,
    -                variation.attributes,
    -                variation.let,
    -                variation.slots,
    -                extra_attributes
    -              )
    -            end
    +              {heex, runtime_attrs} =
    +                component_heex(
    +                  fun_or_mod,
    +                  variation.attributes,
    +                  variation.let,
    +                  variation.slots,
    +                  index,
    +                  extra_attributes
    +                )
    +
    +              {heex, Map.put(attrs, index, runtime_attrs)}
    +            end)
    +
    +          heex =
    +            context.template
    +            |> TemplateHelpers.set_variation_dom_id(context.dom_id)
    +            |> TemplateHelpers.set_js_push_variation_id(context.group_id)
    +            |> TemplateHelpers.replace_template_variation_group(Enum.join(components_heex, ""))
     
    -          context.template
    -          |> TemplateHelpers.set_variation_dom_id(context.dom_id)
    -          |> TemplateHelpers.set_js_push_variation_id(context.group_id)
    -          |> TemplateHelpers.replace_template_variation_group(heex)
    +          {heex, attrs}
     
             true ->
    -          context.template
    -          |> TemplateHelpers.set_variation_dom_id(context.dom_id)
    -          |> TemplateHelpers.set_js_push_variation_id(context.group_id)
    +          {context.template
    +           |> TemplateHelpers.set_variation_dom_id(context.dom_id)
    +           |> TemplateHelpers.set_js_push_variation_id(context.group_id), %{}}
           end
     
    -    render_component_heex(fun_or_mod, heex, context.options)
    +    render_component_heex(fun_or_mod, heex, context.options, attrs)
       end
     
    -  defp component_heex(fun, assigns, _let, [], extra_attrs) when is_function(fun) do
    -    """
    -    <.#{function_name(fun)} #{attributes_markup(assigns)} #{extra_attrs}/>
    -    """
    +  defp component_heex(fun, assigns, _let, [], index, extra_attrs) when is_function(fun) do
    +    {attributes_markup, runtime_attrs} = attributes_markup(assigns, index)
    +
    +    {"""
    +     <.#{function_name(fun)} #{attributes_markup} #{extra_attrs}/>
    +     """, runtime_attrs}
       end
     
    -  defp component_heex(fun, assigns, let, slots, extra_attrs) when is_function(fun) do
    -    """
    -    <.#{function_name(fun)} #{let_markup(let)} #{attributes_markup(assigns)} #{extra_attrs}>
    -      #{slots}
    -    </.#{function_name(fun)}>
    -    """
    +  defp component_heex(fun, assigns, let, slots, index, extra_attrs) when is_function(fun) do
    +    {attributes_markup, runtime_attrs} = attributes_markup(assigns, index)
    +
    +    {"""
    +     <.#{function_name(fun)} #{let_markup(let)} #{attributes_markup} #{extra_attrs}>
    +       #{slots}
    +     </.#{function_name(fun)}>
    +     """, runtime_attrs}
       end
     
    -  defp component_heex(module, assigns, _let, [], extra_attrs) when is_atom(module) do
    -    """
    -    <.live_component module={#{inspect(module)}} #{attributes_markup(assigns)} #{extra_attrs}/>
    -    """
    +  defp component_heex(module, assigns, _let, [], index, extra_attrs) when is_atom(module) do
    +    {attributes_markup, runtime_attrs} = attributes_markup(assigns, index, [:module])
    +
    +    {"""
    +     <.live_component module={#{inspect(module)}} #{attributes_markup} #{extra_attrs}/>
    +     """, runtime_attrs}
       end
     
    -  defp component_heex(module, assigns, let, slots, extra_attrs) when is_atom(module) do
    -    """
    -    <.live_component module={#{inspect(module)}} #{let_markup(let)} #{attributes_markup(assigns)} #{extra_attrs}>
    -      #{slots}
    -    </.live_component>
    -    """
    +  defp component_heex(module, assigns, let, slots, index, extra_attrs) when is_atom(module) do
    +    {attributes_markup, runtime_attrs} = attributes_markup(assigns, index, [:module])
    +
    +    {"""
    +     <.live_component module={#{inspect(module)}} #{let_markup(let)} #{attributes_markup} #{extra_attrs}>
    +       #{slots}
    +     </.live_component>
    +     """, runtime_attrs}
       end
     
       defp template_heex(
              fun_or_mod,
              variation_id,
              template,
              %RenderingVariation{dom_id: dom_id, let: let, slots: slots, attributes: attributes},
    +         index,
              playground_topic
            ) do
         extra_attributes = extract_placeholder_attributes(template, variation_id, playground_topic)
     
    -    template
    -    |> TemplateHelpers.set_variation_dom_id(dom_id)
    -    |> TemplateHelpers.set_js_push_variation_id(variation_id)
    -    |> TemplateHelpers.replace_template_variation(
    -      component_heex(fun_or_mod, attributes, let, slots, extra_attributes)
    -    )
    +    {component_heex, runtime_attrs} =
    +      component_heex(fun_or_mod, attributes, let, slots, index, extra_attributes)
    +
    +    heex =
    +      template
    +      |> TemplateHelpers.set_variation_dom_id(dom_id)
    +      |> TemplateHelpers.set_js_push_variation_id(variation_id)
    +      |> TemplateHelpers.replace_template_variation(component_heex)
    +
    +    {heex, runtime_attrs}
       end
     
       defp extract_placeholder_attributes(template, _variation_id, _topic = nil) do
    @@ -125,20 +154,35 @@ defmodule PhoenixStorybook.Rendering.ComponentRenderer do
       defp let_markup(nil), do: ""
       defp let_markup(let), do: ":let={#{to_string(let)}}"
     
    -  defp attributes_markup(attributes) do
    -    Enum.map_join(attributes, " ", fn
    -      {name, {:eval, val}} ->
    +  defp attributes_markup(attributes, index, reserved_attrs \\ []) do
    +    {eval_attrs, runtime_attrs} =
    +      attributes
    +      |> Enum.reject(fn {name, _val} -> name in reserved_attrs end)
    +      |> Enum.split_with(fn
    +        {_name, {:eval, _val}} -> true
    +        _attr -> false
    +      end)
    +
    +    eval_attrs_markup =
    +      Enum.map_join(eval_attrs, " ", fn {name, {:eval, val}} ->
             ~s|#{name}={#{val}}|
    +      end)
    +
    +    markup =
    +      ["{Map.fetch!(@psb_variation_attrs, #{index})}", eval_attrs_markup]
    +      |> Enum.reject(&(&1 == ""))
    +      |> Enum.join(" ")
    +
    +    {markup, Map.new(runtime_attrs)}
    +  end
     
    -      {name, val} when is_binary(val) ->
    -        ~s|#{name}="#{val}"|
    +  defp render_component_heex(fun_or_mod, heex, opts, attrs) do
    +    assigns = %{psb_variation_attrs: attrs}
     
    -      {name, val} ->
    -        ~s|#{name}={#{inspect(val, structs: false, limit: :infinity, printable_limit: :infinity)}}|
    -    end)
    +    eval_component_heex(fun_or_mod, heex, opts, assigns)
       end
     
    -  defp render_component_heex(fun_or_mod, heex, opts) do
    +  defp eval_component_heex(fun_or_mod, heex, opts, assigns) do
         quoted_code =
           EEx.compile_string(heex,
             engine: TagEngine,
    @@ -161,7 +205,7 @@ defmodule PhoenixStorybook.Rendering.ComponentRenderer do
         {evaluated, _, _} =
           Code.eval_quoted_with_env(
             quoted_code,
    -        [assigns: %{}],
    +        [assigns: assigns],
             env
           )
     
    
  • test/phoenix_storybook/helpers/extra_assigns_helpers_test.exs+31 0 modified
    @@ -97,6 +97,14 @@ defmodule PhoenixStorybook.ExtraAssignsHelpersTest do
               story
             )
           end
    +
    +      assert_raise RuntimeError, ~r/unknown atom value in assign/, fn ->
    +        handle_set_variation_assign(
    +          %{"variation_id" => "variation_id", "atom" => "psb_unknown_atom_value"},
    +          %{{:single, :variation_id} => %{}},
    +          story
    +        )
    +      end
         end
     
         test "with nil typed attributes", %{story: story} do
    @@ -134,6 +142,29 @@ defmodule PhoenixStorybook.ExtraAssignsHelpersTest do
             handle_set_variation_assign(%{}, %{}, story)
           end
         end
    +
    +    test "rejects unknown variation ids", %{story: story} do
    +      assert_raise RuntimeError, ~r/unknown variation_id in assign/, fn ->
    +        handle_set_variation_assign(
    +          %{"variation_id" => "unknown", "attribute" => "foo"},
    +          %{{:single, :variation_id} => %{}},
    +          story
    +        )
    +      end
    +    end
    +
    +    test "rejects invalid attribute names", %{story: story} do
    +      assert_raise RuntimeError, ~r/invalid attribute name in assign/, fn ->
    +        handle_set_variation_assign(
    +          %{
    +            "variation_id" => "variation_id",
    +            ~s|attribute" injected={send(self(), :rce)} bar| => "foo"
    +          },
    +          %{{:single, :variation_id} => %{}},
    +          story
    +        )
    +      end
    +    end
       end
     
       describe "handle_toggle_variation_assign/3" do
    
  • test/phoenix_storybook/live/playground_live_test.exs+19 0 modified
    @@ -30,6 +30,25 @@ defmodule PhoenixStorybook.PlaygroundLiveTest do
           assert view |> element("#psb-playground-preview-live") |> render() =~ "component: world"
         end
     
    +    test "playground psb-assign values are not evaluated as HEEx", %{conn: conn} do
    +      {:ok, view, _html} = live(conn, "/storybook/component?tab=playground")
    +      assert [playground_preview_view] = live_children(view)
    +
    +      process_name = :"playground_rce_#{System.unique_integer([:positive])}"
    +      Process.register(self(), process_name)
    +
    +      payload =
    +        ~s|safe" injected={send(Process.whereis(#{inspect(process_name)}), :rce)} bar="|
    +
    +      render_hook(playground_preview_view, "psb-assign", %{
    +        "variation_id" => "hello",
    +        "label" => payload
    +      })
    +
    +      refute_received :rce
    +      Process.unregister(process_name)
    +    end
    +
         test "renders playground code a simple component", %{conn: conn} do
           {:ok, view, _html} = live(conn, "/storybook/component?tab=playground")
           view |> element("a", "Code") |> render_click()
    
  • test/phoenix_storybook/rendering/component_renderer_test.exs+16 2 modified
    @@ -162,6 +162,20 @@ defmodule PhoenixStorybook.Rendering.ComponentRendererTest do
           assert render_variation(component, :with_eval) |> rendered_to_string() =~ ~s|index_i: 25|
         end
     
    +    test "renders binary attributes without evaluating injected HEEx", %{component: component} do
    +      process_name = :"component_renderer_rce_#{System.unique_integer([:positive])}"
    +      Process.register(self(), process_name)
    +
    +      payload =
    +        ~s|safe" injected={send(Process.whereis(#{inspect(process_name)}), :rce)} bar="|
    +
    +      assert render_variation(component, :hello, %{hello: %{label: payload}})
    +             |> rendered_to_string() =~ "safe"
    +
    +      refute_received :rce
    +      Process.unregister(process_name)
    +    end
    +
         test "it should not crash with a very large binary in a map" do
           defmodule LargeBinaryStory do
             use PhoenixStorybook.Story, :component
    @@ -231,11 +245,11 @@ defmodule PhoenixStorybook.Rendering.ComponentRendererTest do
         end
       end
     
    -  defp render_variation(story, variation_id) do
    +  defp render_variation(story, variation_id, extra_attributes \\ %{}) do
         variation = Enum.find(story.variations(), &(&1.id == variation_id))
     
         TreeStorybook
    -    |> RenderingContext.build(story, variation, %{})
    +    |> RenderingContext.build(story, variation, extra_attributes)
         |> ComponentRenderer.render()
       end
     end
    

Vulnerability mechanics

Root cause

"Unsanitized binary attribute values are interpolated directly into a HEEx template string without escaping double quotes or HEEx expression delimiters, allowing an attacker to break out of the attribute context and inject arbitrary Elixir code."

Attack vector

An unauthenticated attacker sends a crafted `psb-assign` WebSocket event to the playground LiveView with a binary attribute value containing a closing double-quote followed by a HEEx expression block (e.g. `foo" injected={EXPR} bar="`). The `handle_set_variation_assign/3` function [patch_id=852378] previously stored this value verbatim without validation. When `attributes_markup/1` in `ComponentRenderer` interpolated the value as `name="<val>"` into a dynamically compiled HEEx template, the injected expression was treated as an inline Elixir expression. The template was compiled via `EEx.compile_string/2` and executed via `Code.eval_quoted_with_env/3` with full Kernel imports and no sandbox, giving the attacker arbitrary code execution on the server.

Affected code

The vulnerability is in `lib/phoenix_storybook/rendering/component_renderer.ex`, specifically the `attributes_markup/1` function which interpolated binary attribute values directly into a HEEx template as `name="<val>"` without escaping. The entry point is `lib/phoenix_storybook/helpers/extra_assigns_helpers.ex`, where `handle_set_variation_assign/3` accepted arbitrary attribute names and values from unauthenticated WebSocket clients and stored them verbatim. The `PlaygroundPreviewLive` LiveView in `Elixir.PhoenixStorybook.Story.PlaygroundPreviewLive` dispatches the `psb-assign` event to this handler.

What the fix does

The patch [patch_id=852378] fundamentally changes how runtime attributes are rendered. Instead of interpolating binary values directly into the HEEx template string as `name="<val>"`, the new `attributes_markup/2` function splits attributes into eval-type and runtime attributes. Runtime attributes are collected into a map and passed as a single `@psb_variation_attrs` assign, which is spread into the component render via `{Map.fetch!(@psb_variation_attrs, index)}`. This means binary values are never placed inside double-quoted attribute slots in the template string, so injected quotes and HEEx delimiters cannot break out of the attribute context. Additionally, `ExtraAssignsHelpers` now validates attribute names against a strict regex (`@assign_attr_name_regex`), rejects reserved atoms, uses `String.to_existing_atom/1` instead of `String.to_atom/1` to prevent atom table exhaustion, and validates variation IDs against the known set of variations rather than blindly converting them to atoms.

Preconditions

  • networkAttacker must be able to reach the Phoenix Storybook HTTP endpoint (typically served on the application's web port).
  • inputAttacker must send a WebSocket message with event name 'psb-assign' containing a 'variation_id' and at least one attribute name/value pair where the value contains a crafted HEEx injection payload.

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.