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:
@@ -5,6 +5,14 @@ import path from 'path';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
function parseTrustProxy(): boolean | number | string {
|
||||
const v = process.env.TRUST_PROXY ?? '1';
|
||||
if (v === 'false') return false;
|
||||
if (v === 'true') return true;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? v : n;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
host: process.env.HOST || '0.0.0.0',
|
||||
@@ -13,19 +21,47 @@ export const config = {
|
||||
dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'),
|
||||
|
||||
// Session settings
|
||||
sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '6', 10),
|
||||
sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '12', 10),
|
||||
|
||||
// Maximum number of sessions that may exist at once
|
||||
maxSessions: parseInt(process.env.MAX_SESSIONS || '1000', 10),
|
||||
|
||||
// Minimum password length enforced when creating a new session
|
||||
minPasswordLength: parseInt(process.env.MIN_PASSWORD_LENGTH || '6', 10),
|
||||
|
||||
// Sessions unused for this many days are pruned
|
||||
sessionTtlDays: parseInt(process.env.SESSION_TTL_DAYS || '90', 10),
|
||||
|
||||
// Security
|
||||
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || '10', 10),
|
||||
|
||||
// Rate limiting
|
||||
// Rate limiting (HTTP)
|
||||
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
|
||||
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
||||
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '300', 10),
|
||||
|
||||
// WebSocket upgrade rate limiting (per IP)
|
||||
wsUpgradeWindowMs: parseInt(process.env.WS_UPGRADE_WINDOW_MS || '60000', 10), // 1 minute
|
||||
wsUpgradeMax: parseInt(process.env.WS_UPGRADE_MAX || '60', 10), // max upgrades per IP per window
|
||||
|
||||
// Auth brute-force protection
|
||||
authMaxSocketFailures: parseInt(process.env.AUTH_MAX_SOCKET_FAILURES || '5', 10),
|
||||
authLockoutMaxAttempts: parseInt(process.env.AUTH_LOCKOUT_MAX_ATTEMPTS || '10', 10),
|
||||
authLockoutWindowMs: parseInt(process.env.AUTH_LOCKOUT_WINDOW_MS || '900000', 10), // 15 minutes
|
||||
authLockoutDurationMs: parseInt(process.env.AUTH_LOCKOUT_DURATION_MS || '900000', 10), // 15 minutes
|
||||
|
||||
// WebSocket
|
||||
pingInterval: parseInt(process.env.PING_INTERVAL || '30000', 10), // 30 seconds
|
||||
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || '30000', 10), // 30 seconds
|
||||
|
||||
// Origin / CORS allowlist (comma-separated). Empty = same-host only.
|
||||
allowedOrigins: (process.env.ALLOWED_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter((o) => o.length > 0),
|
||||
|
||||
// Trust the X-Forwarded-* headers from a reverse proxy
|
||||
trustProxy: parseTrustProxy(),
|
||||
|
||||
// Logging
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user