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>
385 lines
8.1 KiB
TypeScript
385 lines
8.1 KiB
TypeScript
/**
|
|
* IP blocklist and allowlist management
|
|
* File-based storage with auto-expiration
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
|
|
import { securityLogger } from "./events/logger.js";
|
|
import { SecurityActions } from "./events/schema.js";
|
|
|
|
const BLOCKLIST_FILE = "blocklist.json";
|
|
const SECURITY_DIR_NAME = "security";
|
|
|
|
export interface BlocklistEntry {
|
|
ip: string;
|
|
reason: string;
|
|
blockedAt: string; // ISO 8601
|
|
expiresAt: string; // ISO 8601
|
|
source: "auto" | "manual";
|
|
eventId?: string;
|
|
}
|
|
|
|
export interface AllowlistEntry {
|
|
ip: string;
|
|
reason: string;
|
|
addedAt: string; // ISO 8601
|
|
source: "auto" | "manual";
|
|
}
|
|
|
|
export interface IpListStore {
|
|
version: number;
|
|
blocklist: BlocklistEntry[];
|
|
allowlist: AllowlistEntry[];
|
|
}
|
|
|
|
/**
|
|
* Get security directory path
|
|
*/
|
|
function getSecurityDir(stateDir?: string): string {
|
|
const base = stateDir ?? path.join(os.homedir(), ".openclaw");
|
|
return path.join(base, SECURITY_DIR_NAME);
|
|
}
|
|
|
|
/**
|
|
* Get blocklist file path
|
|
*/
|
|
function getBlocklistPath(stateDir?: string): string {
|
|
return path.join(getSecurityDir(stateDir), BLOCKLIST_FILE);
|
|
}
|
|
|
|
/**
|
|
* Load IP list store from disk
|
|
*/
|
|
function loadStore(stateDir?: string): IpListStore {
|
|
const filePath = getBlocklistPath(stateDir);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return {
|
|
version: 1,
|
|
blocklist: [],
|
|
allowlist: [],
|
|
};
|
|
}
|
|
|
|
try {
|
|
const content = fs.readFileSync(filePath, "utf8");
|
|
return JSON.parse(content) as IpListStore;
|
|
} catch {
|
|
// If file is corrupted, start fresh
|
|
return {
|
|
version: 1,
|
|
blocklist: [],
|
|
allowlist: [],
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save IP list store to disk
|
|
*/
|
|
function saveStore(store: IpListStore, stateDir?: string): void {
|
|
const filePath = getBlocklistPath(stateDir);
|
|
const dir = path.dirname(filePath);
|
|
|
|
// Ensure directory exists with proper permissions
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
}
|
|
|
|
// Write with proper permissions
|
|
fs.writeFileSync(filePath, JSON.stringify(store, null, 2), {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if an IP matches a CIDR block
|
|
*/
|
|
function ipMatchesCidr(ip: string, cidr: string): boolean {
|
|
// Simple exact match for non-CIDR entries
|
|
if (!cidr.includes("/")) {
|
|
return ip === cidr;
|
|
}
|
|
|
|
// Parse CIDR notation
|
|
const [network, bits] = cidr.split("/");
|
|
const maskBits = parseInt(bits, 10);
|
|
|
|
if (isNaN(maskBits)) return false;
|
|
|
|
// Convert IPs to numbers for comparison
|
|
const ipNum = ipToNumber(ip);
|
|
const networkNum = ipToNumber(network);
|
|
|
|
if (ipNum === null || networkNum === null) return false;
|
|
|
|
// Calculate mask
|
|
const mask = -1 << (32 - maskBits);
|
|
|
|
// Check if IP is in network
|
|
return (ipNum & mask) === (networkNum & mask);
|
|
}
|
|
|
|
/**
|
|
* Convert IPv4 address to number
|
|
*/
|
|
function ipToNumber(ip: string): number | null {
|
|
const parts = ip.split(".");
|
|
if (parts.length !== 4) return null;
|
|
|
|
let num = 0;
|
|
for (const part of parts) {
|
|
const val = parseInt(part, 10);
|
|
if (isNaN(val) || val < 0 || val > 255) return null;
|
|
num = num * 256 + val;
|
|
}
|
|
|
|
return num;
|
|
}
|
|
|
|
/**
|
|
* IP manager for blocklist and allowlist
|
|
*/
|
|
export class IpManager {
|
|
private store: IpListStore;
|
|
private stateDir?: string;
|
|
|
|
constructor(params?: { stateDir?: string }) {
|
|
this.stateDir = params?.stateDir;
|
|
this.store = loadStore(this.stateDir);
|
|
|
|
// Clean up expired entries on load
|
|
this.cleanupExpired();
|
|
}
|
|
|
|
/**
|
|
* Check if an IP is blocked
|
|
* Returns block reason if blocked, null otherwise
|
|
*/
|
|
isBlocked(ip: string): string | null {
|
|
// Allowlist overrides blocklist
|
|
if (this.isAllowed(ip)) {
|
|
return null;
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
for (const entry of this.store.blocklist) {
|
|
if (entry.ip === ip && entry.expiresAt > now) {
|
|
return entry.reason;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if an IP is in the allowlist
|
|
*/
|
|
isAllowed(ip: string): boolean {
|
|
// Localhost is always allowed
|
|
if (ip === "127.0.0.1" || ip === "::1" || ip === "localhost") {
|
|
return true;
|
|
}
|
|
|
|
for (const entry of this.store.allowlist) {
|
|
if (ipMatchesCidr(ip, entry.ip)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Block an IP address
|
|
*/
|
|
blockIp(params: {
|
|
ip: string;
|
|
reason: string;
|
|
durationMs: number;
|
|
source?: "auto" | "manual";
|
|
eventId?: string;
|
|
}): void {
|
|
const { ip, reason, durationMs, source = "auto", eventId } = params;
|
|
|
|
// Don't block if allowlisted
|
|
if (this.isAllowed(ip)) {
|
|
return;
|
|
}
|
|
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + durationMs);
|
|
|
|
// Remove existing block for this IP
|
|
this.store.blocklist = this.store.blocklist.filter((e) => e.ip !== ip);
|
|
|
|
// Add new block
|
|
this.store.blocklist.push({
|
|
ip,
|
|
reason,
|
|
blockedAt: now.toISOString(),
|
|
expiresAt: expiresAt.toISOString(),
|
|
source,
|
|
eventId,
|
|
});
|
|
|
|
this.save();
|
|
|
|
// Log event
|
|
securityLogger.logIpManagement({
|
|
action: SecurityActions.IP_BLOCKED,
|
|
ip,
|
|
severity: "warn",
|
|
details: {
|
|
reason,
|
|
expiresAt: expiresAt.toISOString(),
|
|
source,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Unblock an IP address
|
|
*/
|
|
unblockIp(ip: string): boolean {
|
|
const before = this.store.blocklist.length;
|
|
this.store.blocklist = this.store.blocklist.filter((e) => e.ip !== ip);
|
|
const removed = before !== this.store.blocklist.length;
|
|
|
|
if (removed) {
|
|
this.save();
|
|
|
|
securityLogger.logIpManagement({
|
|
action: SecurityActions.IP_UNBLOCKED,
|
|
ip,
|
|
severity: "info",
|
|
details: {},
|
|
});
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
/**
|
|
* Add IP to allowlist
|
|
*/
|
|
allowIp(params: {
|
|
ip: string;
|
|
reason: string;
|
|
source?: "auto" | "manual";
|
|
}): void {
|
|
const { ip, reason, source = "manual" } = params;
|
|
|
|
// Check if already in allowlist
|
|
const exists = this.store.allowlist.some((e) => e.ip === ip);
|
|
if (exists) return;
|
|
|
|
this.store.allowlist.push({
|
|
ip,
|
|
reason,
|
|
addedAt: new Date().toISOString(),
|
|
source,
|
|
});
|
|
|
|
this.save();
|
|
|
|
securityLogger.logIpManagement({
|
|
action: SecurityActions.IP_ALLOWLISTED,
|
|
ip,
|
|
severity: "info",
|
|
details: { reason, source },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove IP from allowlist
|
|
*/
|
|
removeFromAllowlist(ip: string): boolean {
|
|
const before = this.store.allowlist.length;
|
|
this.store.allowlist = this.store.allowlist.filter((e) => e.ip !== ip);
|
|
const removed = before !== this.store.allowlist.length;
|
|
|
|
if (removed) {
|
|
this.save();
|
|
|
|
securityLogger.logIpManagement({
|
|
action: SecurityActions.IP_REMOVED_FROM_ALLOWLIST,
|
|
ip,
|
|
severity: "info",
|
|
details: {},
|
|
});
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
/**
|
|
* Get all blocked IPs (non-expired)
|
|
*/
|
|
getBlockedIps(): BlocklistEntry[] {
|
|
const now = new Date().toISOString();
|
|
return this.store.blocklist.filter((e) => e.expiresAt > now);
|
|
}
|
|
|
|
/**
|
|
* Get all allowlisted IPs
|
|
*/
|
|
getAllowedIps(): AllowlistEntry[] {
|
|
return this.store.allowlist;
|
|
}
|
|
|
|
/**
|
|
* Get blocklist entry for an IP
|
|
*/
|
|
getBlocklistEntry(ip: string): BlocklistEntry | null {
|
|
const now = new Date().toISOString();
|
|
return this.store.blocklist.find((e) => e.ip === ip && e.expiresAt > now) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Clean up expired blocklist entries
|
|
*/
|
|
cleanupExpired(): number {
|
|
const now = new Date().toISOString();
|
|
const before = this.store.blocklist.length;
|
|
|
|
this.store.blocklist = this.store.blocklist.filter((e) => e.expiresAt > now);
|
|
|
|
const removed = before - this.store.blocklist.length;
|
|
|
|
if (removed > 0) {
|
|
this.save();
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
/**
|
|
* Save store to disk
|
|
*/
|
|
private save(): void {
|
|
saveStore(this.store, this.stateDir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Singleton IP manager instance
|
|
*/
|
|
export const ipManager = new IpManager();
|
|
|
|
/**
|
|
* Auto-add Tailscale CGNAT range to allowlist
|
|
*/
|
|
export function ensureTailscaleAllowlist(manager: IpManager = ipManager): void {
|
|
manager.allowIp({
|
|
ip: "100.64.0.0/10",
|
|
reason: "tailscale",
|
|
source: "auto",
|
|
});
|
|
}
|