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

CVE-2026-47068

CVE-2026-47068

Description

Authorization Bypass Through User-Controlled Key vulnerability in phenixdigital phoenix_storybook allows cross-session PubSub topic injection via a URL query parameter.

'Elixir.PhoenixStorybook.Story.ComponentIframeLive':handle_params/3 in lib/phoenix_storybook/live/story/component_iframe_live.ex reads a PubSub topic directly from params["topic"] and broadcasts {:component_iframe_pid, self()} on it with no check that the topic belongs to the requesting session. The shared PhoenixStorybook.PubSub is used to coordinate playground LiveViews with their iframes: a playground subscribes to a session-specific topic and uses the received iframe pid to direct subsequent control messages (variation state, theme switches, extra-assign payloads) via send/2. Because the iframe trusts the query parameter, an attacker who loads /storybook/iframe/?topic=<victim_topic> causes their iframe process pid to be announced on the victim's topic. The victim's playground then addresses its private messages to the attacker's iframe process.

This issue affects phoenix_storybook from 0.4.0 before 1.1.0.

AI Insight

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

A low-severity cross-session PubSub topic injection in phoenix_storybook <1.1.0 lets an unauthenticated attacker hijack playground-iframe handshakes by manipulating a URL query parameter.

Vulnerability

Elixir.PhoenixStorybook.Story.ComponentIframeLive.handle_params/3 in lib/phoenix_storybook/live/story/component_iframe_live.ex reads a PubSub topic directly from params["topic"] and broadcasts {:component_iframe_pid, self()} on it with no check that the topic belongs to the requesting session [1][2]. The shared PhoenixStorybook.PubSub coordinates playground LiveViews with their iframes: a playground subscribes to a session-specific topic and uses the received iframe pid to direct subsequent control messages (variation state, theme switches, extra-assign payloads) via send/2. Affected versions are 0.4.0 up to but not including 1.1.0 [2].

Exploitation

An attacker only needs network access to the application and does not require authentication. To exploit the vulnerability, the attacker loads /storybook/iframe/?topic=<victim_topic> in their own browser. This causes the attacker's iframe process pid to be announced on the victim's private topic. The victim's playground then addresses its private messages to the attacker's iframe process, where they arrive in handle_info/2 [1]. No user interaction from the victim is required beyond them having an active playground session using a predictable or guessable topic.

Impact

A successful attack allows an unauthenticated attacker to intercept control messages intended for a victim's playground session. This results in a low-severity information disclosure (the attacker can receive variation state, theme switches, and extra-assign payloads) and a low-severity integrity impact (the attacker's iframe receives and processes the messages). The CVSS v4.0 score is 2.3 (Low) [2][4].

Mitigation

The fix is implemented in commit 6ee03f1c738d4436dde1b066cf65c80663d489f5 and released in version 1.1.0 [3]. The patch replaces the direct use of params["topic"] with a server-side verified token: the playground passes a signed token via params["playground_token"], which ComponentIframeLive validates using Phoenix.Token.verify/4 with a maximum age of 86400 seconds. If the token is invalid or absent, no broadcast occurs [3]. Users must upgrade to phoenix_storybook >= 1.1.0. No workaround is available for versions before 1.1.0.

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
6ee03f1c738d

Fix Storybook iframe playground topic capability

