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:
+110
-15
@@ -3,7 +3,7 @@
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
@@ -17,19 +17,101 @@ import { handleDesktopConnection } from './handlers/desktopHandler';
|
||||
import { handleWebClientConnection } from './handlers/webClientHandler';
|
||||
import { createApiProxy } from './handlers/apiProxy';
|
||||
|
||||
// Resolve the client IP for an upgrade request, honouring the proxy's
|
||||
// X-Forwarded-For first hop when we trust the proxy.
|
||||
function getClientIp(request: http.IncomingMessage): string {
|
||||
if (config.trustProxy) {
|
||||
const xff = request.headers['x-forwarded-for'];
|
||||
if (typeof xff === 'string' && xff.length > 0) {
|
||||
return xff.split(',')[0].trim();
|
||||
}
|
||||
if (Array.isArray(xff) && xff.length > 0) {
|
||||
return xff[0].split(',')[0].trim();
|
||||
}
|
||||
}
|
||||
return request.socket.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
// Simple in-memory sliding-window rate limiter for WS upgrades, keyed by IP.
|
||||
function createUpgradeRateLimiter() {
|
||||
const hits = new Map<string, number[]>();
|
||||
|
||||
const cleanup = setInterval(() => {
|
||||
const cutoff = Date.now() - config.wsUpgradeWindowMs;
|
||||
for (const [ip, times] of hits) {
|
||||
const recent = times.filter((t) => t > cutoff);
|
||||
if (recent.length === 0) {
|
||||
hits.delete(ip);
|
||||
} else {
|
||||
hits.set(ip, recent);
|
||||
}
|
||||
}
|
||||
}, config.wsUpgradeWindowMs);
|
||||
if (typeof cleanup.unref === 'function') cleanup.unref();
|
||||
|
||||
return function allow(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const cutoff = now - config.wsUpgradeWindowMs;
|
||||
const times = (hits.get(ip) || []).filter((t) => t > cutoff);
|
||||
times.push(now);
|
||||
hits.set(ip, times);
|
||||
return times.length <= config.wsUpgradeMax;
|
||||
};
|
||||
}
|
||||
|
||||
// Validate the Origin header for a WS upgrade against the allowlist.
|
||||
// Native (desktop) clients send no Origin and are permitted.
|
||||
function isOriginAllowed(origin: string | undefined, host: string | undefined): boolean {
|
||||
if (!origin) return true; // non-browser clients (e.g. desktop app)
|
||||
|
||||
if (config.allowedOrigins.length > 0) {
|
||||
return config.allowedOrigins.includes(origin);
|
||||
}
|
||||
|
||||
// Default: allow only same-host origins.
|
||||
try {
|
||||
return new URL(origin).host === host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createServer() {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Trust the reverse proxy so client IPs (rate limiting) are accurate.
|
||||
app.set('trust proxy', config.trustProxy);
|
||||
|
||||
// Initialize managers
|
||||
const sessionManager = new SessionManager();
|
||||
const connectionManager = new ConnectionManager();
|
||||
|
||||
// Middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false // Allow inline scripts for login page
|
||||
// The bundled login/app pages rely on inline <script> and inline event
|
||||
// handlers, so script/style inline is permitted; images may be blobs.
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
connectSrc: ["'self'", 'ws:', 'wss:'],
|
||||
manifestSrc: ["'self'", 'blob:'],
|
||||
objectSrc: ["'none'"],
|
||||
frameAncestors: ["'self'"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
app.use(cors());
|
||||
|
||||
// Restrict CORS to the configured origins. When none are configured we
|
||||
// disallow cross-origin requests (the first-party app is same-origin).
|
||||
const corsOptions: cors.CorsOptions =
|
||||
config.allowedOrigins.length > 0
|
||||
? { origin: config.allowedOrigins }
|
||||
: { origin: false };
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
|
||||
// Rate limiting
|
||||
@@ -46,12 +128,14 @@ export function createServer() {
|
||||
: path.join(__dirname, '..', 'public');
|
||||
app.use('/static', express.static(publicPath));
|
||||
|
||||
// Health check
|
||||
// Health check - counts only, never leak session ids.
|
||||
app.get('/health', (req, res) => {
|
||||
const stats = connectionManager.getStats();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
...stats
|
||||
desktops: stats.desktopConnections,
|
||||
webClients: stats.webClients,
|
||||
uptime: process.uptime()
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,21 +149,14 @@ export function createServer() {
|
||||
res.sendFile(path.join(publicPath, 'index.html'));
|
||||
});
|
||||
|
||||
// Login page for session
|
||||
// Login page for session. Served uniformly regardless of whether the
|
||||
// session exists so validity cannot be probed via this endpoint.
|
||||
app.get('/:sessionId', (req, res) => {
|
||||
const session = sessionManager.getSession(req.params.sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).send('Session not found');
|
||||
}
|
||||
res.sendFile(path.join(publicPath, 'login.html'));
|
||||
});
|
||||
|
||||
// PWA app page (after authentication)
|
||||
// PWA app page (served uniformly; auth is enforced by the API/WS layer).
|
||||
app.get('/:sessionId/app', (req, res) => {
|
||||
const session = sessionManager.getSession(req.params.sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).send('Session not found');
|
||||
}
|
||||
res.sendFile(path.join(publicPath, 'app.html'));
|
||||
});
|
||||
|
||||
@@ -90,8 +167,25 @@ export function createServer() {
|
||||
// WebSocket server
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
const allowUpgrade = createUpgradeRateLimiter();
|
||||
|
||||
// Handle HTTP upgrade for WebSocket
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
// IP-based upgrade rate limiting (WS upgrades bypass Express middleware).
|
||||
const ip = getClientIp(request);
|
||||
if (!allowUpgrade(ip)) {
|
||||
logger.warn(`WS upgrade rate limit exceeded for ${ip}`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Origin validation.
|
||||
if (!isOriginAllowed(request.headers.origin, request.headers.host)) {
|
||||
logger.warn(`WS upgrade rejected: bad origin ${request.headers.origin}`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
@@ -121,6 +215,7 @@ export function createServer() {
|
||||
const shutdown = () => {
|
||||
logger.info('Shutting down...');
|
||||
connectionManager.shutdown();
|
||||
sessionManager.shutdown();
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
|
||||
Reference in New Issue
Block a user