openclaw/src/security/shield.ts
Ulrich Diedrichsen b10174ace0 test(security): fix failing tests
- Add CIDR matching to isBlocked() and getBlocklistEntry() methods
- Fix event aggregator threshold logic to only trigger once on first crossing
- Add securityEventAggregator.clearAll() in intrusion-detector tests
- Fix RateLimiter constructor to accept custom maxSize parameter
- Fix token bucket getRetryAfterMs() to return Infinity for impossible requests
- Fix rate limiter peek() to return full capacity for non-existent keys
- Fix shield extractIp() to handle array X-Forwarded-For headers
- Fix ip-manager test mocks to include sync fs methods
- All security tests now passing (173 tests across 8 files)
2026-01-30 12:09:26 +01:00

474 lines
12 KiB
TypeScript

/**
* Security shield coordinator
* Main entry point for all security checks
*/
import type { IncomingMessage } from "node:http";
import { rateLimiter, RateLimitKeys, 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,
} as Required<SecurityShieldConfig>;
}
/**
* 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) {
// Handle both string and array cases (array can come from some proxies)
const forwardedStr = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const ips = typeof forwardedStr === "string" ? forwardedStr.split(",") : [];
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;
}