Skip to content

LiveView/Phoenix apps break through tunnel: WS frames dropped pre-open, Cookie stripped from WS upgrade, redirects followed internally #2

Description

@bturney

Bug report: LiveView/Phoenix apps break through tunnel (WebSocket frames dropped, session cookie stripped, redirects followed)

Summary

Using traforo to expose a Phoenix LiveView app, the LiveView socket never mounts and form login is broken. Three distinct bugs in the tunnel client (src/client.ts) combine to produce this:

  1. WebSocket frames arriving before the upstream connection opens are silently dropped
  2. The Cookie header is stripped from WebSocket upgrade requests
  3. HTTP redirects are followed internally by the tunnel's fetch, swallowing Set-Cookie and the redirect status

Environment

  • traforo 0.7.2
  • Phoenix 1.8 / LiveView 1.2 (any Phoenix LiveView app reproduces)
  • Tunneled through kimaki tunnel (traforo 0.7.2)
  • Node 24 (client-side), Bun/Chromium for the browser repro

Bug 1 — WS frames dropped during upstream connect

In handleWsOpen (src/client.ts:431), localWs is registered in this.localWsConnections only inside the 'open' callback. Any ws_frame message that arrives before that callback runs hits handleWsFrame (src/client.ts:513), which does:

const localWs = this.localWsConnections.get(msg.connId)
if (!localWs) {
  console.warn(`WS FRAME for unknown connection: ${msg.connId}`)
  return
}

and drops the frame. Phoenix LiveView sends phx_join immediately after the WebSocket opens, so that first frame is always the victim. The browser then loops WS OPEN → frame dropped → timeout → reconnect forever.

Repro (100% deterministic): raw WebSocket to wss://<tunnel>/live/websocket?vsn=2.0.0, send a frame in onopen:

  • Frame sent immediately after open → never delivered (server logs WS FRAME for unknown connection, no reply)
  • Same frame sent after a 1.5s delay → always delivered

Fix: buffer frames in handleWsFrame while the connection is pending, flush them inside the 'open' callback after registering localWs, and clear the buffer on error/close.

Bug 2 — Cookie header stripped from WS upgrade

handleWsOpen (src/client.ts:431-447) only forwards the Sec-WebSocket-Protocol subprotocol:

const subprotocol = msg.headers['sec-websocket-protocol']
const protocols = subprotocol ? subprotocol.split(',').map(p => p.trim()) : undefined
const localWs = new WebSocket(url, protocols)

The client's Cookie header is never forwarded to the upstream WebSocket. Phoenix LiveView requires the session cookie to authorize phx_join — without it the server rejects with {:error, %{reason: "stale"}}.

Repro: WS echo server that logs the request's Cookie header. Connect with Cookie: phx_auth=SECRET-COOKIE-123:

  • Direct (ws://localhost:PORT) → server sees COOKIE: phx_auth=SECRET-COOKIE-123
  • Through tunnel → server sees COOKIE: (none)

Fix: forward cookie (and ideally origin) headers through the ws client options:

const headers = {}
if (msg.headers['cookie']) headers['cookie'] = msg.headers['cookie']
const localWs = new WebSocket(url, protocols ? { protocols, headers } : { headers })

Bug 3 — HTTP redirects followed internally

handleHttpRequest (src/client.ts:335) uses fetch(url, ...) with no redirect option. Node's fetch follows 3xx redirects by default. A Phoenix login POST returns 302 Found + Set-Cookie + Location: /; the tunnel's fetch follows that redirect internally, and because the redirect chain's cookies are never applied to the follow-up request, the browser only ever sees the final 200 login page — the session cookie and the 302 never reach the client.

Repro: POST valid credentials to /operators/log-in on a Phoenix app:

  • Direct (http://localhost:4000) → 302 Found + Set-Cookie + Location: /
  • Through tunnel → 200 (final login page), no Set-Cookie, no Location

Fix: add redirect: 'manual' to the fetch options so the 3xx + headers are returned to the client as-is.

Impact

Phoenix LiveView apps (and any client that relies on immediate WS frames, WS session cookies, or 3xx redirects — Next.js auth flows, SSE endpoints, HMR) are unusable through traforo. These were found via a Phoenix LiveView app; all three repros are deterministic and scriptable.

I have a working patch for all three in the local dist/client.js and can open a PR if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions