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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+2484
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user