feat(security): add security shield coordinator and middleware

Add main security shield that coordinates all security checks:
- IP blocklist checking
- Rate limiting (auth, connections, requests, webhooks, pairing)
- Intrusion detection integration
- Security event logging

Add HTTP middleware for Express/HTTP integration:
- Request rate limiting middleware
- Connection rate limit checks
- Auth rate limit checks
- Webhook rate limit checks
- Pairing rate limit checks

Features:
- Extract IP from X-Forwarded-For/X-Real-IP headers
- Security context creation from requests
- Unified API for all security checks

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Ulrich Diedrichsen 2026-01-30 10:38:45 +01:00
parent 6c6d11c354
commit 79597b7a98
2 changed files with 650 additions and 0 deletions

180
src/security/middleware.ts Normal file
View File

@ -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,
};
}

470
src/security/shield.ts Normal file
View File

@ -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<SecurityShieldConfig>;
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;
}