security(transport): use subprotocol + Authorization header for auth
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.<id>, messenzy-visitor.<id>,
messenzy-key.<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 <apiKey>` — 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.
This commit is contained in:
parent
c7edf8c66c
commit
c241b3e100
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user