Skip to content

fix(server): resolve session names via Registry to prevent atom-exhaustion DoS - #188

Merged
zoedsoupe merged 3 commits into
zoedsoupe:mainfrom
neilberkman:fix/session-name-atom-exhaustion
Jul 15, 2026
Merged

zoedsoupe merged 3 commits into
zoedsoupe:mainfrom
neilberkman:fix/session-name-atom-exhaustion

Conversation

@neilberkman

@neilberkman neilberkman commented Jun 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Anubis.Server.Registry.resolve_session_name/3 named each session process by interpolating the session id into an atom. For HTTP transports the session id comes from the client-supplied mcp-session-id header, and atoms are never garbage collected, so a client sending many distinct session ids grows the global atom table without bound until the BEAM hits its atom limit (default ~1,048,576) and the node crashes with system_limit. That is a remote denial-of-service reachable in a default production deployment.

This PR routes the default session naming through an Elixir Registry (:via tuple keyed by the session-id string) so the client-supplied id stays ordinary term data and no atom is minted per session.

The vulnerability

Two code paths built a fresh atom per session id:

  • The fallback in resolve_session_name/3, used when a registry adapter does not implement the optional session_name/2 callback (lib/anubis/server/registry.ex):

    defp session_name_from_registry_name(registry_name, session_id) when is_atom(registry_name) do
      :"#{registry_name}.session.#{session_id}"
    end
  • The shipped Registry.Local adapter (lib/anubis/server/registry/local.ex), the default for HTTP transports, which implemented session_name/2 the same way:

    def session_name(registry_name, session_id), do: :"#{registry_name}.session.#{session_id}"

Registry.PG does not implement session_name/2, so it hits the atom-minting fallback. Both shipped HTTP-capable registries are therefore affected — no custom adapter required. STDIO is unaffected because it uses a single, fixed "stdio" session id.

The name is used only as the name: passed to start_session/2; after start, lookups go through the adapter's lookup_session/2 (ETS or :pg) by pid. So the atom only ever served as the process registration name, which makes it safe to replace with a :via name.

The fix

resolve_session_name/3 now returns:

{:via, Registry, {naming_registry_name(registry_name), session_id}}
  • A per-server Elixir Registry (keys: :unique) is started in the HTTP supervision tree (build_http_children/8). Its name is derived from the compile-time bounded registry_name via the new Registry.naming_registry_name/1, so it does not itself mint per-session atoms.
  • The client-supplied session_id is the registry key (binary term data), never converted to an atom.
  • :via tuples are drop-in replacements for atom names in GenServer.start_link/3 / start_session/2, and they preserve the existing {:error, {:already_started, pid}} semantics that plug.ex and sse.ex rely on for concurrent requests to the same session.
  • The optional session_name/2 callback is kept for adapters that want their own :via naming (e.g. Horde). Only the shipped atom-minting Registry.Local.session_name/2 is removed, so it falls through to the safe default.

Internal, compile-time bounded names (transports, supervisors, task stores) keep their atom naming — they are not influenced by client input. The public Registry.session_name/2 helper still builds an atom and is now documented as test-only / trusted-id-only.

Changes

  • lib/anubis/server/registry.ex — resolve_session_name/3 fallback now returns a :via Registry tuple; added naming_registry_name/1; documented why session names must not be atoms and marked session_name/2 as trusted-id-only.
  • lib/anubis/server/supervisor.ex — start the per-server naming Registry in build_http_children/8.
  • lib/anubis/server/registry/local.ex — removed the atom-minting session_name/2; uses the safe default.
  • test/anubis/server/registry_test.exs — regression test asserting :erlang.system_info(:atom_count) stays stable across 50,000 distinct session ids, plus round-trip tests that a process is reachable by its resolved :via name and that duplicate starts return {:already_started, pid}.
  • test/anubis/server/transport/streamable_http/plug_test.exs — start the naming Registry in the session-handling setup (mirrors production wiring).

Verification

mix test           # registry/transport/session suites: 63 tests, 0 failures
mix format --check-formatted
mix credo          # no issues on changed files

The regression test fails against the previous atom-based naming and passes with the fix.

@coderabbitai

coderabbitai Bot commented Jun 14, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Problem

Session processes were previously named by converting client-controlled mcp-session-id values into atoms, which are never garbage collected. A client sending many distinct session IDs could exhaust the BEAM atom table and crash with system_limit (remote DoS), affecting the HTTP-capable registries (Registry.Local and the Registry.PG fallback path).

Solution

Changed session process naming to avoid atom creation by using :via tuples backed by a per-server Elixir.Registry started under the HTTP supervision tree. Session processes are now registered as {:via, Elixir.Registry, {naming_registry_name(server_registry), session_id}}, where session_id remains ordinary binary data from the client.

