bunker-admin c192c04c79 Security audit: fix 25 findings across API, nginx, and Docker
Addresses data exposure, access control, input validation, infrastructure
hardening, and supply chain security issues identified during audit.

Key changes:
- Strip internal fields from public campaign/profile/comment endpoints
- Restrict docs routes to CONTENT_ROLES, provisioning to SUPER_ADMIN
- Add SSE connection limits, social middleware fail-closed behavior
- Bind all non-nginx ports to 127.0.0.1, pin container image versions
- Add CSP header, conditional HSTS, token redaction in nginx logs
- Validate nav URLs, calendar schemas, video tracking batch events
- Reject default admin password placeholder, add SSRF protocol checks
- Exclude .env from Code Server, enforce RC admin password in compose
- Add Zod validation for achievement grant/revoke, webhook secret header
- Fix path traversal prefix attack, add calendar token expiry

Bunker Admin
2026-03-09 14:13:37 -06:00

139 lines
3.7 KiB
TypeScript

import type { Response } from 'express';
import { logger } from '../../utils/logger';
interface SSEClient {
id: string;
userId: string;
res: Response;
connectedAt: Date;
}
/** In-memory SSE connection manager (single-node) */
class SSEService {
private clients = new Map<string, SSEClient[]>(); // userId → client connections
private heartbeatInterval: NodeJS.Timeout | null = null;
/** Start heartbeat interval (30s keep-alive) */
startHeartbeat() {
if (this.heartbeatInterval) return;
this.heartbeatInterval = setInterval(() => {
let total = 0;
for (const [, clients] of this.clients) {
for (const client of clients) {
try {
client.res.write(': heartbeat\n\n');
total++;
} catch {
this.removeClient(client.id);
}
}
}
if (total > 0) {
logger.debug(`SSE heartbeat sent to ${total} clients`);
}
}, 30_000);
}
/** Stop heartbeat */
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
/** Add an SSE client */
addClient(userId: string, res: Response): string {
const id = `${userId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const client: SSEClient = { id, userId, res, connectedAt: new Date() };
if (!this.clients.has(userId)) {
this.clients.set(userId, []);
}
this.clients.get(userId)!.push(client);
logger.info(`SSE client connected: ${id} (user: ${userId})`);
return id;
}
/** Remove an SSE client by connection ID */
removeClient(connectionId: string) {
for (const [userId, clients] of this.clients) {
const idx = clients.findIndex((c) => c.id === connectionId);
if (idx >= 0) {
clients.splice(idx, 1);
if (clients.length === 0) {
this.clients.delete(userId);
}
logger.info(`SSE client disconnected: ${connectionId} (user: ${userId})`);
return userId;
}
}
return null;
}
/** Send an event to a specific user (all connections) */
sendToUser(userId: string, event: string, data: unknown) {
const clients = this.clients.get(userId);
if (!clients || clients.length === 0) return false;
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of clients) {
try {
client.res.write(payload);
} catch {
this.removeClient(client.id);
}
}
return true;
}
/** Broadcast an event to a list of user IDs */
sendToUsers(userIds: string[], event: string, data: unknown) {
for (const userId of userIds) {
this.sendToUser(userId, event, data);
}
}
/** Check if a user has any active SSE connections */
isConnected(userId: string): boolean {
const clients = this.clients.get(userId);
return !!clients && clients.length > 0;
}
/** Get all currently connected user IDs */
getConnectedUserIds(): string[] {
return Array.from(this.clients.keys());
}
/** Get connection count */
getConnectionCount(): number {
let count = 0;
for (const clients of this.clients.values()) {
count += clients.length;
}
return count;
}
/** Get connection count for a specific user */
getConnectionCountForUser(userId: string): number {
return this.clients.get(userId)?.length ?? 0;
}
/** Close all connections (graceful shutdown) */
closeAll() {
this.stopHeartbeat();
for (const [, clients] of this.clients) {
for (const client of clients) {
try { client.res.end(); } catch {}
}
}
this.clients.clear();
logger.info('SSE: All connections closed');
}
}
export const sseService = new SSEService();