https://github.com/phenixdigital/phoenix_storybookChristian BlavierMay 20, 2026via nvd-ref
5 files changed · +71 6
  • lib/phoenix_storybook/live/story/component_iframe_live.ex+19 3 modified
    @@ -11,17 +11,22 @@ defmodule PhoenixStorybook.Story.ComponentIframeLive do
       alias PhoenixStorybook.StoryNotFound
       alias PhoenixStorybook.ThemeHelpers
     
    +  @playground_topic_salt "phoenix_storybook:playground_topic"
    +  @playground_token_max_age 86_400
    +
       def mount(_params, _session, socket) do
         {:ok, assign(socket, []), layout: {PhoenixStorybook.LayoutView, :live_iframe}}
       end
     
       def handle_params(params = %{"story" => story_path}, _uri, socket) do
         case load_story(socket, story_path) do
           {:ok, story} ->
    -        if params["topic"] do
    +        topic = verified_playground_topic(socket, params["playground_token"])
    +
    +        if topic do
               PubSub.broadcast!(
                 PhoenixStorybook.PubSub,
    -            params["topic"],
    +            topic,
                 {:component_iframe_pid, self()}
               )
             end
    @@ -36,7 +41,7 @@ defmodule PhoenixStorybook.Story.ComponentIframeLive do
                story: story,
                variation_id: params["variation_id"],
                variation: current_variation(story.storybook_type(), story, params),
    -           topic: params["topic"],
    +           topic: topic,
                theme: params["theme"],
                color_mode: params["color_mode"]
              )
    @@ -51,6 +56,17 @@ defmodule PhoenixStorybook.Story.ComponentIframeLive do
         end
       end
     
    +  defp verified_playground_topic(_socket, nil), do: nil
    +
    +  defp verified_playground_topic(socket, token) do
    +    case Phoenix.Token.verify(socket.endpoint, @playground_topic_salt, token,
    +           max_age: @playground_token_max_age
    +         ) do
    +      {:ok, %{"topic" => topic}} when is_binary(topic) -> topic
    +      _other -> nil
    +    end
    +  end
    +
       defp load_story(socket, story_param) do
         story_path = Path.join(story_param)
         socket.assigns.backend_module.load_story(story_path)
    
  • lib/phoenix_storybook/live/story_live.ex+8 0 modified
    @@ -24,8 +24,14 @@ defmodule PhoenixStorybook.StoryLive do
     
       import PhoenixStorybook.NavigationHelpers
     
    +  @playground_topic_salt "phoenix_storybook:playground_topic"
    +
       def mount(_params, _session, socket) do
         playground_topic = "playground-#{inspect(self())}"
    +
    +    playground_token =
    +      Phoenix.Token.sign(socket.endpoint, @playground_topic_salt, %{"topic" => playground_topic})
    +
         event_logs_topic = "event_logs:#{inspect(self())}"
     
         if connected?(socket) do
    @@ -41,6 +47,7 @@ defmodule PhoenixStorybook.StoryLive do
            playground_error: nil,
            playground_preview_pid: nil,
            playground_topic: playground_topic,
    +       playground_token: playground_token,
            fa_plan: backend_module.config(:font_awesome_plan, :free),
            selected_color_mode: get_selected_color_mode(socket),
            color_mode: get_color_mode(socket)
    @@ -429,6 +436,7 @@ defmodule PhoenixStorybook.StoryLive do
           theme={@theme}
           color_mode={@color_mode}
           topic={@playground_topic}
    +      playground_token={@playground_token}
           fa_plan={@fa_plan}
           root_path={@root_path}
         />
    
  • lib/phoenix_storybook/live/story/playground.ex+1 1 modified
    @@ -316,7 +316,7 @@ defmodule PhoenixStorybook.Story.Playground do
                     theme: to_string(@theme),
                     color_mode: to_string(@color_mode),
                     playground: true,
    -                topic: @topic
    +                playground_token: @playground_token
                   )
                 }
                 height="128"
    
  • test/phoenix_storybook/live/component_iframe_live_test.exs+29 1 modified
    @@ -8,6 +8,7 @@ defmodule PhoenixStorybook.ComponentIframeLiveTest do
       alias PhoenixStorybook.Story.ComponentIframeLive
     
       @endpoint PhoenixStorybook.ComponentIframeLiveEndpoint
    +  @playground_topic_salt "phoenix_storybook:playground_topic"
       @moduletag :capture_log
     
       defmodule BackendNoTheme do
    @@ -194,7 +195,7 @@ defmodule PhoenixStorybook.ComponentIframeLiveTest do
         end
       end
     
    -  test "broadcasts iframe pid when topic is provided", %{conn: conn} do
    +  test "does not broadcast iframe pid when raw topic is provided", %{conn: conn} do
         topic = "psb-component-iframe-#{System.unique_integer([:positive])}"
         Phoenix.PubSub.subscribe(PhoenixStorybook.PubSub, topic)
     
    @@ -204,10 +205,37 @@ defmodule PhoenixStorybook.ComponentIframeLiveTest do
             "topic" => topic
           })
     
    +    refute_receive {:component_iframe_pid, _pid}, 50
    +  end
    +
    +  test "broadcasts iframe pid when valid playground token is provided", %{conn: conn} do
    +    topic = "psb-component-iframe-#{System.unique_integer([:positive])}"
    +    token = Phoenix.Token.sign(@endpoint, @playground_topic_salt, %{"topic" => topic})
    +    Phoenix.PubSub.subscribe(PhoenixStorybook.PubSub, topic)
    +
    +    {:ok, _view, _html} =
    +      live_with_params(conn, "/storybook/iframe/component", %{
    +        "variation_id" => "hello",
    +        "playground_token" => token
    +      })
    +
         assert_receive {:component_iframe_pid, pid}
         assert is_pid(pid)
       end
     
    +  test "does not broadcast iframe pid when playground token is invalid", %{conn: conn} do
    +    topic = "psb-component-iframe-#{System.unique_integer([:positive])}"
    +    Phoenix.PubSub.subscribe(PhoenixStorybook.PubSub, topic)
    +
    +    {:ok, _view, _html} =
    +      live_with_params(conn, "/storybook/iframe/component", %{
    +        "variation_id" => "hello",
    +        "playground_token" => "bad-token"
    +      })
    +
    +    refute_receive {:component_iframe_pid, _pid}, 50
    +  end
    +
       test "handle_params raises StoryNotFound for not_found backend" do
         socket = base_socket(BackendNotFound)
     
    
  • test/phoenix_storybook/live/playground_live_test.exs+14 1 modified
    @@ -130,8 +130,21 @@ defmodule PhoenixStorybook.PlaygroundLiveTest do
     
       describe "component in an iframe" do
         test "renders the playground preview iframe", %{conn: conn} do
    -      {:ok, _view, html} = live(conn, "/storybook/live_component?tab=playground")
    +      {:ok, view, html} = live(conn, "/storybook/live_component?tab=playground")
           assert html =~ ~S|<iframe id="tree_storybook_live_component-playground-preview"|
    +
    +      [src] =
    +        view
    +        |> element("#tree_storybook_live_component-playground-preview")
    +        |> render()
    +        |> LazyHTML.from_fragment()
    +        |> LazyHTML.attribute("src")
    +
    +      query = src |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query()
    +
    +      assert Map.has_key?(query, "playground_token")
    +      refute Map.has_key?(query, "topic")
    +      refute src =~ "topic=playground-"
         end
       end
     
    