Rationale / Impact

This preserves deterministic, bounded registry naming for internal infrastructure while ensuring untrusted session IDs never get converted into atoms, eliminating the atom-table exhaustion vector. The atom-based adapter naming hook is retained only for custom :via needs, and the atom-minting Registry.Local.session_name/2 implementation is removed. A regression test validates bounded atom growth across 50,000 distinct session IDs, and the updated transport test wiring ensures the naming registry is started where needed.

Walkthrough

Anubis.Server.Registry replaces its session-process naming strategy: instead of building atoms from client-supplied session IDs (e.g., :"Anubis.session.<id>"), it now derives a per-server Elixir Registry name via naming_registry_name/1 and returns {:via, Elixir.Registry, {registry_name, session_id}} tuples. The existing session_name/2 is retained with added atom-minting warnings. Registry.Local loses its session_name/2 helper. Anubis.Server.Supervisor starts this Elixir.Registry as an additional child under HTTP-style transports. Tests cover :via format correctness, process lookup, duplicate-start detection, atom-count stability over 50,000 session IDs, and naming_registry_name/1 determinism.

Possibly related PRs

  • zoedsoupe/anubis-mcp#133: Directly modifies Anubis.Server.Registry session naming — session_name/2 and resolve_session_name/3 — to route through :via tuples, the same core mechanism changed here.
  • zoedsoupe/anubis-mcp#96: Earlier session-centric registry refactor establishing the resolve_session_name/3 pattern that this PR secures against atom exhaustion via Elixir.Registry.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically identifies the main vulnerability (atom-exhaustion DoS) and the solution (Registry-based session naming), accurately reflecting the core change.
Description check ✅ Passed The description comprehensively covers the problem (atom exhaustion DoS via client-supplied session IDs), solution (Registry via tuple), rationale (safety, no atom minting), and detailed implementation changes across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/anubis/server/registry.ex (1)

34-40: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

P2: Update stale session_name/2 callback docs (current default is no longer atom-based)

Line 36 says “default returns a plain atom,” but current code returns :via tuples (Lines 58 and 92). Line 38’s no-op guidance is also too broad now.

✏️ Suggested doc patch
-  Returns the GenServer name for a session. Override this to return a `:via` tuple
-  (e.g. `{:via, Horde.Registry, {name, session_id}}`) when using a distributed registry
-  that auto-registers processes on `start_link`. The default returns a plain atom.
-
-  When a `:via` tuple is returned, `register_session/3` should be a no-op since
-  registration happens automatically on process start.
+  Returns the GenServer name for a session. Override this to return an adapter-specific
+  name (for example a distributed `:via` tuple) when your registry auto-registers
+  processes on `start_link`.
+
+  The default resolves to a `:via` tuple through
+  `Anubis.Server.Session.NameRegistry`. Adapters remain responsible for
+  `register_session/3` and `lookup_session/2` unless their own registry layer
+  fully covers that contract.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ad48e64e-4225-4516-b2b8-e9dc4a17ff75

📥 Commits

Reviewing files that changed from the base of the PR and between 2ed6187 and 4413dce.

📒 Files selected for processing (6)
  • lib/anubis/application.ex
  • lib/anubis/server/registry.ex
  • lib/anubis/server/registry/local.ex
  • lib/anubis/server/session/name_registry.ex
  • test/anubis/server/registry/session_name_test.exs
  • test/anubis/server/transport/streamable_http/plug_test.exs
💤 Files with no reviewable changes (1)
  • lib/anubis/server/registry/local.ex

Comment thread test/anubis/server/registry/session_name_test.exs Outdated
`Anubis.Server.Registry.resolve_session_name/3` fell back to
`session_name_from_registry_name/2`, which built a fresh atom per
session via `:"#{registry_name}.session.#{session_id}"`. Session ids
come from the client-controlled `mcp-session-id` header and atoms are
never garbage collected, so a client sending many distinct session ids
could grow the atom table without bound and crash the VM (default limit
1,048,576 atoms).

The shipped HTTP adapters hit this path: `Registry.Local` implemented
`session_name/2` with the same dynamic-atom construction, and
`Registry.PG` does not implement the optional callback at all, so it
took the dynamic-atom fallback. Only STDIO (single, fixed "stdio"
session id) was unaffected.

Route the default session naming through an Elixir `Registry` (a `:via`
tuple keyed by the session-id string) instead of minting atoms. The
naming `Registry` is started per server in the HTTP supervision tree and
its name is derived from the compile-time bounded `registry_name`, so no
new atoms are created per session. `{:via, Registry, ...}` also
preserves the existing `{:error, {:already_started, pid}}` semantics
that the transports rely on for concurrent requests to the same session.

