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:
parent
6c6d11c354
commit
79597b7a98
180
src/security/middleware.ts
Normal file
180
src/security/middleware.ts
Normal 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
470
src/security/shield.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user