security(relay): harden internet-facing relay server
Closes the account-takeover chain and related issues found in audit: - WS auth brute-force protection: per-session lockout (authThrottle) + per- socket failure cap that closes the socket (1008); applied to both web-client and desktop auth. Failed auth no longer leaves the socket open for unlimited guesses. - WS upgrades now pass an IP-based rate limiter and Origin allowlist before handleUpgrade (previously bypassed all HTTP middleware); trust proxy set so limiting keys on the real client IP. - /health no longer leaks live session IDs (counts only). - .env.example rate-limit fixed (900000ms / 300) from the accidental ~11k rps. - Credentials moved out of URLs into the X-MacroPad-Password header; images fetched via header + blob URLs; login stores creds in sessionStorage. - Session creation bounded (max sessions, min password length) and TTL-pruned; session store writes are now atomic (temp+rename) and debounced. - Session IDs lengthened to 12 chars with rejection sampling (no modulo bias). - Enable helmet CSP; restrict CORS to configured origins. - Request IDs use crypto.randomUUID; drop postinstall build hook and uuid dep. - Uniform response for unknown session IDs (removes enumeration oracle). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,11 @@ import WebSocket from 'ws';
|
||||
import { ConnectionManager } 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';
|
||||
@@ -35,17 +40,29 @@ export function handleDesktopConnection(
|
||||
sessionManager: SessionManager
|
||||
): void {
|
||||
let authenticatedSessionId: string | null = null;
|
||||
// Track failed auth attempts on this individual socket.
|
||||
let socketAuthFailures = 0;
|
||||
|
||||
socket.on('message', async (data) => {
|
||||
try {
|
||||
const message: DesktopMessage = JSON.parse(data.toString());
|
||||
|
||||
switch (message.type) {
|
||||
case 'auth':
|
||||
await handleAuth(socket, message, sessionManager, connectionManager, (sessionId) => {
|
||||
case 'auth': {
|
||||
const failed = await handleAuth(socket, message, sessionManager, connectionManager, (sessionId) => {
|
||||
authenticatedSessionId = sessionId;
|
||||
});
|
||||
if (failed) {
|
||||
socketAuthFailures++;
|
||||
if (socketAuthFailures >= config.authMaxSocketFailures) {
|
||||
logger.warn('Desktop exceeded auth attempts, closing socket');
|
||||
socket.close(WS_POLICY_VIOLATION, 'Too many failed attempts');
|
||||
}
|
||||
} else {
|
||||
socketAuthFailures = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'api_response':
|
||||
if (authenticatedSessionId) {
|
||||
@@ -87,32 +104,46 @@ export function handleDesktopConnection(
|
||||
});
|
||||
}
|
||||
|
||||
// Returns true if the auth attempt failed (so the caller can count it).
|
||||
async function handleAuth(
|
||||
socket: WebSocket,
|
||||
message: AuthMessage,
|
||||
sessionManager: SessionManager,
|
||||
connectionManager: ConnectionManager,
|
||||
setSessionId: (id: string) => void
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
let sessionId = message.sessionId;
|
||||
let session;
|
||||
|
||||
if (sessionId) {
|
||||
// Reject early if this session id is currently locked out.
|
||||
const lockRemaining = getLockoutRemaining(sessionId);
|
||||
if (lockRemaining > 0) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'auth_response',
|
||||
success: false,
|
||||
error: 'Too many failed attempts, try again later',
|
||||
retryAfterMs: lockRemaining
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validate existing session
|
||||
const valid = await sessionManager.validatePassword(sessionId, message.password);
|
||||
if (!valid) {
|
||||
recordFailure(sessionId);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'auth_response',
|
||||
success: false,
|
||||
error: 'Invalid session ID or password'
|
||||
}));
|
||||
socket.close();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
recordSuccess(sessionId);
|
||||
session = sessionManager.getSession(sessionId);
|
||||
} else {
|
||||
// Create new session
|
||||
// Create new session (may throw on limit / short password)
|
||||
session = await sessionManager.createSession(message.password);
|
||||
sessionId = session.id;
|
||||
}
|
||||
@@ -123,8 +154,7 @@ async function handleAuth(
|
||||
success: false,
|
||||
error: 'Failed to create session'
|
||||
}));
|
||||
socket.close();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add to connection manager
|
||||
@@ -139,14 +169,15 @@ async function handleAuth(
|
||||
}));
|
||||
|
||||
logger.info(`Desktop authenticated: ${sessionId}`);
|
||||
} catch (error) {
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
logger.error('Desktop auth error:', error);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'auth_response',
|
||||
success: false,
|
||||
error: 'Authentication failed'
|
||||
error: error?.message || 'Authentication failed'
|
||||
}));
|
||||
socket.close();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user