fix(security): stream tickets instead of session tokens in URLs (FLAW-22)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s

WebSocket and EventSource put the 30-day session token into the URL,
where proxies log it. The app now gets a one-time ticket right before
connecting (POST /auth/stream-ticket, 60 s, bound to the token version);
?token= is no longer accepted for streams, non-browser clients may use
the Authorization header.

With BASE_URL set, CORS allows only that origin and WebSocket upgrades
only it or the requested host; credentials are off (no cookies exist).
Logout, ending all sessions, a new password and deactivation close all
live streams of the account; clients with a valid session reconnect
with a fresh ticket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 14:46:58 +01:00
1 parent 5c711c2cf7
commit f57a9884d1
17 files changed
+335 -41

No files matched your search

+53 -15
View File
@@ -1209,19 +1209,28 @@ let isRealtimeConnectedState = false;
let manualDisconnect = false;
let wsReconnectDelay = 2000;
export function buildWebSocketURL(listId?: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getToken() || '';
if (listId) {
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`;
// WebSocket and EventSource cannot send headers, so instead of the session token (which would land in
// proxy logs) every connection gets a one-time ticket, valid for a minute (POST /auth/stream-ticket).
async function fetchStreamTicket(): Promise<string | null> {
try {
const res = await request<{ ticket?: string }>('/api/v1/auth/stream-ticket', { method: 'POST' });
return res?.ticket || null;
} catch {
return null;
}
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
}
export function buildSSEURL(listId?: string, docId?: string, rooms?: string[]): string {
const token = getToken() || '';
export function buildWebSocketURL(ticket: string, listId?: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
if (listId) {
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?ticket=${encodeURIComponent(ticket)}`;
}
return `${protocol}//${window.location.host}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
}
export function buildSSEURL(ticket: string, listId?: string, docId?: string, rooms?: string[]): string {
const params = new URLSearchParams();
if (token) params.set('token', token);
params.set('ticket', ticket);
if (listId) params.set('listId', listId);
if (docId) params.set('docId', docId);
if (rooms && rooms.length > 0) params.set('rooms', rooms.join(','));
@@ -1309,7 +1318,9 @@ function switchToSSE(reason: string) {
connectSSE();
}
export function connectSSE(rooms?: string[]): void {
let sseTicketPending = false;
export async function connectSSE(rooms?: string[]): Promise<void> {
if (rooms) {
rooms.forEach((r) => globalSubscribedRooms.add(r));
}
@@ -1328,8 +1339,20 @@ export function connectSSE(rooms?: string[]): void {
activeGlobalSSE = null;
}
if (sseTicketPending) return;
sseTicketPending = true;
const ticket = await fetchStreamTicket();
sseTicketPending = false;
if (manualDisconnect || !getToken()) return;
if (!ticket) {
// Server unreachable or session gone: try again later like after a dropped stream
notifyRealtimeStatusChange(false, 'none');
sseReconnectTimer = setTimeout(() => connectSSE(), sseReconnectDelay);
return;
}
try {
const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms));
const url = buildSSEURL(ticket, undefined, undefined, Array.from(globalSubscribedRooms));
const es = new EventSource(url);
activeGlobalSSE = es;
@@ -1402,15 +1425,16 @@ export function connectSSE(rooms?: string[]): void {
}
}
export function connectWebSocket(): void {
let wsTicketPending = false;
export async function connectWebSocket(): Promise<void> {
const token = getToken();
if (!token) return;
manualDisconnect = false;
// If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE
if (isWsBlockedByProxy) {
connectSSE();
return;
return connectSSE();
}
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
@@ -1421,8 +1445,22 @@ export function connectWebSocket(): void {
clearTimeout(wsReconnectTimer);
clearTimeout(sseReconnectTimer);
if (wsTicketPending) return;
wsTicketPending = true;
const ticket = await fetchStreamTicket();
wsTicketPending = false;
if (manualDisconnect || !getToken()) return;
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
return;
}
if (!ticket) {
notifyRealtimeStatusChange(false, 'none');
wsReconnectTimer = setTimeout(() => connectWebSocket(), wsReconnectDelay);
return;
}
try {
const url = buildWebSocketURL();
const url = buildWebSocketURL(ticket);
const ws = new WebSocket(url);
activeGlobalWS = ws;