- Add `Registry.naming_registry_name/1` and start the naming `Registry`
  in `build_http_children/8`.
- Drop `Registry.Local.session_name/2` so it uses the safe default.
- Document `Registry.session_name/2` as test-only (still atom-based).
- Add a regression test asserting `:erlang.system_info(:atom_count)`
  stays stable across 50,000 distinct session ids.
@neilberkman
neilberkman force-pushed the fix/session-name-atom-exhaustion branch from 4413dce to 223fb63 Compare June 14, 2026 22:51
@neilberkman neilberkman changed the title fix(server): resolve session names without minting atoms (atom-exhaustion DoS) fix(server): resolve session names via Registry to prevent atom-exhaustion DoS Jun 14, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7e4ee212-c7f4-4b7b-9819-f4424f3f21a6

📥 Commits

Reviewing files that changed from the base of the PR and between 4413dce and 223fb63.

📒 Files selected for processing (5)
  • lib/anubis/server/registry.ex
  • lib/anubis/server/registry/local.ex
  • lib/anubis/server/supervisor.ex
  • test/anubis/server/registry_test.exs
  • test/anubis/server/transport/streamable_http/plug_test.exs
💤 Files with no reviewable changes (1)
  • lib/anubis/server/registry/local.ex

Comment thread test/anubis/server/registry_test.exs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/anubis/server/registry_test.exs (1)

60-60: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

P3: Test name says concurrent, but the flow is sequential 😄

Line 60’s title implies a race/concurrency assertion, but the body does a straightforward duplicate start check. Rename the test to match behavior so failures are easier to interpret.

Suggested diff
-    test "concurrent starts for the same session id yield {:already_started, pid}", ctx do
+    test "second start for same session id yields {:already_started, pid}", ctx do

As per coding guidelines, test/**/*.exs should use descriptive test block names.

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: be52e2a5-4cfb-4f6d-9e7b-3b174073eadf

📥 Commits

Reviewing files that changed from the base of the PR and between 223fb63 and d18b2dd.

📒 Files selected for processing (1)
  • test/anubis/server/registry_test.exs

@zoedsoupe
zoedsoupe merged commit 17e4a6d into zoedsoupe:main Jul 15, 2026
9 checks passed
@zoedsoupe zoedsoupe mentioned this pull request Jul 15, 2026
zoedsoupe added a commit that referenced this pull request Jul 16, 2026
🚀 Want to release this?
---


##
[1.7.0](v1.6.2...v1.7.0)
(2026-07-16)


### Features

