Environment
- Elixir version (elixir -v): any
- Phoenix version (mix deps): 1.8.9
- Operating system: any
Actual behavior
A longpoll session token is signed as {:v1, endpoint_id, server_pid, priv_topic}. Neither the payload nor the salt includes the mounted path or the socket handler. Both are identical for every socket mounted on the endpoint.
A token from one longpoll mount is accepted at any other longpoll mount on the same endpoint and the resuming mount's handler is never consulted.
Repro:
- Clone https://github.com/lukaszsamson/phoenix-longpoll-mount-binding-repro
- mix deps.get
- mix phx.server
- open localhost:4000 and click "Run all steps"
The endpoint mounts two sockets, longpoll only:
socket "/public", G6ReproWeb.PublicSocket, websocket: false, longpoll: [...]
socket "/private", G6ReproWeb.PrivateSocket, websocket: false, longpoll: [...]
PublicSocket.connect/3 accepts everything. PrivateSocket.connect/3 always returns :error. Both serve the same room:* channel, which tags each pushed message with served_by naming the socket module that owns the session.
Result:
--- CONTROL: GET /private/longpoll (no token) ---
status: 403 <- PrivateSocket rejects all connects
--- 1. GET /public/longpoll (no token) ---
status: 410, token: SFMyNTY.g2gDaAR3AnYx...
--- 2. POST /public/longpoll (phx_join room:lobby) ---
status: 200
--- 3. POST /private/longpoll with the /public token (ping) ---
status: 200 <- accepted
--- 4. GET /private/longpoll with the /public token ---
event=phx_reply payload={"status":"ok","response":{}}
event=joined payload={"served_by":"PublicSocket (/public)"}
event=pong payload={"served_by":"PublicSocket (/public)"}
Another consequence of the bug is that :max_age is taken from the resuming mount. Given:
socket "/shortlived", MySocket, longpoll: [crypto: [max_age: 1]]
socket "/public", MySocket, longpoll: [] # default max_age: 1_209_600
a token that /shortlived rejects as expired is still accepted at /public.
1. mint at /shortlived -> 410 + token
2. POST /shortlived with token -> 200 (fresh)
3. sleep 1.5s (past max_age, session process still alive)
4. POST /shortlived with token -> 410 (own mount rejects it)
5. POST /public with the same token -> 200 (accepted anyway)
Note: I do not think this is a vulnerability. The token is for a session the caller already established. Replaying it at another mount returns the same session so no barriers are actually crossed. check_origin is still applied per request so no CSWSH bypass. The token is not a cookie so no CSRF / confused-deputy
Expected behavior
A session token should only be usable at the mount that issued it. The handler and the mount path should be included in the payload or salt.
If the behavior is intentional, the effect on :max_age should be documented
Environment
Actual behavior
A longpoll session token is signed as
{:v1, endpoint_id, server_pid, priv_topic}. Neither the payload nor the salt includes the mounted path or the socket handler. Both are identical for every socket mounted on the endpoint.A token from one longpoll mount is accepted at any other longpoll mount on the same endpoint and the resuming mount's handler is never consulted.
Repro:
The endpoint mounts two sockets, longpoll only:
PublicSocket.connect/3accepts everything.PrivateSocket.connect/3always returns :error. Both serve the sameroom:*channel, which tags each pushed message withserved_bynaming the socket module that owns the session.Result:
Another consequence of the bug is that
:max_ageis taken from the resuming mount. Given:a token that
/shortlivedrejects as expired is still accepted at/public.Note: I do not think this is a vulnerability. The token is for a session the caller already established. Replaying it at another mount returns the same session so no barriers are actually crossed.
check_originis still applied per request so no CSWSH bypass. The token is not a cookie so no CSRF / confused-deputyExpected behavior
A session token should only be usable at the mount that issued it. The handler and the mount path should be included in the payload or salt.
If the behavior is intentional, the effect on
:max_ageshould be documented