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(expand)+ 1 more
- (no CPE)
- (no CPE)range: >=0.4.0, <1.1.0
Patches
16ee03f1c738dFix Storybook iframe playground topic capability
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
4News mentions
0No linked articles in our index yet.