* **streamable_http:** per-subscriber metadata and targeted sends
([#218](#218))
([c606658](c606658))


### Bug Fixes

* Forward configured :headers on the DELETE session-teardown request
(follow-up to
[#180](#180))
([#213](#213))
([b32134a](b32134a))
* prevent "Server not initialized" race on first request
([#198](#198))
([e84624c](e84624c))
* **server:** resolve session names via Registry to prevent
atom-exhaustion DoS
([#188](#188))
([17e4a6d](17e4a6d))
* **session:** trap_exit so terminate/2 runs on supervisor shutdown
([#209](#209))
([6335cf4](6335cf4))
* **streamable_http:** don't close superseded SSE handler to prevent
reconnect flap
([#215](#215))
([a1e0ce6](a1e0ce6))


### Continuous Integration

* add new elixir versions
([3b636a8](3b636a8))
* add pr-quality workflow
([c0ca08f](c0ca08f))
* fix zig correct version for burrito
([6e410bd](6e410bd))
* use mlugg/setup-zig 0.15.2 in release-please auto build job
([2ed6187](2ed6187))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
zoedsoupe added a commit that referenced this pull request Jul 16, 2026
…stion DoS (#188)

## Summary

`Anubis.Server.Registry.resolve_session_name/3` named each session
process by interpolating the session id into an atom. For HTTP
transports the session id comes from the client-supplied
`mcp-session-id` header, and atoms are never garbage collected, so a
client sending many distinct session ids grows the global atom table
without bound until the BEAM hits its atom limit (default ~1,048,576)
and the node crashes with `system_limit`. That is a remote
denial-of-service reachable in a default production deployment.

This PR routes the default session naming through an Elixir `Registry`
(`:via` tuple keyed by the session-id string) so the client-supplied id
stays ordinary term data and no atom is minted per session.

## The vulnerability

Two code paths built a fresh atom per session id:

- The fallback in `resolve_session_name/3`, used when a registry adapter
does not implement the optional `session_name/2` callback
(`lib/anubis/server/registry.ex`):

  ```elixir
defp session_name_from_registry_name(registry_name, session_id) when
is_atom(registry_name) do
    :"#{registry_name}.session.#{session_id}"
  end
  ```

- The shipped `Registry.Local` adapter
(`lib/anubis/server/registry/local.ex`), the default for HTTP
transports, which implemented `session_name/2` the same way:

  ```elixir
def session_name(registry_name, session_id), do:
:"#{registry_name}.session.#{session_id}"
  ```

`Registry.PG` does not implement `session_name/2`, so it hits the
atom-minting fallback. Both shipped HTTP-capable registries are
therefore affected — no custom adapter required. STDIO is unaffected
because it uses a single, fixed `"stdio"` session id.

The name is used only as the `name:` passed to `start_session/2`; after
start, lookups go through the adapter's `lookup_session/2` (ETS or
`:pg`) by pid. So the atom only ever served as the process registration
name, which makes it safe to replace with a `:via` name.

## The fix

`resolve_session_name/3` now returns:

```elixir
{:via, Registry, {naming_registry_name(registry_name), session_id}}
```

- A per-server Elixir `Registry` (`keys: :unique`) is started in the
HTTP supervision tree (`build_http_children/8`). Its name is derived
from the compile-time bounded `registry_name` via the new
`Registry.naming_registry_name/1`, so it does not itself mint
per-session atoms.
- The client-supplied `session_id` is the registry key (binary term
data), never converted to an atom.
- `:via` tuples are drop-in replacements for atom names in
`GenServer.start_link/3` / `start_session/2`, and they preserve the
existing `{:error, {:already_started, pid}}` semantics that `plug.ex`
and `sse.ex` rely on for concurrent requests to the same session.
- The optional `session_name/2` callback is kept for adapters that want
their own `:via` naming (e.g. Horde). Only the shipped atom-minting
`Registry.Local.session_name/2` is removed, so it falls through to the
safe default.

Internal, compile-time bounded names (transports, supervisors, task
stores) keep their atom naming — they are not influenced by client
input. The public `Registry.session_name/2` helper still builds an atom
and is now documented as test-only / trusted-id-only.

## Changes

- `lib/anubis/server/registry.ex` — `resolve_session_name/3` fallback
now returns a `:via` `Registry` tuple; added `naming_registry_name/1`;
documented why session names must not be atoms and marked
`session_name/2` as trusted-id-only.
- `lib/anubis/server/supervisor.ex` — start the per-server naming
`Registry` in `build_http_children/8`.
- `lib/anubis/server/registry/local.ex` — removed the atom-minting
`session_name/2`; uses the safe default.
- `test/anubis/server/registry_test.exs` — regression test asserting
`:erlang.system_info(:atom_count)` stays stable across 50,000 distinct
session ids, plus round-trip tests that a process is reachable by its
resolved `:via` name and that duplicate starts return
`{:already_started, pid}`.
- `test/anubis/server/transport/streamable_http/plug_test.exs` — start
the naming `Registry` in the session-handling setup (mirrors production
wiring).

## Verification

```
mix test           # registry/transport/session suites: 63 tests, 0 failures
mix format --check-formatted
mix credo          # no issues on changed files
```

The regression test fails against the previous atom-based naming and
passes with the fix.

---------

Co-authored-by: zoey <zoey.spessanha@zeetech.io>
zoedsoupe added a commit that referenced this pull request Jul 16, 2026
🚀 Want to release this?
---


##
[1.7.0](v1.6.2...v1.7.0)
(2026-07-16)


### Features

* **streamable_http:** per-subscriber metadata and targeted sends
([#218](#218))
([d6cf7b1](d6cf7b1))


### Bug Fixes

* Forward configured :headers on the DELETE session-teardown request
(follow-up to
[#180](#180))
([#213](#213))
([4edd2c0](4edd2c0))
* prevent "Server not initialized" race on first request
([#198](#198))
([6bb60f9](6bb60f9))
* **server:** resolve session names via Registry to prevent
atom-exhaustion DoS
([#188](#188))
([fdbc238](fdbc238))
* **session:** trap_exit so terminate/2 runs on supervisor shutdown
([#209](#209))
([a224c00](a224c00))
* **streamable_http:** don't close superseded SSE handler to prevent
reconnect flap
([#215](#215))
([e1cc4a8](e1cc4a8))


### Continuous Integration

* add new elixir versions
([26dd267](26dd267))
* add pr-quality workflow
([d67eaa9](d67eaa9))
* fix zig correct version for burrito
([afec768](afec768))
* use mlugg/setup-zig 0.15.2 in release-please auto build job
([428cace](428cace))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
This was referenced Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants