b5e5bed7dd
- Uniform auth responses remove the session-enumeration oracle: unknown session and wrong password now return an identical 401 at the API layer and an identical WS handshake/close; desktop-status and the 503 "not connected" signal are only exposed after successful auth. - Session-count cap re-checked after bcrypt.hash so concurrent creates can't overshoot maxSessions. - Relay web client reconnect backoff resets only after a successful auth response (an accept-then-close server no longer defeats the backoff). - idGenerator comment corrected to match the rejection-sampling; drop unused recordFailure return value. - DEPLOY.md: document ALLOWED_ORIGINS for reverse-proxy deployments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
4.9 KiB
TypeScript
158 lines
4.9 KiB
TypeScript
// Web Client WebSocket Handler
|
|
|
|
import WebSocket from 'ws';
|
|
import { ConnectionManager, WebClientConnection } from '../services/ConnectionManager';
|
|
import { SessionManager } from '../services/SessionManager';
|
|
import { logger } from '../utils/logger';
|
|
import { config } from '../config';
|
|
import { getLockoutRemaining, recordFailure, recordSuccess } from '../utils/authThrottle';
|
|
|
|
// Close code 1008 = policy violation (used for auth abuse).
|
|
const WS_POLICY_VIOLATION = 1008;
|
|
|
|
interface AuthMessage {
|
|
type: 'auth';
|
|
password: string;
|
|
}
|
|
|
|
interface PingMessage {
|
|
type: 'ping';
|
|
}
|
|
|
|
type WebClientMessage = AuthMessage | PingMessage | any;
|
|
|
|
export function handleWebClientConnection(
|
|
socket: WebSocket,
|
|
sessionId: string,
|
|
connectionManager: ConnectionManager,
|
|
sessionManager: SessionManager,
|
|
clientIp: string
|
|
): void {
|
|
// Namespace the brute-force lockout key to the web keyspace and include the
|
|
// client IP. This prevents one abusive IP (which knows the shareable session
|
|
// id) from locking out other web clients or the desktop for this session.
|
|
const throttleKey = `web:${sessionId}:${clientIp}`;
|
|
|
|
// Do NOT reveal whether the session exists. A non-existent session id and
|
|
// an existing one must be indistinguishable to an unauthenticated client so
|
|
// session ids cannot be enumerated: we always run the same handshake and let
|
|
// the (uniform) auth response be the only signal. validatePassword() safely
|
|
// returns false for an unknown session id, and desktop connectivity is only
|
|
// disclosed AFTER a successful auth (see the 'auth' case below).
|
|
const client = connectionManager.addWebClient(sessionId, socket, false);
|
|
|
|
// Track failed auth attempts on this individual socket.
|
|
let socketAuthFailures = 0;
|
|
|
|
// Request authentication.
|
|
socket.send(JSON.stringify({
|
|
type: 'auth_required'
|
|
}));
|
|
|
|
socket.on('message', async (data) => {
|
|
try {
|
|
const message: WebClientMessage = JSON.parse(data.toString());
|
|
|
|
switch (message.type) {
|
|
case 'auth': {
|
|
const failed = await handleAuth(socket, client, message, sessionId, sessionManager, throttleKey);
|
|
if (failed) {
|
|
socketAuthFailures++;
|
|
if (socketAuthFailures >= config.authMaxSocketFailures) {
|
|
logger.warn(`Web client exceeded auth attempts for session ${sessionId}, closing socket`);
|
|
socket.close(WS_POLICY_VIOLATION, 'Too many failed attempts');
|
|
}
|
|
} else {
|
|
socketAuthFailures = 0;
|
|
// Authenticated: only now do we disclose desktop connectivity.
|
|
const desktop = connectionManager.getDesktopBySessionId(sessionId);
|
|
socket.send(JSON.stringify({
|
|
type: 'desktop_status',
|
|
status: desktop ? 'connected' : 'disconnected'
|
|
}));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'ping':
|
|
socket.send(JSON.stringify({ type: 'pong' }));
|
|
break;
|
|
|
|
default:
|
|
// Forward other messages to desktop if authenticated
|
|
if (client.authenticated) {
|
|
forwardToDesktop(message, sessionId, connectionManager);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error handling web client message:', error);
|
|
}
|
|
});
|
|
|
|
socket.on('close', () => {
|
|
connectionManager.removeWebClient(sessionId, client);
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
logger.error('Web client WebSocket error:', error);
|
|
connectionManager.removeWebClient(sessionId, client);
|
|
});
|
|
}
|
|
|
|
// Returns true if the auth attempt failed (so the caller can count it).
|
|
async function handleAuth(
|
|
socket: WebSocket,
|
|
client: WebClientConnection,
|
|
message: AuthMessage,
|
|
sessionId: string,
|
|
sessionManager: SessionManager,
|
|
throttleKey: string
|
|
): Promise<boolean> {
|
|
// Reject early if this key is currently locked out.
|
|
const lockRemaining = getLockoutRemaining(throttleKey);
|
|
if (lockRemaining > 0) {
|
|
socket.send(JSON.stringify({
|
|
type: 'auth_response',
|
|
success: false,
|
|
error: 'Too many failed attempts, try again later',
|
|
retryAfterMs: lockRemaining
|
|
}));
|
|
return true;
|
|
}
|
|
|
|
const valid = await sessionManager.validatePassword(sessionId, message.password);
|
|
|
|
if (valid) {
|
|
recordSuccess(throttleKey);
|
|
client.authenticated = true;
|
|
socket.send(JSON.stringify({
|
|
type: 'auth_response',
|
|
success: true
|
|
}));
|
|
logger.debug(`Web client authenticated for session: ${sessionId}`);
|
|
return false;
|
|
} else {
|
|
recordFailure(throttleKey);
|
|
socket.send(JSON.stringify({
|
|
type: 'auth_response',
|
|
success: false,
|
|
error: 'Authentication failed'
|
|
}));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function forwardToDesktop(
|
|
message: any,
|
|
sessionId: string,
|
|
connectionManager: ConnectionManager
|
|
): void {
|
|
const desktop = connectionManager.getDesktopBySessionId(sessionId);
|
|
if (desktop && desktop.socket.readyState === WebSocket.OPEN) {
|
|
desktop.socket.send(JSON.stringify({
|
|
type: 'ws_message',
|
|
data: message
|
|
}));
|
|
}
|
|
}
|