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:
2026-07-17 10:15:23 -07:00
parent 1f26427328
commit eba5a2a38e
14 changed files with 2998 additions and 84 deletions
+30 -5
View File
@@ -9,12 +9,37 @@ NODE_ENV=production
# Security
BCRYPT_ROUNDS=10
# Session ID length (default: 6 characters)
SESSION_ID_LENGTH=6
# Session ID length (default: 12 characters)
SESSION_ID_LENGTH=12
# Rate limiting
RATE_LIMIT_WINDOW_MS=90
RATE_LIMIT_MAX=1024
# Minimum password length enforced on session creation
MIN_PASSWORD_LENGTH=6
# Maximum number of concurrent sessions
MAX_SESSIONS=1000
# Prune sessions unused for this many days
SESSION_TTL_DAYS=90
# HTTP rate limiting (15 minute window, 300 requests per window)
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=300
# WebSocket upgrade rate limiting (per IP)
WS_UPGRADE_WINDOW_MS=60000
WS_UPGRADE_MAX=60
# Auth brute-force protection
AUTH_MAX_SOCKET_FAILURES=5
AUTH_LOCKOUT_MAX_ATTEMPTS=10
AUTH_LOCKOUT_WINDOW_MS=900000
AUTH_LOCKOUT_DURATION_MS=900000
# Allowed origins for CORS + WebSocket (comma-separated). Empty = same-host only.
# ALLOWED_ORIGINS=https://macropad.example.com
# Trust reverse proxy (number of hops, or true/false). Default: 1
TRUST_PROXY=1
# WebSocket timeouts (in milliseconds)
PING_INTERVAL=30000
+6 -3
View File
@@ -9,9 +9,12 @@ For AnHonestHost cloud-node-container deployment:
```bash
cd /home/jknapp/code/macropad/macropad-relay
npm install
npm run build
npm run build # required: the build is no longer triggered automatically on install
```
> Note: the `postinstall` build hook was removed. Always run `npm run build`
> explicitly (locally or in CI) after `npm install` to produce `dist/`.
### 2. Prepare Deployment Package
The build outputs to `dist/` with public files copied. Upload:
@@ -84,8 +87,8 @@ Set these in your container configuration:
# Test health endpoint
curl http://localhost:3000/health
# Should return:
# {"status":"ok","desktopConnections":0,"webClients":0,"sessions":[]}
# Should return (counts only - no session ids are exposed):
# {"status":"ok","desktops":0,"webClients":0,"uptime":1.23}
```
## Nginx/Reverse Proxy (for HTTPS)
+2484
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -4,7 +4,6 @@
"description": "Relay server for MacroPad remote access",
"main": "dist/index.js",
"scripts": {
"postinstall": "npm run build",
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon --exec ts-node src/index.ts",
@@ -21,7 +20,6 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"typescript": "^5.3.2",
"uuid": "^9.0.0",
"winston": "^3.11.0",
"ws": "^8.14.2"
},
@@ -29,7 +27,6 @@
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.16",
"@types/express": "^4.17.21",
"@types/uuid": "^9.0.6",
"@types/ws": "^8.5.9",
"nodemon": "^3.0.2",
"ts-node": "^10.9.2"
+39 -12
View File
@@ -292,15 +292,12 @@
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/);
this.sessionId = pathMatch ? pathMatch[1] : null;
// Get password from URL or sessionStorage
const urlParams = new URLSearchParams(window.location.search);
this.password = urlParams.get('auth') || sessionStorage.getItem(`macropad_${this.sessionId}`);
// Get password from storage (never from the URL).
this.password = sessionStorage.getItem(`macropad_${this.sessionId}`)
|| localStorage.getItem(`macropad_${this.sessionId}`);
if (this.password) {
sessionStorage.setItem(`macropad_${this.sessionId}`, this.password);
if (urlParams.has('auth')) {
window.history.replaceState({}, '', window.location.pathname);
}
}
this.init();
@@ -374,6 +371,7 @@
handleAuthError() {
sessionStorage.removeItem(`macropad_${this.sessionId}`);
localStorage.removeItem(`macropad_${this.sessionId}`);
window.location.href = `/${this.sessionId}`;
}
@@ -467,19 +465,48 @@
}
container.innerHTML = entries.map(([id, macro]) => {
// Include password as query param for image authentication
const imageSrc = macro.image_path
? `/${this.sessionId}/api/image/${macro.image_path}?password=${encodeURIComponent(this.password)}`
: null;
const hasImage = !!macro.image_path;
const firstChar = macro.name.charAt(0).toUpperCase();
return `
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
${imageSrc ? `<img src="${imageSrc}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">` : ''}
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>${firstChar}</div>
${hasImage ? `<img class="macro-image" style="display:none">` : ''}
<div class="macro-image-placeholder">${firstChar}</div>
<span class="macro-name">${macro.name}</span>
</div>
`;
}).join('');
// Load macro images via authenticated fetch (header, not URL).
this.loadMacroImages();
}
// Fetch each macro image with the password header and display it
// as a blob object URL so the credential never appears in a URL.
async loadMacroImages() {
for (const [id, macro] of Object.entries(this.macros)) {
if (!macro.image_path) continue;
const card = document.querySelector(`[data-macro-id="${id}"]`);
if (!card) continue;
const img = card.querySelector('.macro-image');
if (!img) continue;
try {
const response = await fetch(
`/${this.sessionId}/api/image/${macro.image_path}`,
{ headers: this.getApiHeaders() }
);
if (!response.ok) continue; // keep placeholder
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
img.onload = () => URL.revokeObjectURL(objectUrl);
img.src = objectUrl;
img.style.display = '';
const placeholder = img.nextElementSibling;
if (placeholder) placeholder.style.display = 'none';
} catch (error) {
// Leave the placeholder visible on failure.
}
}
}
setupEventListeners() {
+10 -6
View File
@@ -169,8 +169,9 @@
let desktopConnected = false;
// Check for saved password
const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`);
// Check for saved password (session first, then "remembered" store)
const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`)
|| localStorage.getItem(`macropad_${sessionId}`);
if (savedPassword) {
passwordInput.value = savedPassword;
}
@@ -225,13 +226,16 @@
});
if (response.ok) {
// Save password if remember is checked
// Hand the credential to the app via storage (never the URL).
sessionStorage.setItem(`macropad_${sessionId}`, password);
if (rememberCheckbox.checked) {
sessionStorage.setItem(`macropad_${sessionId}`, password);
localStorage.setItem(`macropad_${sessionId}`, password);
} else {
localStorage.removeItem(`macropad_${sessionId}`);
}
// Redirect to the PWA with password
window.location.href = `/${sessionId}/app?auth=${encodeURIComponent(password)}`;
// Redirect to the PWA (credential is read from storage).
window.location.href = `/${sessionId}/app`;
} else {
const data = await response.json();
errorDiv.textContent = data.error || 'Invalid password';
+39 -3
View File
@@ -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',
+3 -3
View File
@@ -18,9 +18,9 @@ export function createApiProxy(
return res.status(404).json({ error: 'Session not found' });
}
// Check password in header or query
const password = req.headers['x-macropad-password'] as string ||
req.query.password as string;
// Credentials must be supplied via the header (never the URL/query,
// which leaks into logs, history and referrers).
const password = req.headers['x-macropad-password'] as string;
if (!password) {
return res.status(401).json({ error: 'Password required' });
+42 -11
View File
@@ -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;
}
}
@@ -4,6 +4,11 @@ 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';
@@ -36,6 +41,9 @@ export function handleWebClientConnection(
// Add client (not authenticated yet)
const client = connectionManager.addWebClient(sessionId, socket, false);
// Track failed auth attempts on this individual socket.
let socketAuthFailures = 0;
// Check if desktop is connected
const desktop = connectionManager.getDesktopBySessionId(sessionId);
socket.send(JSON.stringify({
@@ -53,9 +61,19 @@ export function handleWebClientConnection(
const message: WebClientMessage = JSON.parse(data.toString());
switch (message.type) {
case 'auth':
await handleAuth(socket, client, message, sessionId, sessionManager);
case 'auth': {
const failed = await handleAuth(socket, client, message, sessionId, sessionManager);
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;
}
break;
}
case 'ping':
socket.send(JSON.stringify({ type: 'pong' }));
@@ -82,28 +100,45 @@ export function handleWebClientConnection(
});
}
// 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
): Promise<void> {
): Promise<boolean> {
// 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;
}
const valid = await sessionManager.validatePassword(sessionId, message.password);
if (valid) {
recordSuccess(sessionId);
client.authenticated = true;
socket.send(JSON.stringify({
type: 'auth_response',
success: true
}));
logger.debug(`Web client authenticated for session: ${sessionId}`);
return false;
} else {
recordFailure(sessionId);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Invalid password'
}));
return true;
}
}
+110 -15
View File
@@ -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);
+99 -7
View File
@@ -18,13 +18,38 @@ interface SessionStore {
sessions: Record<string, Session>;
}
// Flush pending (debounced) writes at most this often.
const FLUSH_INTERVAL_MS = 60 * 1000;
// Prune expired sessions on this cadence.
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
export class SessionManager {
private sessionsFile: string;
private sessions: Map<string, Session> = new Map();
private dirty = false;
private flushTimer: NodeJS.Timeout | null = null;
private pruneTimer: NodeJS.Timeout | null = null;
constructor() {
this.sessionsFile = path.join(config.dataDir, 'sessions.json');
this.load();
this.pruneExpired();
this.startBackgroundTasks();
}
private startBackgroundTasks(): void {
this.flushTimer = setInterval(() => {
if (this.dirty) {
this.save();
this.dirty = false;
}
}, FLUSH_INTERVAL_MS);
if (typeof this.flushTimer.unref === 'function') this.flushTimer.unref();
this.pruneTimer = setInterval(() => {
this.pruneExpired();
}, PRUNE_INTERVAL_MS);
if (typeof this.pruneTimer.unref === 'function') this.pruneTimer.unref();
}
private load(): void {
@@ -41,23 +66,69 @@ export class SessionManager {
}
}
// Atomic write: write to a temp file in the same directory, then rename.
private save(): void {
try {
const store: SessionStore = {
sessions: Object.fromEntries(this.sessions)
};
fs.mkdirSync(path.dirname(this.sessionsFile), { recursive: true });
fs.writeFileSync(this.sessionsFile, JSON.stringify(store, null, 2));
const dir = path.dirname(this.sessionsFile);
fs.mkdirSync(dir, { recursive: true });
const tmpFile = path.join(
dir,
`.sessions.${process.pid}.${Date.now()}.tmp`
);
fs.writeFileSync(tmpFile, JSON.stringify(store, null, 2));
fs.renameSync(tmpFile, this.sessionsFile);
} catch (error) {
logger.error('Failed to save sessions:', error);
}
}
// Prune sessions that have been unused past the configured TTL.
private pruneExpired(): void {
const ttlMs = config.sessionTtlDays * 24 * 60 * 60 * 1000;
if (ttlMs <= 0) return;
const now = Date.now();
let pruned = 0;
for (const [id, session] of this.sessions) {
const last = Date.parse(session.lastConnected || session.createdAt);
if (!Number.isNaN(last) && now - last > ttlMs) {
this.sessions.delete(id);
pruned++;
}
}
if (pruned > 0) {
logger.info(`Pruned ${pruned} expired session(s)`);
this.save();
this.dirty = false;
}
}
async exists(sessionId: string): Promise<boolean> {
return this.sessions.has(sessionId);
}
async createSession(password: string): Promise<Session> {
// Reject empty / short passwords before doing any hashing.
if (!password || password.length < config.minPasswordLength) {
throw new Error(
`Password must be at least ${config.minPasswordLength} characters`
);
}
// Cap total sessions to prevent unbounded resource exhaustion.
if (this.sessions.size >= config.maxSessions) {
// Try to reclaim space by pruning expired sessions first.
this.pruneExpired();
if (this.sessions.size >= config.maxSessions) {
throw new Error('Session limit reached');
}
}
// Generate unique session ID
let id: string;
do {
@@ -75,7 +146,7 @@ export class SessionManager {
};
this.sessions.set(id, session);
this.save();
this.save(); // creation is important, persist immediately
logger.info(`Created new session: ${id}`);
return session;
@@ -90,9 +161,14 @@ export class SessionManager {
const valid = await bcrypt.compare(password, session.passwordHash);
if (valid) {
// Update last connected time
session.lastConnected = new Date().toISOString();
this.save();
// Update last-connected time, but only mark dirty (debounced flush)
// if it moved by more than a minute to avoid writing on every auth.
const now = Date.now();
const prev = Date.parse(session.lastConnected);
if (Number.isNaN(prev) || now - prev > 60 * 1000) {
session.lastConnected = new Date(now).toISOString();
this.dirty = true;
}
}
return valid;
@@ -106,7 +182,7 @@ export class SessionManager {
const session = this.sessions.get(sessionId);
if (session) {
session.lastConnected = new Date().toISOString();
this.save();
this.dirty = true;
}
}
@@ -118,4 +194,20 @@ export class SessionManager {
}
return deleted;
}
// Flush any pending writes and stop background timers.
shutdown(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = null;
}
if (this.pruneTimer) {
clearInterval(this.pruneTimer);
this.pruneTimer = null;
}
if (this.dirty) {
this.save();
this.dirty = false;
}
}
}
+76
View File
@@ -0,0 +1,76 @@
// Per-session-id auth brute-force lockout (in-memory).
//
// Tracks failed authentication attempts keyed by session id. After
// `authLockoutMaxAttempts` failures within `authLockoutWindowMs`, further
// attempts are rejected until `authLockoutDurationMs` has elapsed.
import { config } from '../config';
interface AttemptRecord {
count: number;
windowStart: number;
lockedUntil: number;
}
const records = new Map<string, AttemptRecord>();
/**
* Returns the number of milliseconds remaining on a lockout for this key,
* or 0 if the key is not currently locked.
*/
export function getLockoutRemaining(key: string): number {
const rec = records.get(key);
if (!rec) return 0;
const now = Date.now();
if (rec.lockedUntil > now) {
return rec.lockedUntil - now;
}
return 0;
}
/**
* Record a failed auth attempt. Returns the lockout remaining (ms) after
* this failure is applied (0 if still not locked).
*/
export function recordFailure(key: string): number {
const now = Date.now();
let rec = records.get(key);
// Start a fresh window if none exists or the current one has expired.
if (!rec || now - rec.windowStart > config.authLockoutWindowMs) {
rec = { count: 0, windowStart: now, lockedUntil: 0 };
records.set(key, rec);
}
rec.count += 1;
if (rec.count >= config.authLockoutMaxAttempts) {
rec.lockedUntil = now + config.authLockoutDurationMs;
}
return rec.lockedUntil > now ? rec.lockedUntil - now : 0;
}
/**
* Clear any recorded failures for this key (call on successful auth).
*/
export function recordSuccess(key: string): void {
records.delete(key);
}
// Periodically drop stale records so the map does not grow unbounded.
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, rec] of records) {
const windowExpired = now - rec.windowStart > config.authLockoutWindowMs;
const notLocked = rec.lockedUntil <= now;
if (windowExpired && notLocked) {
records.delete(key);
}
}
}, Math.max(60000, config.authLockoutWindowMs));
// Do not keep the process alive solely for this timer.
if (typeof cleanup.unref === 'function') {
cleanup.unref();
}
+22 -13
View File
@@ -1,31 +1,40 @@
// Unique ID generation utilities
import { randomBytes } from 'crypto';
import { randomBytes, randomUUID } from 'crypto';
const BASE62_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
// Largest multiple of 62 that fits in a byte (62 * 4 = 248). Bytes >= 248
// are rejected so every character is drawn with uniform probability
// (avoids modulo bias from `byte % 62`).
const REJECTION_THRESHOLD = Math.floor(256 / BASE62_CHARS.length) * BASE62_CHARS.length;
/**
* Generate a random base62 string of specified length.
* Uses cryptographically secure random bytes.
* Generate a random base62 string of the specified length.
* Uses cryptographically secure random bytes with rejection sampling
* to eliminate modulo bias.
*/
export function generateSessionId(length: number = 6): string {
const bytes = randomBytes(length);
export function generateSessionId(length: number = 12): string {
let result = '';
for (let i = 0; i < length; i++) {
result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length];
while (result.length < length) {
// Over-fetch a little to reduce the number of syscalls when rejecting.
const bytes = randomBytes(length);
for (let i = 0; i < bytes.length && result.length < length; i++) {
const byte = bytes[i];
if (byte >= REJECTION_THRESHOLD) {
continue; // reject to avoid bias
}
result += BASE62_CHARS[byte % BASE62_CHARS.length];
}
}
return result;
}
/**
* Generate a UUID v4 for request IDs.
* Generate a UUID v4 for request IDs using the crypto module.
*/
export function generateRequestId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
return randomUUID();
}