security: WS auth via first-message, not query string
Tokens in URL leak through proxy access logs, browser history and
Referer headers. Now: WS opens unauthenticated, client sends
{type:'auth', token} as first message; server responds with
{type:'auth_ok'} and starts normal message processing.
5-second timeout closes any unauthenticated connection.
Frontend queues session join until auth_ok received.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+34
-16
@@ -279,27 +279,21 @@ function attach(httpServer) {
|
|||||||
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
||||||
_wss = wss;
|
_wss = wss;
|
||||||
|
|
||||||
wss.on('connection', (ws, req) => {
|
wss.on('connection', (ws) => {
|
||||||
/* ── Auth ── */
|
ws.userId = null;
|
||||||
let user = null;
|
ws.userName = '';
|
||||||
try {
|
|
||||||
const url = new URL(req.url, 'http://localhost');
|
|
||||||
const token = url.searchParams.get('token') || '';
|
|
||||||
user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
|
||||||
} catch {
|
|
||||||
ws.close(4001, 'Unauthorized');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.userId = user.id;
|
|
||||||
ws.userName = user.name || user.email || '';
|
|
||||||
ws.isAlive = true;
|
ws.isAlive = true;
|
||||||
|
|
||||||
ws.on('pong', () => { ws.isAlive = true; });
|
ws.on('pong', () => { ws.isAlive = true; });
|
||||||
|
|
||||||
ws._msgCount = 0;
|
ws._msgCount = 0;
|
||||||
ws._msgWindowStart = Date.now();
|
ws._msgWindowStart = Date.now();
|
||||||
|
|
||||||
|
// Close unauthenticated connections after 5s
|
||||||
|
ws._authTimer = setTimeout(() => {
|
||||||
|
if (!ws.userId) { try { ws.close(4001, 'Auth timeout'); } catch {} }
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
ws.on('message', raw => {
|
ws.on('message', raw => {
|
||||||
// Rate-limit: max 120 messages per second per connection
|
// Rate-limit: max 120 messages per second per connection
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -308,13 +302,37 @@ function attach(httpServer) {
|
|||||||
|
|
||||||
let msg;
|
let msg;
|
||||||
try { msg = JSON.parse(raw); } catch { return; }
|
try { msg = JSON.parse(raw); } catch { return; }
|
||||||
|
|
||||||
|
// Pre-auth: only {type:'auth', token} accepted
|
||||||
|
if (!ws.userId) {
|
||||||
|
if (msg.type !== 'auth' || !msg.token) {
|
||||||
|
try { ws.close(4001, 'Auth required'); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const user = jwt.verify(msg.token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
|
ws.userId = user.id;
|
||||||
|
ws.userName = user.name || user.email || '';
|
||||||
|
clearTimeout(ws._authTimer);
|
||||||
|
ws._authTimer = null;
|
||||||
|
try { ws.send(JSON.stringify({ type: 'auth_ok' })); } catch {}
|
||||||
|
} catch {
|
||||||
|
try { ws.close(4001, 'Invalid token'); } catch {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-auth: normal handler
|
||||||
try { _handleMessage(ws, msg); } catch (e) {
|
try { _handleMessage(ws, msg); } catch (e) {
|
||||||
// swallow — never crash the WS server on bad input
|
// swallow — never crash the WS server on bad input
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('error', () => {});
|
ws.on('error', () => {});
|
||||||
ws.on('close', () => { _unregisterUser(ws); });
|
ws.on('close', () => {
|
||||||
|
if (ws._authTimer) { clearTimeout(ws._authTimer); ws._authTimer = null; }
|
||||||
|
if (ws.userId) _unregisterUser(ws);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Ping/pong keepalive (30s) ── */
|
/* ── Ping/pong keepalive (30s) ── */
|
||||||
|
|||||||
+14
-7
@@ -4330,12 +4330,11 @@
|
|||||||
function _crWsConnect() {
|
function _crWsConnect() {
|
||||||
if (_crWs && (_crWs.readyState === WebSocket.OPEN || _crWs.readyState === WebSocket.CONNECTING)) return;
|
if (_crWs && (_crWs.readyState === WebSocket.OPEN || _crWs.readyState === WebSocket.CONNECTING)) return;
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const token = LS.getToken() || '';
|
_crWs = new WebSocket(`${proto}//${location.host}/ws`);
|
||||||
_crWs = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(token)}`);
|
|
||||||
_crWs.onopen = () => {
|
_crWs.onopen = () => {
|
||||||
_crWsReady = true;
|
// Authenticate via first message — token never appears in URL or proxy logs
|
||||||
// Register in session so server delivers events via WS instead of SSE
|
const token = LS.getToken() || '';
|
||||||
if (_sessionId) _crWs.send(JSON.stringify({ type: 'classroom_join', sessionId: _sessionId }));
|
_crWs.send(JSON.stringify({ type: 'auth', token }));
|
||||||
};
|
};
|
||||||
_crWs.onclose = () => {
|
_crWs.onclose = () => {
|
||||||
_crWsReady = false;
|
_crWsReady = false;
|
||||||
@@ -4343,9 +4342,17 @@
|
|||||||
if (_sessionId) setTimeout(() => { if (_sessionId) _crWsConnect(); }, 2000);
|
if (_sessionId) setTimeout(() => { if (_sessionId) _crWsConnect(); }, 2000);
|
||||||
};
|
};
|
||||||
_crWs.onerror = () => { _crWsReady = false; };
|
_crWs.onerror = () => { _crWsReady = false; };
|
||||||
// All classroom server→client events arrive here (WS replaces SSE for classroom)
|
|
||||||
_crWs.onmessage = e => {
|
_crWs.onmessage = e => {
|
||||||
try { handleSSE(JSON.parse(e.data), true); } catch {}
|
let msg;
|
||||||
|
try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
|
if (msg.type === 'auth_ok') {
|
||||||
|
_crWsReady = true;
|
||||||
|
// Register in session so server delivers events via WS instead of SSE
|
||||||
|
if (_sessionId) _crWs.send(JSON.stringify({ type: 'classroom_join', sessionId: _sessionId }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// All other classroom server→client events
|
||||||
|
try { handleSSE(msg, true); } catch {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user