From c241b3e100e7959b171a15a858eff45bb7a98bba Mon Sep 17 00:00:00 2001 From: Serge RAKOTO HARRY-NAIVO Date: Mon, 27 Apr 2026 15:25:08 +0200 Subject: [PATCH] security(transport): use subprotocol + Authorization header for auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the bot-side hardening (serge/messenger-bot feat/webchat-auth-hardening): credentials no longer leak via URL query strings. * WebSocket handshake uses Sec-WebSocket-Protocol subprotocols (messenzy.v1, messenzy-bot., messenzy-visitor., messenzy-key.) — the browser WebSocket ctor doesn't accept custom headers, so subprotocols are the standard pattern. * HTTP fallback (/webchat/msg, /webchat/history) uses `Authorization: Bearer ` — fetch supports custom headers. * botId/visitorId stay in body/query as public identifiers; only the apiKey moves off the URL. No public API change — `createTransport(opts)` takes the same TransportOpts as before. --- src/transport/ws-client.ts | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/transport/ws-client.ts b/src/transport/ws-client.ts index 4302764..e3d5fa9 100644 --- a/src/transport/ws-client.ts +++ b/src/transport/ws-client.ts @@ -52,13 +52,19 @@ export function createTransport(opts: TransportOpts): Transport { function connectWs() { if (closed) return; - const wsUrl = - opts.serverUrl.replace(/^http/, 'ws') + - `/webchat/ws?botId=${encodeURIComponent(opts.botId)}` + - `&apiKey=${encodeURIComponent(opts.apiKey)}` + - `&visitorId=${encodeURIComponent(opts.visitorId)}`; + // The browser WebSocket constructor does NOT accept custom headers, so + // we encode credentials into the Sec-WebSocket-Protocol list (the + // standard workaround). The bot reads `req.headers['sec-websocket- + // protocol']` and parses these tokens. See bot's `webchat/subprotocol.ts`. + const wsUrl = opts.serverUrl.replace(/^http/, 'ws') + '/webchat/ws'; + const subprotocols = [ + 'messenzy.v1', + `messenzy-bot.${opts.botId}`, + `messenzy-visitor.${opts.visitorId}`, + `messenzy-key.${opts.apiKey}`, + ]; try { - ws = new WebSocket(wsUrl); + ws = new WebSocket(wsUrl, subprotocols); } catch { onWsClose(); return; @@ -118,14 +124,19 @@ export function createTransport(opts: TransportOpts): Transport { async function startPolling() { pollTimer = setInterval(async () => { if (closed) return; + // HTTP fallback uses Authorization: Bearer (browser fetch DOES allow + // custom headers, unlike the WebSocket constructor). botId/visitorId + // are public identifiers and stay in the query string. const url = `${opts.serverUrl}/webchat/history?` + `botId=${encodeURIComponent(opts.botId)}` + - `&apiKey=${encodeURIComponent(opts.apiKey)}` + `&visitorId=${encodeURIComponent(opts.visitorId)}` + (lastSeenAt ? `&since=${encodeURIComponent(lastSeenAt)}` : ''); try { - const res = await fetch(url, { credentials: 'omit' }); + const res = await fetch(url, { + credentials: 'omit', + headers: { authorization: `Bearer ${opts.apiKey}` }, + }); if (!res.ok) return; const data = (await res.json()) as { messages?: Array<{ id: string; text: string; at: string }>; @@ -153,10 +164,12 @@ export function createTransport(opts: TransportOpts): Transport { try { await fetch(`${opts.serverUrl}/webchat/msg`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${opts.apiKey}`, + }, body: JSON.stringify({ botId: opts.botId, - apiKey: opts.apiKey, visitorId: opts.visitorId, text: msg.text, idempotency_key: msg.id,