diff --git a/src/security/middleware.ts b/src/security/middleware.ts new file mode 100644 index 000000000..bed9f40a0 --- /dev/null +++ b/src/security/middleware.ts @@ -0,0 +1,180 @@ +/** + * Security shield HTTP middleware + * Integrates security checks into Express/HTTP request pipeline + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import { getSecurityShield, SecurityShield, type SecurityContext } from "./shield.js"; + +/** + * Create security context from HTTP request + */ +export function createSecurityContext(req: IncomingMessage): SecurityContext { + return { + ip: SecurityShield.extractIp(req), + userAgent: req.headers["user-agent"], + requestId: (req as any).requestId, // May be set by other middleware + }; +} + +/** + * Security middleware for HTTP requests + * Checks IP blocklist and rate limits + */ +export function securityMiddleware( + req: IncomingMessage, + res: ServerResponse, + next: () => void +): void { + const shield = getSecurityShield(); + + if (!shield.isEnabled()) { + next(); + return; + } + + const ctx = createSecurityContext(req); + + // Check if IP is blocked + if (shield.isIpBlocked(ctx.ip)) { + res.statusCode = 403; + res.setHeader("Content-Type", "text/plain"); + res.end("Forbidden: IP blocked"); + return; + } + + // Check request rate limit + const requestCheck = shield.checkRequest(ctx); + if (!requestCheck.allowed) { + res.statusCode = 429; + res.setHeader("Content-Type", "text/plain"); + res.setHeader("Retry-After", String(Math.ceil((requestCheck.rateLimitInfo?.retryAfterMs ?? 60000) / 1000))); + res.end("Too Many Requests"); + return; + } + + next(); +} + +/** + * Connection rate limit check + * Call this when accepting new connections + */ +export function checkConnectionRateLimit(req: IncomingMessage): { + allowed: boolean; + reason?: string; +} { + const shield = getSecurityShield(); + + if (!shield.isEnabled()) { + return { allowed: true }; + } + + const ctx = createSecurityContext(req); + const result = shield.checkConnection(ctx); + + return { + allowed: result.allowed, + reason: result.reason, + }; +} + +/** + * Authentication rate limit check + * Call this before processing authentication + */ +export function checkAuthRateLimit(req: IncomingMessage, deviceId?: string): { + allowed: boolean; + reason?: string; + retryAfterMs?: number; +} { + const shield = getSecurityShield(); + + if (!shield.isEnabled()) { + return { allowed: true }; + } + + const ctx = createSecurityContext(req); + if (deviceId) { + ctx.deviceId = deviceId; + } + + const result = shield.checkAuthAttempt(ctx); + + return { + allowed: result.allowed, + reason: result.reason, + retryAfterMs: result.rateLimitInfo?.retryAfterMs, + }; +} + +/** + * Log failed authentication + * Call this after authentication fails + */ +export function logAuthFailure(req: IncomingMessage, reason: string, deviceId?: string): void { + const shield = getSecurityShield(); + + if (!shield.isEnabled()) { + return; + } + + const ctx = createSecurityContext(req); + if (deviceId) { + ctx.deviceId = deviceId; + } + + shield.logAuthFailure(ctx, reason); +} + +/** + * Pairing rate limit check + */ +export function checkPairingRateLimit(params: { + channel: string; + sender: string; + ip: string; +}): { + allowed: boolean; + reason?: string; +} { + const shield = getSecurityShield(); + + if (!shield.isEnabled()) { + return { allowed: true }; + } + + const result = shield.checkPairingRequest(params); + + return { + allowed: result.allowed, + reason: result.reason, + }; +} + +/** + * Webhook rate limit check + */ +export function checkWebhookRateLimit(params: { + token: string; + path: string; + ip: string; +}): { + allowed: boolean; + reason?: string; + retryAfterMs?: number; +} { + const shield = getSecurityShield(); + + if (!shield.isEnabled()) { + return { allowed: true }; + } + + const result = shield.checkWebhook(params); + + return { + allowed: result.allowed, + reason: result.reason, + retryAfterMs: result.rateLimitInfo?.retryAfterMs, + }; +} diff --git a/src/security/shield.ts b/src/security/shield.ts new file mode 100644 index 000000000..61b367bd9 --- /dev/null +++ b/src/security/shield.ts @@ -0,0 +1,470 @@ +/** + * Security shield coordinator + * Main entry point for all security checks + */ + +import type { IncomingMessage } from "node:http"; +import { rateLimiter, RateLimitKeys, type RateLimit, type RateLimitResult } from "./rate-limiter.js"; +import { ipManager } from "./ip-manager.js"; +import { intrusionDetector } from "./intrusion-detector.js"; +import { securityLogger } from "./events/logger.js"; +import { SecurityActions } from "./events/schema.js"; +import type { SecurityShieldConfig } from "../config/types.security.js"; +import { DEFAULT_SECURITY_CONFIG } from "../config/types.security.js"; + +export interface SecurityContext { + ip: string; + deviceId?: string; + userId?: string; + userAgent?: string; + requestId?: string; +} + +export interface SecurityCheckResult { + allowed: boolean; + reason?: string; + rateLimitInfo?: RateLimitResult; +} + +/** + * Security shield - coordinates all security checks + */ +export class SecurityShield { + private config: Required; + + constructor(config?: SecurityShieldConfig) { + this.config = { + enabled: config?.enabled ?? DEFAULT_SECURITY_CONFIG.shield.enabled, + rateLimiting: config?.rateLimiting ?? DEFAULT_SECURITY_CONFIG.shield.rateLimiting, + intrusionDetection: config?.intrusionDetection ?? DEFAULT_SECURITY_CONFIG.shield.intrusionDetection, + ipManagement: config?.ipManagement ?? DEFAULT_SECURITY_CONFIG.shield.ipManagement, + }; + } + + /** + * Check if security shield is enabled + */ + isEnabled(): boolean { + return this.config.enabled; + } + + /** + * Check if an IP is blocked + */ + isIpBlocked(ip: string): boolean { + if (!this.config.enabled) return false; + return ipManager.isBlocked(ip) !== null; + } + + /** + * Check authentication attempt + */ + checkAuthAttempt(ctx: SecurityContext): SecurityCheckResult { + if (!this.config.enabled) { + return { allowed: true }; + } + + const { ip } = ctx; + + // Check IP blocklist + const blockReason = ipManager.isBlocked(ip); + if (blockReason) { + return { + allowed: false, + reason: `IP blocked: ${blockReason}`, + }; + } + + // Rate limit per-IP + if (this.config.rateLimiting?.enabled && this.config.rateLimiting.perIp?.authAttempts) { + const limit = this.config.rateLimiting.perIp.authAttempts; + const result = rateLimiter.check(RateLimitKeys.authAttempt(ip), limit); + + if (!result.allowed) { + securityLogger.logRateLimit({ + action: SecurityActions.RATE_LIMIT_EXCEEDED, + ip, + outcome: "deny", + severity: "warn", + resource: "auth", + details: { + limit: limit.max, + windowMs: limit.windowMs, + retryAfterMs: result.retryAfterMs, + }, + deviceId: ctx.deviceId, + requestId: ctx.requestId, + }); + + return { + allowed: false, + reason: "Rate limit exceeded", + rateLimitInfo: result, + }; + } + } + + // Rate limit per-device (if deviceId provided) + if (ctx.deviceId && this.config.rateLimiting?.enabled && this.config.rateLimiting.perDevice?.authAttempts) { + const limit = this.config.rateLimiting.perDevice.authAttempts; + const result = rateLimiter.check(RateLimitKeys.authAttemptDevice(ctx.deviceId), limit); + + if (!result.allowed) { + securityLogger.logRateLimit({ + action: SecurityActions.RATE_LIMIT_EXCEEDED, + ip, + outcome: "deny", + severity: "warn", + resource: "auth", + details: { + limit: limit.max, + windowMs: limit.windowMs, + retryAfterMs: result.retryAfterMs, + }, + deviceId: ctx.deviceId, + requestId: ctx.requestId, + }); + + return { + allowed: false, + reason: "Rate limit exceeded (device)", + rateLimitInfo: result, + }; + } + } + + return { allowed: true }; + } + + /** + * Log failed authentication attempt + * Triggers intrusion detection for brute force + */ + logAuthFailure(ctx: SecurityContext, reason: string): void { + if (!this.config.enabled) return; + + const { ip } = ctx; + + // Log the failure + securityLogger.logAuth({ + action: SecurityActions.AUTH_FAILED, + ip, + outcome: "deny", + severity: "warn", + resource: "gateway_auth", + details: { reason }, + deviceId: ctx.deviceId, + userId: ctx.userId, + userAgent: ctx.userAgent, + requestId: ctx.requestId, + }); + + // Check for brute force pattern + if (this.config.intrusionDetection?.enabled) { + const event = { + timestamp: new Date().toISOString(), + eventId: "", + severity: "warn" as const, + category: "authentication" as const, + ip, + action: SecurityActions.AUTH_FAILED, + resource: "gateway_auth", + outcome: "deny" as const, + details: { reason }, + }; + + intrusionDetector.checkBruteForce({ ip, event }); + } + } + + /** + * Check connection rate limit + */ + checkConnection(ctx: SecurityContext): SecurityCheckResult { + if (!this.config.enabled) { + return { allowed: true }; + } + + const { ip } = ctx; + + // Check IP blocklist + const blockReason = ipManager.isBlocked(ip); + if (blockReason) { + return { + allowed: false, + reason: `IP blocked: ${blockReason}`, + }; + } + + // Rate limit connections + if (this.config.rateLimiting?.enabled && this.config.rateLimiting.perIp?.connections) { + const limit = this.config.rateLimiting.perIp.connections; + const result = rateLimiter.check(RateLimitKeys.connection(ip), limit); + + if (!result.allowed) { + securityLogger.logRateLimit({ + action: SecurityActions.CONNECTION_LIMIT_EXCEEDED, + ip, + outcome: "deny", + severity: "warn", + resource: "gateway_connection", + details: { + limit: limit.max, + windowMs: limit.windowMs, + }, + requestId: ctx.requestId, + }); + + // Check for port scanning + if (this.config.intrusionDetection?.enabled) { + const event = { + timestamp: new Date().toISOString(), + eventId: "", + severity: "warn" as const, + category: "network_access" as const, + ip, + action: SecurityActions.CONNECTION_LIMIT_EXCEEDED, + resource: "gateway_connection", + outcome: "deny" as const, + details: {}, + }; + + intrusionDetector.checkPortScanning({ ip, event }); + } + + return { + allowed: false, + reason: "Connection rate limit exceeded", + rateLimitInfo: result, + }; + } + } + + return { allowed: true }; + } + + /** + * Check request rate limit + */ + checkRequest(ctx: SecurityContext): SecurityCheckResult { + if (!this.config.enabled) { + return { allowed: true }; + } + + const { ip } = ctx; + + // Check IP blocklist + const blockReason = ipManager.isBlocked(ip); + if (blockReason) { + return { + allowed: false, + reason: `IP blocked: ${blockReason}`, + }; + } + + // Rate limit requests + if (this.config.rateLimiting?.enabled && this.config.rateLimiting.perIp?.requests) { + const limit = this.config.rateLimiting.perIp.requests; + const result = rateLimiter.check(RateLimitKeys.request(ip), limit); + + if (!result.allowed) { + securityLogger.logRateLimit({ + action: SecurityActions.RATE_LIMIT_EXCEEDED, + ip, + outcome: "deny", + severity: "warn", + resource: "gateway_request", + details: { + limit: limit.max, + windowMs: limit.windowMs, + }, + requestId: ctx.requestId, + }); + + return { + allowed: false, + reason: "Request rate limit exceeded", + rateLimitInfo: result, + }; + } + } + + return { allowed: true }; + } + + /** + * Check pairing request rate limit + */ + checkPairingRequest(params: { + channel: string; + sender: string; + ip: string; + }): SecurityCheckResult { + if (!this.config.enabled) { + return { allowed: true }; + } + + const { channel, sender, ip } = params; + + // Check IP blocklist + const blockReason = ipManager.isBlocked(ip); + if (blockReason) { + return { + allowed: false, + reason: `IP blocked: ${blockReason}`, + }; + } + + // Rate limit pairing requests + if (this.config.rateLimiting?.enabled && this.config.rateLimiting.perSender?.pairingRequests) { + const limit = this.config.rateLimiting.perSender.pairingRequests; + const result = rateLimiter.check(RateLimitKeys.pairingRequest(channel, sender), limit); + + if (!result.allowed) { + securityLogger.logPairing({ + action: SecurityActions.PAIRING_RATE_LIMIT, + ip, + outcome: "deny", + severity: "warn", + details: { + channel, + sender, + limit: limit.max, + windowMs: limit.windowMs, + }, + }); + + return { + allowed: false, + reason: "Pairing rate limit exceeded", + rateLimitInfo: result, + }; + } + } + + return { allowed: true }; + } + + /** + * Check webhook rate limit + */ + checkWebhook(params: { + token: string; + path: string; + ip: string; + }): SecurityCheckResult { + if (!this.config.enabled) { + return { allowed: true }; + } + + const { token, path, ip } = params; + + // Check IP blocklist + const blockReason = ipManager.isBlocked(ip); + if (blockReason) { + return { + allowed: false, + reason: `IP blocked: ${blockReason}`, + }; + } + + // Rate limit per token + if (this.config.rateLimiting?.enabled && this.config.rateLimiting.webhook?.perToken) { + const limit = this.config.rateLimiting.webhook.perToken; + const result = rateLimiter.check(RateLimitKeys.webhookToken(token), limit); + + if (!result.allowed) { + securityLogger.logRateLimit({ + action: SecurityActions.RATE_LIMIT_EXCEEDED, + ip, + outcome: "deny", + severity: "warn", + resource: `webhook:${path}`, + details: { + token: `${token.substring(0, 8)}...`, + limit: limit.max, + windowMs: limit.windowMs, + }, + }); + + return { + allowed: false, + reason: "Webhook rate limit exceeded (token)", + rateLimitInfo: result, + }; + } + } + + // Rate limit per path + if (this.config.rateLimiting?.enabled && this.config.rateLimiting.webhook?.perPath) { + const limit = this.config.rateLimiting.webhook.perPath; + const result = rateLimiter.check(RateLimitKeys.webhookPath(path), limit); + + if (!result.allowed) { + securityLogger.logRateLimit({ + action: SecurityActions.RATE_LIMIT_EXCEEDED, + ip, + outcome: "deny", + severity: "warn", + resource: `webhook:${path}`, + details: { + limit: limit.max, + windowMs: limit.windowMs, + }, + }); + + return { + allowed: false, + reason: "Webhook rate limit exceeded (path)", + rateLimitInfo: result, + }; + } + } + + return { allowed: true }; + } + + /** + * Extract IP from request + */ + static extractIp(req: IncomingMessage): string { + // Try X-Forwarded-For first (if behind proxy) + const forwarded = req.headers["x-forwarded-for"]; + if (forwarded) { + const ips = typeof forwarded === "string" ? forwarded.split(",") : forwarded; + const clientIp = ips[0]?.trim(); + if (clientIp) return clientIp; + } + + // Try X-Real-IP + const realIp = req.headers["x-real-ip"]; + if (realIp && typeof realIp === "string") { + return realIp.trim(); + } + + // Fall back to socket remote address + return req.socket?.remoteAddress ?? "unknown"; + } +} + +/** + * Singleton security shield instance + */ +export let securityShield: SecurityShield; + +/** + * Initialize security shield with config + */ +export function initSecurityShield(config?: SecurityShieldConfig): void { + securityShield = new SecurityShield(config); +} + +/** + * Get security shield instance (creates default if not initialized) + */ +export function getSecurityShield(): SecurityShield { + if (!securityShield) { + securityShield = new SecurityShield(); + } + return securityShield; +}