Files
MP-Server/macropad-relay/src/services/SessionManager.ts
T
shadowdao b5e5bed7dd security(relay): minor hardening follow-ups from PR review
- Uniform auth responses remove the session-enumeration oracle: unknown
  session and wrong password now return an identical 401 at the API layer and
  an identical WS handshake/close; desktop-status and the 503 "not connected"
  signal are only exposed after successful auth.
- Session-count cap re-checked after bcrypt.hash so concurrent creates can't
  overshoot maxSessions.
- Relay web client reconnect backoff resets only after a successful auth
  response (an accept-then-close server no longer defeats the backoff).
- idGenerator comment corrected to match the rejection-sampling; drop unused
  recordFailure return value.
- DEPLOY.md: document ALLOWED_ORIGINS for reverse-proxy deployments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:13:33 -07:00

229 lines
6.6 KiB
TypeScript

// Session Manager - handles session storage and authentication
import fs from 'fs';
import path from 'path';
import bcrypt from 'bcrypt';
import { config } from '../config';
import { generateSessionId } from '../utils/idGenerator';
import { logger } from '../utils/logger';
export interface Session {
id: string;
passwordHash: string;
createdAt: string;
lastConnected: string;
}
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 {
try {
if (fs.existsSync(this.sessionsFile)) {
const data = JSON.parse(fs.readFileSync(this.sessionsFile, 'utf-8')) as SessionStore;
for (const [id, session] of Object.entries(data.sessions || {})) {
this.sessions.set(id, session);
}
logger.info(`Loaded ${this.sessions.size} sessions`);
}
} catch (error) {
logger.error('Failed to load sessions:', error);
}
}
// 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)
};
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 {
id = generateSessionId(config.sessionIdLength);
} while (this.sessions.has(id));
const passwordHash = await bcrypt.hash(password, config.bcryptRounds);
// Re-check the cap AFTER the (awaited) hash. bcrypt.hash is the only
// suspension point between the first cap check and the set() below, so
// concurrent createSession calls could each have passed the earlier check
// and then filled the map while we were hashing. Re-checking here — with
// no await between this check and the synchronous set() — makes the
// check-and-insert atomic under Node's single-threaded model, so the cap
// cannot be overshot.
if (this.sessions.size >= config.maxSessions) {
this.pruneExpired();
if (this.sessions.size >= config.maxSessions) {
throw new Error('Session limit reached');
}
}
const now = new Date().toISOString();
const session: Session = {
id,
passwordHash,
createdAt: now,
lastConnected: now
};
this.sessions.set(id, session);
this.save(); // creation is important, persist immediately
logger.info(`Created new session: ${id}`);
return session;
}
async validatePassword(sessionId: string, password: string): Promise<boolean> {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
const valid = await bcrypt.compare(password, session.passwordHash);
if (valid) {
// 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;
}
getSession(sessionId: string): Session | undefined {
return this.sessions.get(sessionId);
}
updateLastConnected(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.lastConnected = new Date().toISOString();
this.dirty = true;
}
}
deleteSession(sessionId: string): boolean {
const deleted = this.sessions.delete(sessionId);
if (deleted) {
this.save();
logger.info(`Deleted session: ${sessionId}`);
}
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;
}
}
}