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
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:
1 parent
5c711c2cf7
commit
f57a9884d1
17 files changed
+335
-41
No files matched your search
@@ -11,7 +11,7 @@ import {
|
||||
ShoppingCart,
|
||||
} from 'lucide-react';
|
||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('realtime fallback', () => {
|
||||
vi.stubGlobal('EventSource', FakeEventSource);
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem('mindsift_jwt_token', 'test-token');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{"ticket":"t1"}', { status: 200 })));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -73,7 +73,7 @@ describe('realtime fallback', () => {
|
||||
const api = await loadApi();
|
||||
const states: string[] = [];
|
||||
api.onRealtimeStatus((connected, transport) => states.push(`${connected}:${transport}`));
|
||||
api.connectWebSocket();
|
||||
await api.connectWebSocket();
|
||||
FakeWebSocket.instances[0].open();
|
||||
expect(api.getRealtimeTransport()).toBe('ws');
|
||||
|
||||
@@ -87,9 +87,17 @@ describe('realtime fallback', () => {
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
|
||||
it('connects with a one-time ticket, never with the session token in the URL', async () => {
|
||||
const api = await loadApi();
|
||||
await api.connectWebSocket();
|
||||
expect(FakeWebSocket.instances[0].url).toContain('ticket=t1');
|
||||
expect(FakeWebSocket.instances[0].url).not.toContain('test-token');
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
|
||||
it('a WebSocket answering PONG stays in use', async () => {
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
await api.connectWebSocket();
|
||||
const ws = FakeWebSocket.instances[0];
|
||||
ws.open();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
@@ -104,7 +112,7 @@ describe('realtime fallback', () => {
|
||||
it('SSE only counts as connected once the first event arrives', async () => {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
await api.connectWebSocket();
|
||||
const es = FakeEventSource.instances[0];
|
||||
es.open();
|
||||
expect(api.isRealtimeConnected()).toBe(false);
|
||||
@@ -118,7 +126,7 @@ describe('realtime fallback', () => {
|
||||
it('a buffering proxy (stream opens, no events) switches SSE off for this tab', async () => {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
await api.connectWebSocket();
|
||||
const es = FakeEventSource.instances[0];
|
||||
es.open();
|
||||
await vi.advanceTimersByTimeAsync(5_500);
|
||||
|
||||
+53
-15
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user