From 874caa32bca620ffddbd5d713cca068dfe7f5964 Mon Sep 17 00:00:00 2001 From: ronitchidara Date: Tue, 27 Jan 2026 13:09:31 +0530 Subject: [PATCH] security: add gateway rate limiting and security warnings - Implement token bucket rate limiter with per-client tracking - Add configurable limits: unauthenticated (60/min), authenticated (unlimited) - Add channel message rate limiting (200/min per channel) - Implement exponential backoff after auth failures (1s base, 60s max) - Add startup security warnings for non-loopback binding without auth - Add GatewayRateLimitConfig type to config schema - All settings configurable via gateway.rateLimit in clawdbot.json Co-Authored-By: Claude Opus 4.5 --- CLAWDBOT-SECURITY-PLAN.md | 54 ++-- src/config/types.gateway.ts | 47 ++++ src/gateway/rate-limit.test.ts | 306 +++++++++++++++++++++ src/gateway/rate-limit.ts | 361 +++++++++++++++++++++++++ src/gateway/server-startup-log.test.ts | 205 ++++++++++++++ src/gateway/server-startup-log.ts | 63 +++++ src/gateway/server.impl.ts | 15 +- 7 files changed, 1020 insertions(+), 31 deletions(-) create mode 100644 src/gateway/rate-limit.test.ts create mode 100644 src/gateway/rate-limit.ts create mode 100644 src/gateway/server-startup-log.test.ts diff --git a/CLAWDBOT-SECURITY-PLAN.md b/CLAWDBOT-SECURITY-PLAN.md index a7ea03ae2..36c011279 100644 --- a/CLAWDBOT-SECURITY-PLAN.md +++ b/CLAWDBOT-SECURITY-PLAN.md @@ -81,38 +81,28 @@ git worktree add ../clawdbot-ux -b phase3-ux - Add migration CLI command: `clawdbot secrets migrate` - Update `clawdbot doctor` to check for unencrypted credentials +#### 1.4 Gateway Authentication & Rate Limiting +- **Files Created/Modified:** + - `src/gateway/rate-limit.ts` - New rate limiting module (~370 lines) + - `src/gateway/rate-limit.test.ts` - 19 tests + - `src/gateway/server-startup-log.ts` - Added security warnings + - `src/gateway/server-startup-log.test.ts` - 9 tests + - `src/gateway/server.impl.ts` - Integrated rate limiter and security warnings + - `src/config/types.gateway.ts` - Added GatewayRateLimitConfig type +- **Features:** + - Token bucket algorithm for smooth rate limiting with burst support + - Per-client tracking (separate buckets per IP/session) + - Configurable limits: unauthenticated (60/min), authenticated (unlimited by default) + - Channel message rate limiting (200/min per channel) + - Exponential backoff after auth failures (1s base, 60s max) + - Security warning at startup if binding to non-loopback without auth + - All settings configurable in `gateway.rateLimit` config +- **Commit:** TBD + --- ### ⏳ PENDING -#### 1.4 Gateway Authentication & Rate Limiting -**Objective:** Enforce authentication and add configurable rate limiting. - -**Files to Modify:** -- `src/gateway/auth.ts` - Enforce token+password for non-loopback bindings -- `src/gateway/server.impl.ts` - Add startup warning for public binding -- Create `src/gateway/rate-limit.ts` - Configurable rate limiting - -**Implementation Spec:** -```typescript -// Rate limit defaults (configurable in clawdbot.json) -{ - "gateway": { - "rateLimits": { - "unauthenticated": 60, // req/min (prevent brute-force) - "authenticated": 0, // 0 = unlimited (power user flexibility) - "channelMessages": 200, // per channel per minute - "burstMultiplier": 2 // allow 2x burst for short spikes - } - } -} -``` - -**Key Behaviors:** -- Exponential backoff ONLY after failed auth (not normal requests) -- Log warning at startup if binding to non-loopback without auth -- Allow authenticated users to remain unlimited by default - #### 1.5 Pairing & Approval Hardening **Objective:** Increase entropy and add replay protection. @@ -222,8 +212,12 @@ pnpm build | 1.3 | src/telegram/accounts.ts | ✅ | +5 | | 1.3 | src/slack/accounts.ts | ✅ | +40 | | 1.3 | src/web/auth-store.ts | ✅ | +120 | -| 1.4 | src/gateway/auth.ts | ⏳ | TBD | -| 1.4 | src/gateway/rate-limit.ts | ⏳ | TBD | +| 1.4 | src/gateway/rate-limit.ts | ✅ | +370 | +| 1.4 | src/gateway/rate-limit.test.ts | ✅ | +310 | +| 1.4 | src/gateway/server-startup-log.ts | ✅ | +60 | +| 1.4 | src/gateway/server-startup-log.test.ts | ✅ | +170 | +| 1.4 | src/gateway/server.impl.ts | ✅ | +15 | +| 1.4 | src/config/types.gateway.ts | ✅ | +45 | | 1.5 | src/pairing/pairing-store.ts | ⏳ | TBD | | 1.6 | docs/security/*.md | ⏳ | TBD | diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index a0d562f7b..019126e1f 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -205,6 +205,51 @@ export type GatewayNodesConfig = { denyCommands?: string[]; }; +/** + * Rate limiting configuration for gateway requests. + * Defaults are generous for power users while preventing brute-force attacks. + */ +export type GatewayRateLimitConfig = { + /** + * Whether rate limiting is enabled (default: true). + * When disabled, all rate limits are bypassed. + */ + enabled?: boolean; + /** + * Max requests/minute for unauthenticated connections (default: 60). + * This primarily prevents brute-force auth attempts. + */ + unauthenticated?: number; + /** + * Max requests/minute for authenticated connections (default: 0 = unlimited). + * Power users typically want no limits after authentication. + */ + authenticated?: number; + /** + * Max messages/minute per channel (default: 200). + * Prevents runaway message loops or abuse. + */ + channelMessages?: number; + /** + * Burst multiplier for short spikes (default: 2). + * Allows up to rate * multiplier requests in short bursts. + */ + burstMultiplier?: number; + /** + * Max consecutive auth failures before exponential backoff (default: 5). + */ + authFailuresBeforeBackoff?: number; + /** + * Base backoff delay in ms after auth failures (default: 1000). + * Each subsequent failure doubles the delay up to maxBackoffMs. + */ + authBackoffBaseMs?: number; + /** + * Maximum backoff delay in ms (default: 60000 = 1 minute). + */ + authBackoffMaxMs?: number; +}; + export type GatewayConfig = { /** Single multiplexed port for Gateway WS + HTTP (default: 18789). */ port?: number; @@ -227,6 +272,8 @@ export type GatewayConfig = { customBindHost?: string; controlUi?: GatewayControlUiConfig; auth?: GatewayAuthConfig; + /** Rate limiting configuration for gateway requests. */ + rateLimit?: GatewayRateLimitConfig; tailscale?: GatewayTailscaleConfig; remote?: GatewayRemoteConfig; reload?: GatewayReloadConfig; diff --git a/src/gateway/rate-limit.test.ts b/src/gateway/rate-limit.test.ts new file mode 100644 index 000000000..9944007bb --- /dev/null +++ b/src/gateway/rate-limit.test.ts @@ -0,0 +1,306 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createGatewayRateLimiter, + DEFAULT_RATE_LIMIT_CONFIG, + GatewayRateLimiter, + resolveRateLimitConfig, +} from "./rate-limit.js"; + +describe("rate-limit config resolution", () => { + it("uses defaults when no config provided", () => { + const resolved = resolveRateLimitConfig(); + expect(resolved).toEqual(DEFAULT_RATE_LIMIT_CONFIG); + }); + + it("merges partial config with defaults", () => { + const resolved = resolveRateLimitConfig({ + enabled: false, + unauthenticated: 30, + }); + expect(resolved.enabled).toBe(false); + expect(resolved.unauthenticated).toBe(30); + expect(resolved.authenticated).toBe(DEFAULT_RATE_LIMIT_CONFIG.authenticated); + expect(resolved.channelMessages).toBe(DEFAULT_RATE_LIMIT_CONFIG.channelMessages); + }); + + it("respects all config overrides", () => { + const config = { + enabled: true, + unauthenticated: 10, + authenticated: 100, + channelMessages: 50, + burstMultiplier: 3, + authFailuresBeforeBackoff: 3, + authBackoffBaseMs: 500, + authBackoffMaxMs: 30000, + }; + const resolved = resolveRateLimitConfig(config); + expect(resolved).toEqual(config); + }); +}); + +describe("GatewayRateLimiter", () => { + let limiter: GatewayRateLimiter; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + limiter?.close(); + vi.useRealTimers(); + }); + + describe("basic rate limiting", () => { + it("allows requests when rate limit is disabled", () => { + limiter = createGatewayRateLimiter({ enabled: false }); + for (let i = 0; i < 1000; i++) { + const result = limiter.checkRequest("client1", false); + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(Infinity); + } + }); + + it("allows unlimited authenticated requests when authenticated=0", () => { + limiter = createGatewayRateLimiter({ authenticated: 0 }); + for (let i = 0; i < 1000; i++) { + const result = limiter.checkRequest("client1", true); + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(Infinity); + } + }); + + it("blocks unauthenticated requests after burst limit", () => { + limiter = createGatewayRateLimiter({ + unauthenticated: 10, + burstMultiplier: 2, + }); + + // Should allow 20 requests (10 * 2 burst) + for (let i = 0; i < 20; i++) { + const result = limiter.checkRequest("client1", false); + expect(result.allowed).toBe(true); + } + + // 21st request should be blocked + const blocked = limiter.checkRequest("client1", false); + expect(blocked.allowed).toBe(false); + expect(blocked.reason).toBe("rate_limit"); + expect(blocked.retryAfterMs).toBeGreaterThan(0); + }); + + it("refills tokens over time", () => { + limiter = createGatewayRateLimiter({ + unauthenticated: 60, // 1 per second + burstMultiplier: 1, + }); + + // Exhaust all tokens + for (let i = 0; i < 60; i++) { + limiter.checkRequest("client1", false); + } + + const blocked = limiter.checkRequest("client1", false); + expect(blocked.allowed).toBe(false); + + // Advance time by 30 seconds + vi.advanceTimersByTime(30 * 1000); + + // Should have ~30 tokens refilled + for (let i = 0; i < 30; i++) { + const result = limiter.checkRequest("client1", false); + expect(result.allowed).toBe(true); + } + }); + + it("tracks separate buckets per client", () => { + limiter = createGatewayRateLimiter({ + unauthenticated: 5, + burstMultiplier: 1, + }); + + // Exhaust client1's tokens + for (let i = 0; i < 5; i++) { + limiter.checkRequest("client1", false); + } + expect(limiter.checkRequest("client1", false).allowed).toBe(false); + + // client2 should still have tokens + expect(limiter.checkRequest("client2", false).allowed).toBe(true); + }); + }); + + describe("channel message rate limiting", () => { + it("limits channel messages separately from client requests", () => { + limiter = createGatewayRateLimiter({ + channelMessages: 5, + burstMultiplier: 1, + }); + + // Exhaust channel messages + for (let i = 0; i < 5; i++) { + const result = limiter.checkChannelMessage("telegram:123"); + expect(result.allowed).toBe(true); + } + + const blocked = limiter.checkChannelMessage("telegram:123"); + expect(blocked.allowed).toBe(false); + expect(blocked.reason).toBe("rate_limit"); + + // Different channel should still work + expect(limiter.checkChannelMessage("telegram:456").allowed).toBe(true); + }); + + it("allows unlimited channel messages when channelMessages=0", () => { + limiter = createGatewayRateLimiter({ channelMessages: 0 }); + for (let i = 0; i < 1000; i++) { + expect(limiter.checkChannelMessage("telegram:123").allowed).toBe(true); + } + }); + }); + + describe("auth failure backoff", () => { + it("does not apply backoff before threshold", () => { + limiter = createGatewayRateLimiter({ + authFailuresBeforeBackoff: 5, + }); + + // Record 4 failures + for (let i = 0; i < 4; i++) { + limiter.recordAuthFailure("client1"); + } + + const result = limiter.checkRequest("client1", false); + expect(result.allowed).toBe(true); + }); + + it("applies backoff after threshold failures", () => { + limiter = createGatewayRateLimiter({ + authFailuresBeforeBackoff: 3, + authBackoffBaseMs: 1000, + }); + + // Record 3 failures (threshold) + for (let i = 0; i < 3; i++) { + limiter.recordAuthFailure("client1"); + } + + const result = limiter.checkRequest("client1", false); + expect(result.allowed).toBe(false); + expect(result.reason).toBe("auth_backoff"); + expect(result.retryAfterMs).toBeGreaterThan(0); + }); + + it("applies exponential backoff for subsequent failures", () => { + limiter = createGatewayRateLimiter({ + authFailuresBeforeBackoff: 1, + authBackoffBaseMs: 1000, + authBackoffMaxMs: 60000, + }); + + // First failure at threshold + limiter.recordAuthFailure("client1"); + const state1 = limiter.getAuthBackoffState("client1"); + expect(state1?.backoffUntilMs).toBeGreaterThan(Date.now()); + + // Wait for backoff to expire + vi.advanceTimersByTime(1100); + + // Second failure + limiter.recordAuthFailure("client1"); + const state2 = limiter.getAuthBackoffState("client1"); + + // Backoff should be ~2x longer + expect(state2?.failures).toBe(2); + }); + + it("respects max backoff limit", () => { + limiter = createGatewayRateLimiter({ + authFailuresBeforeBackoff: 1, + authBackoffBaseMs: 10000, + authBackoffMaxMs: 20000, + }); + + // Record many failures + for (let i = 0; i < 10; i++) { + limiter.recordAuthFailure("client1"); + vi.advanceTimersByTime(100); + } + + const state = limiter.getAuthBackoffState("client1"); + const backoffDuration = state!.backoffUntilMs - Date.now(); + expect(backoffDuration).toBeLessThanOrEqual(20000); + }); + + it("clears backoff after successful auth", () => { + limiter = createGatewayRateLimiter({ + authFailuresBeforeBackoff: 1, + authBackoffBaseMs: 1000, + }); + + limiter.recordAuthFailure("client1"); + expect(limiter.getAuthBackoffState("client1")).not.toBeNull(); + + limiter.clearAuthFailure("client1"); + expect(limiter.getAuthBackoffState("client1")).toBeNull(); + }); + + it("resets failure counter after 10 minute gap", () => { + limiter = createGatewayRateLimiter({ + authFailuresBeforeBackoff: 3, + authBackoffBaseMs: 1000, + }); + + // Record 2 failures + limiter.recordAuthFailure("client1"); + limiter.recordAuthFailure("client1"); + + // Wait 11 minutes + vi.advanceTimersByTime(11 * 60 * 1000); + + // This should reset counter and count as first failure + limiter.recordAuthFailure("client1"); + + // Should not be in backoff yet + const result = limiter.checkRequest("client1", false); + expect(result.allowed).toBe(true); + }); + }); + + describe("config updates", () => { + it("updates config at runtime", () => { + limiter = createGatewayRateLimiter({ enabled: true }); + expect(limiter.getConfig().enabled).toBe(true); + + limiter.updateConfig({ enabled: false }); + expect(limiter.getConfig().enabled).toBe(false); + }); + }); + + describe("cleanup", () => { + it("cleans up stale buckets", () => { + limiter = createGatewayRateLimiter(); + + // Create some buckets + limiter.checkRequest("client1", false); + limiter.checkRequest("client2", false); + + // Advance time past cleanup threshold (10 min) + vi.advanceTimersByTime(11 * 60 * 1000); + + // Trigger cleanup (normally happens on interval, but we can check indirectly) + // After cleanup, new requests should create fresh buckets + const result = limiter.checkRequest("client1", false); + expect(result.allowed).toBe(true); + }); + }); +}); + +describe("createGatewayRateLimiter", () => { + it("creates a limiter instance", () => { + const limiter = createGatewayRateLimiter(); + expect(limiter).toBeInstanceOf(GatewayRateLimiter); + limiter.close(); + }); +}); diff --git a/src/gateway/rate-limit.ts b/src/gateway/rate-limit.ts new file mode 100644 index 000000000..59748c8ea --- /dev/null +++ b/src/gateway/rate-limit.ts @@ -0,0 +1,361 @@ +/** + * Gateway Rate Limiting + * + * Implements configurable rate limiting for the gateway server: + * - Per-client request rate limiting (token bucket algorithm) + * - Exponential backoff after repeated auth failures + * - Separate limits for authenticated vs unauthenticated requests + * - Channel message rate limiting + * + * Defaults are generous for power users while preventing abuse: + * - Unauthenticated: 60 req/min (prevents brute-force) + * - Authenticated: unlimited by default + * - Channel messages: 200/min per channel + */ + +import type { GatewayRateLimitConfig } from "../config/types.gateway.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const log = createSubsystemLogger("rate-limit"); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TYPES & DEFAULTS +// ═══════════════════════════════════════════════════════════════════════════════ + +export type RateLimitResult = { + allowed: boolean; + remaining: number; + resetMs: number; + retryAfterMs?: number; + reason?: "rate_limit" | "auth_backoff"; +}; + +export type ResolvedRateLimitConfig = Required; + +/** Default rate limit configuration (generous for power users) */ +export const DEFAULT_RATE_LIMIT_CONFIG: ResolvedRateLimitConfig = { + enabled: true, + unauthenticated: 60, // 60 req/min for unauthenticated (prevents brute-force) + authenticated: 0, // 0 = unlimited for authenticated users + channelMessages: 200, // 200 msg/min per channel + burstMultiplier: 2, // Allow 2x burst for short spikes + authFailuresBeforeBackoff: 5, // Start backoff after 5 failures + authBackoffBaseMs: 1000, // 1 second base delay + authBackoffMaxMs: 60000, // Max 1 minute delay +}; + +/** Internal state for a rate limit bucket */ +type RateBucket = { + tokens: number; + lastRefillMs: number; + maxTokens: number; + refillRatePerMs: number; +}; + +/** Auth failure tracking per client */ +type AuthFailureState = { + failures: number; + lastFailureMs: number; + backoffUntilMs: number; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// RATE LIMITER CLASS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Gateway rate limiter with per-client tracking. + * Uses token bucket algorithm for smooth rate limiting with burst support. + */ +export class GatewayRateLimiter { + private config: ResolvedRateLimitConfig; + private buckets: Map = new Map(); + private authFailures: Map = new Map(); + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor(config?: Partial) { + this.config = resolveRateLimitConfig(config); + + // Periodic cleanup of stale entries (every 5 minutes) + this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000); + } + + /** + * Check if a request should be allowed based on rate limits. + * + * @param clientId - Unique client identifier (IP address, session ID, etc.) + * @param authenticated - Whether the client is authenticated + * @returns Rate limit result with allowed status and metadata + */ + checkRequest(clientId: string, authenticated: boolean): RateLimitResult { + if (!this.config.enabled) { + return { allowed: true, remaining: Infinity, resetMs: 0 }; + } + + const now = Date.now(); + + // Check auth backoff first (applies to all requests from blocked clients) + const authState = this.authFailures.get(clientId); + if (authState && authState.backoffUntilMs > now) { + const retryAfterMs = authState.backoffUntilMs - now; + log.debug("Request blocked by auth backoff", { + clientId: clientId.slice(0, 16), + retryAfterMs, + }); + return { + allowed: false, + remaining: 0, + resetMs: retryAfterMs, + retryAfterMs, + reason: "auth_backoff", + }; + } + + // Determine rate limit based on auth status + const ratePerMinute = authenticated ? this.config.authenticated : this.config.unauthenticated; + + // 0 = unlimited + if (ratePerMinute === 0) { + return { allowed: true, remaining: Infinity, resetMs: 0 }; + } + + const bucketKey = `${authenticated ? "auth" : "unauth"}:${clientId}`; + const bucket = this.getOrCreateBucket(bucketKey, ratePerMinute); + + // Refill tokens based on elapsed time + this.refillBucket(bucket, now); + + if (bucket.tokens >= 1) { + bucket.tokens -= 1; + return { + allowed: true, + remaining: Math.floor(bucket.tokens), + resetMs: Math.ceil((bucket.maxTokens - bucket.tokens) / bucket.refillRatePerMs), + }; + } + + // Rate limited + const resetMs = Math.ceil((1 - bucket.tokens) / bucket.refillRatePerMs); + log.debug("Request rate limited", { + clientId: clientId.slice(0, 16), + authenticated, + resetMs, + }); + + return { + allowed: false, + remaining: 0, + resetMs, + retryAfterMs: resetMs, + reason: "rate_limit", + }; + } + + /** + * Check if a channel message should be allowed. + * + * @param channelKey - Unique channel identifier (e.g., "telegram:123") + * @returns Rate limit result + */ + checkChannelMessage(channelKey: string): RateLimitResult { + if (!this.config.enabled || this.config.channelMessages === 0) { + return { allowed: true, remaining: Infinity, resetMs: 0 }; + } + + const now = Date.now(); + const bucketKey = `channel:${channelKey}`; + const bucket = this.getOrCreateBucket(bucketKey, this.config.channelMessages); + + this.refillBucket(bucket, now); + + if (bucket.tokens >= 1) { + bucket.tokens -= 1; + return { + allowed: true, + remaining: Math.floor(bucket.tokens), + resetMs: Math.ceil((bucket.maxTokens - bucket.tokens) / bucket.refillRatePerMs), + }; + } + + const resetMs = Math.ceil((1 - bucket.tokens) / bucket.refillRatePerMs); + log.warn("Channel message rate limited", { channelKey, resetMs }); + + return { + allowed: false, + remaining: 0, + resetMs, + retryAfterMs: resetMs, + reason: "rate_limit", + }; + } + + /** + * Record an authentication failure for exponential backoff. + * + * @param clientId - Unique client identifier + */ + recordAuthFailure(clientId: string): void { + const now = Date.now(); + const state = this.authFailures.get(clientId) ?? { + failures: 0, + lastFailureMs: 0, + backoffUntilMs: 0, + }; + + // Reset counter if last failure was more than 10 minutes ago + if (now - state.lastFailureMs > 10 * 60 * 1000) { + state.failures = 0; + } + + state.failures += 1; + state.lastFailureMs = now; + + // Apply exponential backoff after threshold + if (state.failures >= this.config.authFailuresBeforeBackoff) { + const exponent = state.failures - this.config.authFailuresBeforeBackoff; + const backoffMs = Math.min( + this.config.authBackoffBaseMs * Math.pow(2, exponent), + this.config.authBackoffMaxMs, + ); + state.backoffUntilMs = now + backoffMs; + + log.warn("Auth failure backoff applied", { + clientId: clientId.slice(0, 16), + failures: state.failures, + backoffMs, + }); + } + + this.authFailures.set(clientId, state); + } + + /** + * Clear auth failure state after successful authentication. + * + * @param clientId - Unique client identifier + */ + clearAuthFailure(clientId: string): void { + this.authFailures.delete(clientId); + } + + /** + * Get current auth backoff state for a client. + * + * @param clientId - Unique client identifier + * @returns Backoff state or null if not in backoff + */ + getAuthBackoffState(clientId: string): { failures: number; backoffUntilMs: number } | null { + const state = this.authFailures.get(clientId); + if (!state || state.backoffUntilMs <= Date.now()) { + return null; + } + return { failures: state.failures, backoffUntilMs: state.backoffUntilMs }; + } + + /** + * Update configuration at runtime. + */ + updateConfig(config: Partial): void { + this.config = resolveRateLimitConfig(config); + log.info("Rate limit config updated", { enabled: this.config.enabled }); + } + + /** + * Get current configuration. + */ + getConfig(): ResolvedRateLimitConfig { + return { ...this.config }; + } + + /** + * Clean up resources. + */ + close(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.buckets.clear(); + this.authFailures.clear(); + } + + // ───────────────────────────────────────────────────────────────────────────── + // Private Methods + // ───────────────────────────────────────────────────────────────────────────── + + private getOrCreateBucket(key: string, ratePerMinute: number): RateBucket { + let bucket = this.buckets.get(key); + if (!bucket) { + const maxTokens = ratePerMinute * this.config.burstMultiplier; + bucket = { + tokens: maxTokens, // Start full + lastRefillMs: Date.now(), + maxTokens, + refillRatePerMs: ratePerMinute / (60 * 1000), // tokens per ms + }; + this.buckets.set(key, bucket); + } + return bucket; + } + + private refillBucket(bucket: RateBucket, now: number): void { + const elapsedMs = now - bucket.lastRefillMs; + if (elapsedMs <= 0) return; + + const tokensToAdd = elapsedMs * bucket.refillRatePerMs; + bucket.tokens = Math.min(bucket.tokens + tokensToAdd, bucket.maxTokens); + bucket.lastRefillMs = now; + } + + private cleanup(): void { + const now = Date.now(); + const staleThresholdMs = 10 * 60 * 1000; // 10 minutes + + // Clean up stale buckets + for (const [key, bucket] of this.buckets) { + if (now - bucket.lastRefillMs > staleThresholdMs) { + this.buckets.delete(key); + } + } + + // Clean up stale auth failures + for (const [key, state] of this.authFailures) { + if (now - state.lastFailureMs > staleThresholdMs && state.backoffUntilMs < now) { + this.authFailures.delete(key); + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// UTILITY FUNCTIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Resolve rate limit config with defaults. + */ +export function resolveRateLimitConfig( + config?: Partial, +): ResolvedRateLimitConfig { + return { + enabled: config?.enabled ?? DEFAULT_RATE_LIMIT_CONFIG.enabled, + unauthenticated: config?.unauthenticated ?? DEFAULT_RATE_LIMIT_CONFIG.unauthenticated, + authenticated: config?.authenticated ?? DEFAULT_RATE_LIMIT_CONFIG.authenticated, + channelMessages: config?.channelMessages ?? DEFAULT_RATE_LIMIT_CONFIG.channelMessages, + burstMultiplier: config?.burstMultiplier ?? DEFAULT_RATE_LIMIT_CONFIG.burstMultiplier, + authFailuresBeforeBackoff: + config?.authFailuresBeforeBackoff ?? DEFAULT_RATE_LIMIT_CONFIG.authFailuresBeforeBackoff, + authBackoffBaseMs: config?.authBackoffBaseMs ?? DEFAULT_RATE_LIMIT_CONFIG.authBackoffBaseMs, + authBackoffMaxMs: config?.authBackoffMaxMs ?? DEFAULT_RATE_LIMIT_CONFIG.authBackoffMaxMs, + }; +} + +/** + * Create a global rate limiter instance. + * This is typically called once during gateway startup. + */ +export function createGatewayRateLimiter( + config?: Partial, +): GatewayRateLimiter { + return new GatewayRateLimiter(config); +} diff --git a/src/gateway/server-startup-log.test.ts b/src/gateway/server-startup-log.test.ts new file mode 100644 index 000000000..10af1cabd --- /dev/null +++ b/src/gateway/server-startup-log.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from "vitest"; +import { logSecurityWarnings } from "./server-startup-log.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { DEFAULT_RATE_LIMIT_CONFIG } from "./rate-limit.js"; + +describe("logSecurityWarnings", () => { + const createMockLog = () => ({ + info: vi.fn(), + warn: vi.fn(), + }); + + describe("non-loopback binding warnings", () => { + it("warns when binding to 0.0.0.0 without auth", () => { + const log = createMockLog(); + const auth: ResolvedGatewayAuth = { + mode: "token", + token: undefined, + allowTailscale: false, + }; + + logSecurityWarnings({ + bindHost: "0.0.0.0", + resolvedAuth: auth, + rateLimitConfig: DEFAULT_RATE_LIMIT_CONFIG, + log, + }); + + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining("SECURITY WARNING"), + expect.anything(), + ); + }); + + it("warns when binding to specific IP without auth", () => { + const log = createMockLog(); + const auth: ResolvedGatewayAuth = { + mode: "password", + password: undefined, + allowTailscale: false, + }; + + logSecurityWarnings({ + bindHost: "192.168.1.100", + resolvedAuth: auth, + rateLimitConfig: DEFAULT_RATE_LIMIT_CONFIG, + log, + }); + + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining("SECURITY WARNING"), + expect.anything(), + ); + }); + + it("does not warn when binding to loopback", () => { + const log = createMockLog(); + const auth: ResolvedGatewayAuth = { + mode: "token", + token: undefined, + allowTailscale: false, + }; + + logSecurityWarnings({ + bindHost: "127.0.0.1", + resolvedAuth: auth, + rateLimitConfig: DEFAULT_RATE_LIMIT_CONFIG, + log, + }); + + // Should not have a security warning about auth + expect(log.warn).not.toHaveBeenCalledWith( + expect.stringContaining("SECURITY WARNING"), + expect.anything(), + ); + }); + + it("does not warn when binding to non-loopback with token auth", () => { + const log = createMockLog(); + const auth: ResolvedGatewayAuth = { + mode: "token", + token: "secret-token-123", + allowTailscale: false, + }; + + logSecurityWarnings({ + bindHost: "0.0.0.0", + resolvedAuth: auth, + rateLimitConfig: DEFAULT_RATE_LIMIT_CONFIG, + log, + }); + + expect(log.warn).not.toHaveBeenCalledWith( + expect.stringContaining("SECURITY WARNING"), + expect.anything(), + ); + }); + + it("does not warn when binding to non-loopback with password auth", () => { + const log = createMockLog(); + const auth: ResolvedGatewayAuth = { + mode: "password", + password: "secure-password", + allowTailscale: false, + }; + + logSecurityWarnings({ + bindHost: "0.0.0.0", + resolvedAuth: auth, + rateLimitConfig: DEFAULT_RATE_LIMIT_CONFIG, + log, + }); + + expect(log.warn).not.toHaveBeenCalledWith( + expect.stringContaining("SECURITY WARNING"), + expect.anything(), + ); + }); + }); + + describe("rate limiting status", () => { + it("logs rate limit info when enabled", () => { + const log = createMockLog(); + const auth: ResolvedGatewayAuth = { + mode: "token", + token: "secret-token-123", + allowTailscale: false, + }; + + logSecurityWarnings({ + bindHost: "127.0.0.1", + resolvedAuth: auth, + rateLimitConfig: DEFAULT_RATE_LIMIT_CONFIG, + log, + }); + + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("rate limiting")); + }); + + it("warns when rate limiting is disabled", () => { + const log = createMockLog(); + const auth: ResolvedGatewayAuth = { + mode: "token", + token: "secret-token-123", + allowTailscale: false, + }; + + logSecurityWarnings({ + bindHost: "127.0.0.1", + resolvedAuth: auth, + rateLimitConfig: { + ...DEFAULT_RATE_LIMIT_CONFIG, + enabled: false, + }, + log, + }); + + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining("Rate limiting is disabled"), + expect.anything(), + ); + }); + + it("shows unlimited for authenticated when config is 0", () => { + const log = createMockLog(); + const auth: ResolvedGatewayAuth = { + mode: "token", + token: "secret-token-123", + allowTailscale: false, + }; + + logSecurityWarnings({ + bindHost: "127.0.0.1", + resolvedAuth: auth, + rateLimitConfig: { + ...DEFAULT_RATE_LIMIT_CONFIG, + authenticated: 0, + }, + log, + }); + + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("unlimited")); + }); + + it("shows specific limit for authenticated when set", () => { + const log = createMockLog(); + const auth: ResolvedGatewayAuth = { + mode: "token", + token: "secret-token-123", + allowTailscale: false, + }; + + logSecurityWarnings({ + bindHost: "127.0.0.1", + resolvedAuth: auth, + rateLimitConfig: { + ...DEFAULT_RATE_LIMIT_CONFIG, + authenticated: 500, + }, + log, + }); + + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("500/min")); + }); + }); +}); diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index cf6d2575c..294e3e812 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -3,6 +3,9 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { loadConfig } from "../config/config.js"; import { getResolvedLoggerSettings } from "../logging.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { isLoopbackHost } from "./net.js"; +import type { ResolvedRateLimitConfig } from "./rate-limit.js"; export function logGatewayStartup(params: { cfg: ReturnType; @@ -38,3 +41,63 @@ export function logGatewayStartup(params: { params.log.info("gateway: running in Nix mode (config managed externally)"); } } + +/** + * Check if authentication is properly configured for the binding mode. + * Returns true if auth is configured (token or password set). + */ +function isAuthConfigured(auth: ResolvedGatewayAuth): boolean { + if (auth.mode === "token" && auth.token) return true; + if (auth.mode === "password" && auth.password) return true; + return false; +} + +/** + * Log security warnings at gateway startup. + * Warns about potentially insecure configurations: + * - Non-loopback binding without authentication + * - Rate limiting disabled + */ +export function logSecurityWarnings(params: { + bindHost: string; + resolvedAuth: ResolvedGatewayAuth; + rateLimitConfig?: ResolvedRateLimitConfig; + log: { + info: (msg: string, meta?: Record) => void; + warn: (msg: string, meta?: Record) => void; + }; +}): void { + const { bindHost, resolvedAuth, rateLimitConfig, log } = params; + + // Check if binding to non-loopback without auth + const isLoopback = isLoopbackHost(bindHost); + const authConfigured = isAuthConfigured(resolvedAuth); + + if (!isLoopback && !authConfigured) { + log.warn( + `SECURITY WARNING: Gateway binding to ${bindHost} (non-loopback) without authentication configured.`, + { + consoleMessage: chalk.yellow( + `⚠️ SECURITY WARNING: Gateway binding to ${chalk.bold(bindHost)} without authentication.\n` + + ` Configure gateway.auth.token or gateway.auth.password in clawdbot.json for secure access.`, + ), + }, + ); + } + + // Log rate limiting status + if (rateLimitConfig) { + if (!rateLimitConfig.enabled) { + log.warn("Rate limiting is disabled. Consider enabling for production use.", { + consoleMessage: chalk.yellow( + "⚠️ Rate limiting disabled. Set gateway.rateLimit.enabled: true for protection.", + ), + }); + } else { + log.info( + `rate limiting: unauthenticated=${rateLimitConfig.unauthenticated}/min, ` + + `authenticated=${rateLimitConfig.authenticated === 0 ? "unlimited" : `${rateLimitConfig.authenticated}/min`}`, + ); + } + } +} diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f641c4076..34388c697 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -65,7 +65,8 @@ import { createGatewayRuntimeState } from "./server-runtime-state.js"; import { hasConnectedMobileNode } from "./server-mobile-nodes.js"; import { resolveSessionKeyForRun } from "./server-session-key.js"; import { startGatewaySidecars } from "./server-startup.js"; -import { logGatewayStartup } from "./server-startup-log.js"; +import { logGatewayStartup, logSecurityWarnings } from "./server-startup-log.js"; +import { createGatewayRateLimiter, resolveRateLimitConfig } from "./rate-limit.js"; import { startGatewayTailscaleExposure } from "./server-tailscale.js"; import { loadGatewayTlsRuntime } from "./server/tls.js"; import { createWizardSessionTracker } from "./server-wizard-sessions.js"; @@ -269,6 +270,11 @@ export async function startGatewayServer( if (cfgAtStart.gateway?.tls?.enabled && !gatewayTls.enabled) { throw new Error(gatewayTls.error ?? "gateway tls: failed to enable"); } + + // Create rate limiter for gateway request protection + const rateLimitConfig = resolveRateLimitConfig(cfgAtStart.gateway?.rateLimit); + const rateLimiter = createGatewayRateLimiter(rateLimitConfig); + const { canvasHost, httpServer, @@ -483,6 +489,12 @@ export async function startGatewayServer( log, isNixMode, }); + logSecurityWarnings({ + bindHost, + resolvedAuth, + rateLimitConfig, + log, + }); scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode }); const tailscaleCleanup = await startGatewayTailscaleExposure({ tailscaleMode, @@ -579,6 +591,7 @@ export async function startGatewayServer( skillsRefreshTimer = null; } skillsChangeUnsub(); + rateLimiter.close(); await close(opts); }, };