Add foundational security components for rate limiting, intrusion detection, and activity logging: Core Components: - Security event logging system (schema, logger, aggregator) - Rate limiting with token bucket + sliding window algorithm - IP blocklist/allowlist management with auto-expiration - Security configuration schema with opt-out mode defaults Features: - JSONL security log files (/tmp/openclaw/security-*.jsonl) - LRU cache-based rate limiter (10k entry limit, auto-cleanup) - File-based IP blocklist storage (~/.openclaw/security/blocklist.json) - Tailscale CGNAT range auto-allowlisted (100.64.0.0/10) - Configurable rate limits per-IP, per-device, per-sender - Auto-blocking rules with configurable duration Configuration: - New security config section in OpenClawConfig - Enabled by default for new deployments (opt-out mode) - Comprehensive defaults for VPS security Related to: Security shield implementation plan Part of: Phase 1 - Core Features Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
260 lines
5.7 KiB
TypeScript
260 lines
5.7 KiB
TypeScript
/**
|
|
* Rate limiter with token bucket + sliding window
|
|
* Uses LRU cache to prevent memory exhaustion
|
|
*/
|
|
|
|
import { TokenBucket, createTokenBucket } from "./token-bucket.js";
|
|
|
|
export interface RateLimit {
|
|
max: number;
|
|
windowMs: number;
|
|
}
|
|
|
|
export interface RateLimitResult {
|
|
allowed: boolean;
|
|
retryAfterMs?: number;
|
|
remaining: number;
|
|
resetAt: Date;
|
|
}
|
|
|
|
interface CacheEntry {
|
|
bucket: TokenBucket;
|
|
lastAccess: number;
|
|
}
|
|
|
|
const MAX_CACHE_SIZE = 10_000;
|
|
const CACHE_CLEANUP_INTERVAL_MS = 60_000; // 1 minute
|
|
const CACHE_TTL_MS = 120_000; // 2 minutes
|
|
|
|
/**
|
|
* LRU cache for rate limit buckets
|
|
*/
|
|
class LRUCache<K, V> {
|
|
private cache = new Map<K, V>();
|
|
private accessOrder: K[] = [];
|
|
|
|
constructor(private readonly maxSize: number) {}
|
|
|
|
get(key: K): V | undefined {
|
|
const value = this.cache.get(key);
|
|
if (value !== undefined) {
|
|
// Move to end (most recently used)
|
|
this.accessOrder = this.accessOrder.filter((k) => k !== key);
|
|
this.accessOrder.push(key);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
set(key: K, value: V): void {
|
|
// If key exists, remove it from access order
|
|
if (this.cache.has(key)) {
|
|
this.accessOrder = this.accessOrder.filter((k) => k !== key);
|
|
}
|
|
|
|
// Add to cache
|
|
this.cache.set(key, value);
|
|
this.accessOrder.push(key);
|
|
|
|
// Evict least recently used if over capacity
|
|
while (this.cache.size > this.maxSize && this.accessOrder.length > 0) {
|
|
const lru = this.accessOrder.shift();
|
|
if (lru !== undefined) {
|
|
this.cache.delete(lru);
|
|
}
|
|
}
|
|
}
|
|
|
|
delete(key: K): boolean {
|
|
this.accessOrder = this.accessOrder.filter((k) => k !== key);
|
|
return this.cache.delete(key);
|
|
}
|
|
|
|
clear(): void {
|
|
this.cache.clear();
|
|
this.accessOrder = [];
|
|
}
|
|
|
|
size(): number {
|
|
return this.cache.size;
|
|
}
|
|
|
|
keys(): K[] {
|
|
return Array.from(this.cache.keys());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rate limiter using token bucket algorithm
|
|
*/
|
|
export class RateLimiter {
|
|
private buckets = new LRUCache<string, CacheEntry>(MAX_CACHE_SIZE);
|
|
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
|
|
constructor() {
|
|
this.startCleanup();
|
|
}
|
|
|
|
/**
|
|
* Check if a request should be allowed
|
|
* Returns rate limit result
|
|
*/
|
|
check(key: string, limit: RateLimit): RateLimitResult {
|
|
const entry = this.getOrCreateEntry(key, limit);
|
|
const allowed = entry.bucket.consume(1);
|
|
const remaining = entry.bucket.getTokens();
|
|
const retryAfterMs = allowed ? undefined : entry.bucket.getRetryAfterMs(1);
|
|
const resetAt = new Date(Date.now() + limit.windowMs);
|
|
|
|
entry.lastAccess = Date.now();
|
|
|
|
return {
|
|
allowed,
|
|
retryAfterMs,
|
|
remaining: Math.max(0, Math.floor(remaining)),
|
|
resetAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check without consuming (peek)
|
|
*/
|
|
peek(key: string, limit: RateLimit): RateLimitResult {
|
|
const entry = this.buckets.get(key);
|
|
|
|
if (!entry) {
|
|
// Not rate limited yet
|
|
return {
|
|
allowed: true,
|
|
remaining: limit.max - 1,
|
|
resetAt: new Date(Date.now() + limit.windowMs),
|
|
};
|
|
}
|
|
|
|
const remaining = entry.bucket.getTokens();
|
|
const wouldAllow = remaining >= 1;
|
|
const retryAfterMs = wouldAllow ? undefined : entry.bucket.getRetryAfterMs(1);
|
|
const resetAt = new Date(Date.now() + limit.windowMs);
|
|
|
|
return {
|
|
allowed: wouldAllow,
|
|
retryAfterMs,
|
|
remaining: Math.max(0, Math.floor(remaining)),
|
|
resetAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reset rate limit for a key
|
|
*/
|
|
reset(key: string): void {
|
|
this.buckets.delete(key);
|
|
}
|
|
|
|
/**
|
|
* Reset all rate limits
|
|
*/
|
|
resetAll(): void {
|
|
this.buckets.clear();
|
|
}
|
|
|
|
/**
|
|
* Get current cache size
|
|
*/
|
|
getCacheSize(): number {
|
|
return this.buckets.size();
|
|
}
|
|
|
|
/**
|
|
* Get statistics
|
|
*/
|
|
getStats(): {
|
|
cacheSize: number;
|
|
maxCacheSize: number;
|
|
} {
|
|
return {
|
|
cacheSize: this.buckets.size(),
|
|
maxCacheSize: MAX_CACHE_SIZE,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Stop cleanup interval (for testing)
|
|
*/
|
|
stop(): void {
|
|
if (this.cleanupInterval) {
|
|
clearInterval(this.cleanupInterval);
|
|
this.cleanupInterval = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get or create cache entry for a key
|
|
*/
|
|
private getOrCreateEntry(key: string, limit: RateLimit): CacheEntry {
|
|
let entry = this.buckets.get(key);
|
|
|
|
if (!entry) {
|
|
entry = {
|
|
bucket: createTokenBucket(limit),
|
|
lastAccess: Date.now(),
|
|
};
|
|
this.buckets.set(key, entry);
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* Start periodic cleanup of stale entries
|
|
*/
|
|
private startCleanup(): void {
|
|
if (this.cleanupInterval) return;
|
|
|
|
this.cleanupInterval = setInterval(() => {
|
|
this.cleanup();
|
|
}, CACHE_CLEANUP_INTERVAL_MS);
|
|
|
|
// Don't keep process alive for cleanup
|
|
if (this.cleanupInterval.unref) {
|
|
this.cleanupInterval.unref();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up stale cache entries
|
|
*/
|
|
private cleanup(): void {
|
|
const now = Date.now();
|
|
const keysToDelete: string[] = [];
|
|
|
|
for (const key of this.buckets.keys()) {
|
|
const entry = this.buckets.get(key);
|
|
if (entry && now - entry.lastAccess > CACHE_TTL_MS) {
|
|
keysToDelete.push(key);
|
|
}
|
|
}
|
|
|
|
for (const key of keysToDelete) {
|
|
this.buckets.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Singleton rate limiter instance
|
|
*/
|
|
export const rateLimiter = new RateLimiter();
|
|
|
|
/**
|
|
* Rate limit key generators
|
|
*/
|
|
export const RateLimitKeys = {
|
|
authAttempt: (ip: string) => `auth:${ip}`,
|
|
authAttemptDevice: (deviceId: string) => `auth:device:${deviceId}`,
|
|
connection: (ip: string) => `conn:${ip}`,
|
|
request: (ip: string) => `req:${ip}`,
|
|
pairingRequest: (channel: string, sender: string) => `pair:${channel}:${sender}`,
|
|
webhookToken: (token: string) => `hook:token:${token}`,
|
|
webhookPath: (path: string) => `hook:path:${path}`,
|
|
} as const;
|