Files
MP-Server/macropad-relay/src/config.ts
T
shadowdao eba5a2a38e 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>
2026-07-17 10:15:23 -07:00

72 lines
2.5 KiB
TypeScript

// Configuration for MacroPad Relay Server
import dotenv from 'dotenv';
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',
// Data storage
dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'),
// Session settings
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 (HTTP)
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
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',
// Environment
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV !== 'production',
};