Vulnerability mechanics

Root cause

"The iframe LiveView trusts a user-supplied `topic` query parameter without verifying it belongs to the requesting session, allowing an attacker to inject a PubSub topic belonging to a different user's playground."

Attack vector

An attacker crafts a URL to the storybook iframe endpoint, e.g. `/storybook/iframe/<story>?topic=<victim_topic>`, and lures a victim whose playground is already subscribed to that topic. When the attacker's browser loads this URL, `ComponentIframeLive` broadcasts `{:component_iframe_pid, self()}` on the victim's topic [patch_id=852379]. The victim's playground process receives the attacker's iframe pid and subsequently sends private control messages (variation state, theme, extra-assign payloads) to the attacker's iframe process via `send/2`. No authentication or session binding is required beyond the victim visiting a storybook page with an active playground.

Affected code

The vulnerability is in `lib/phoenix_storybook/live/story/component_iframe_live.ex` in the `handle_params/3` function, which reads `params["topic"]` directly and broadcasts on that topic without verification. The fix also modifies `lib/phoenix_storybook/live/story_live.ex` to generate a signed token, and `lib/phoenix_storybook/live/story/playground.ex` to pass `playground_token` instead of `topic` in the iframe URL.

What the fix does

The patch replaces the raw `params["topic"]` parameter with a signed `playground_token` [patch_id=852379]. In `StoryLive.mount/2`, a `playground_token` is generated via `Phoenix.Token.sign/3` using a salt and the session-specific topic. This token is passed to the iframe through the `playground` component's URL. In `ComponentIframeLive.handle_params/3`, the new `verified_playground_topic/2` function calls `Phoenix.Token.verify/4` with a 24-hour max age, returning the topic only if the token is valid. A raw `?topic=` parameter is ignored, and an invalid or missing token results in `nil`, preventing any broadcast. The test suite confirms that raw topics are rejected, valid tokens succeed, and invalid tokens are refused.

Preconditions

  • networkAttacker must be able to send HTTP requests to the Phoenix Storybook endpoint.
  • inputAttacker must know or guess a victim's playground PubSub topic (e.g. 'playground-').
  • authNo authentication required; the victim must have an active playground session on the same PubSub.

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.