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 <noreply@anthropic.com>
This commit is contained in:
parent
9d26cd5db2
commit
874caa32bc
@ -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 |
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
306
src/gateway/rate-limit.test.ts
Normal file
306
src/gateway/rate-limit.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
361
src/gateway/rate-limit.ts
Normal file
361
src/gateway/rate-limit.ts
Normal file
@ -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<GatewayRateLimitConfig>;
|
||||
|
||||
/** 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<string, RateBucket> = new Map();
|
||||
private authFailures: Map<string, AuthFailureState> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(config?: Partial<GatewayRateLimitConfig>) {
|
||||
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<GatewayRateLimitConfig>): 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<GatewayRateLimitConfig>,
|
||||
): 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<GatewayRateLimitConfig>,
|
||||
): GatewayRateLimiter {
|
||||
return new GatewayRateLimiter(config);
|
||||
}
|
||||
205
src/gateway/server-startup-log.test.ts
Normal file
205
src/gateway/server-startup-log.test.ts
Normal file
@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<typeof loadConfig>;
|
||||
@ -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<string, unknown>) => void;
|
||||
warn: (msg: string, meta?: Record<string, unknown>) => 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`}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user