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
+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;
}
}
}