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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user