diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 902a432676..8c58cd4e94 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1893,6 +1893,12 @@ See [Plugins](/tools/plugin). token: "your-token", // password: "your-password", // or OPENCLAW_GATEWAY_PASSWORD allowTailscale: true, + rateLimit: { + maxAttempts: 10, + windowMs: 60000, + lockoutMs: 300000, + exemptLoopback: true, + }, }, tailscale: { mode: "off", // off | serve | funnel @@ -1929,6 +1935,8 @@ See [Plugins](/tools/plugin). - `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IP only), or `custom`. - **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default. - `auth.allowTailscale`: when `true`, Tailscale Serve identity headers satisfy auth (verified via `tailscale whois`). Defaults to `true` when `tailscale.mode = "serve"`. +- `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. + - `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments). - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth). - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. - `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth. diff --git a/docs/gateway/openai-http-api.md b/docs/gateway/openai-http-api.md index 2406063c0c..dbaa06fbe3 100644 --- a/docs/gateway/openai-http-api.md +++ b/docs/gateway/openai-http-api.md @@ -26,6 +26,7 @@ Notes: - When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). - When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). +- If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`. ## Choosing an agent diff --git a/docs/gateway/openresponses-http-api.md b/docs/gateway/openresponses-http-api.md index 88f1547b8f..f0e91f2ba2 100644 --- a/docs/gateway/openresponses-http-api.md +++ b/docs/gateway/openresponses-http-api.md @@ -28,6 +28,7 @@ Notes: - When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). - When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). +- If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`. ## Choosing an agent diff --git a/docs/gateway/tools-invoke-http-api.md b/docs/gateway/tools-invoke-http-api.md index 88a98471aa..c44ad5b466 100644 --- a/docs/gateway/tools-invoke-http-api.md +++ b/docs/gateway/tools-invoke-http-api.md @@ -25,6 +25,7 @@ Notes: - When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). - When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). +- If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`. ## Request body @@ -90,6 +91,7 @@ To help group policies resolve context, you can optionally set: - `200` → `{ ok: true, result }` - `400` → `{ ok: false, error: { type, message } }` (invalid request or tool error) - `401` → unauthorized +- `429` → auth rate-limited (`Retry-After` set) - `404` → tool not available (not found or not allowlisted) - `405` → method not allowed diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 25ad30c3a1..cdfc1560e3 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -87,6 +87,19 @@ export type GatewayAuthConfig = { password?: string; /** Allow Tailscale identity headers when serve mode is enabled. */ allowTailscale?: boolean; + /** Rate-limit configuration for failed authentication attempts. */ + rateLimit?: GatewayAuthRateLimitConfig; +}; + +export type GatewayAuthRateLimitConfig = { + /** Maximum failed attempts per IP before blocking. @default 10 */ + maxAttempts?: number; + /** Sliding window duration in milliseconds. @default 60000 (1 min) */ + windowMs?: number; + /** Lockout duration in milliseconds after the limit is exceeded. @default 300000 (5 min) */ + lockoutMs?: number; + /** Exempt localhost/loopback addresses from auth rate limiting. @default true */ + exemptLoopback?: boolean; }; export type GatewayTailscaleMode = "off" | "serve" | "funnel"; diff --git a/src/gateway/auth-rate-limit.test.ts b/src/gateway/auth-rate-limit.test.ts new file mode 100644 index 0000000000..0eaee4be0b --- /dev/null +++ b/src/gateway/auth-rate-limit.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, + AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, + createAuthRateLimiter, + type AuthRateLimiter, +} from "./auth-rate-limit.js"; + +describe("auth rate limiter", () => { + let limiter: AuthRateLimiter; + + afterEach(() => { + limiter?.dispose(); + }); + + // ---------- basic sliding window ---------- + + it("allows requests when no failures have been recorded", () => { + limiter = createAuthRateLimiter({ maxAttempts: 5, windowMs: 60_000, lockoutMs: 300_000 }); + const result = limiter.check("192.168.1.1"); + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(5); + expect(result.retryAfterMs).toBe(0); + }); + + it("decrements remaining count after each failure", () => { + limiter = createAuthRateLimiter({ maxAttempts: 3, windowMs: 60_000, lockoutMs: 300_000 }); + limiter.recordFailure("10.0.0.1"); + expect(limiter.check("10.0.0.1").remaining).toBe(2); + limiter.recordFailure("10.0.0.1"); + expect(limiter.check("10.0.0.1").remaining).toBe(1); + }); + + it("blocks the IP once maxAttempts is reached", () => { + limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 10_000 }); + limiter.recordFailure("10.0.0.2"); + limiter.recordFailure("10.0.0.2"); + const result = limiter.check("10.0.0.2"); + expect(result.allowed).toBe(false); + expect(result.remaining).toBe(0); + expect(result.retryAfterMs).toBeGreaterThan(0); + expect(result.retryAfterMs).toBeLessThanOrEqual(10_000); + }); + + // ---------- lockout expiry ---------- + + it("unblocks after the lockout period expires", () => { + vi.useFakeTimers(); + try { + limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 5_000 }); + limiter.recordFailure("10.0.0.3"); + limiter.recordFailure("10.0.0.3"); + expect(limiter.check("10.0.0.3").allowed).toBe(false); + + // Advance just past the lockout. + vi.advanceTimersByTime(5_001); + const result = limiter.check("10.0.0.3"); + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + // ---------- sliding window expiry ---------- + + it("expires old failures outside the window", () => { + vi.useFakeTimers(); + try { + limiter = createAuthRateLimiter({ maxAttempts: 3, windowMs: 10_000, lockoutMs: 60_000 }); + limiter.recordFailure("10.0.0.4"); + limiter.recordFailure("10.0.0.4"); + expect(limiter.check("10.0.0.4").remaining).toBe(1); + + // Move past the window so the two old failures expire. + vi.advanceTimersByTime(11_000); + expect(limiter.check("10.0.0.4").remaining).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + + // ---------- per-IP isolation ---------- + + it("tracks IPs independently", () => { + limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 60_000 }); + limiter.recordFailure("10.0.0.10"); + limiter.recordFailure("10.0.0.10"); + expect(limiter.check("10.0.0.10").allowed).toBe(false); + + // A different IP should be unaffected. + expect(limiter.check("10.0.0.11").allowed).toBe(true); + expect(limiter.check("10.0.0.11").remaining).toBe(2); + }); + + it("tracks scopes independently for the same IP", () => { + limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); + limiter.recordFailure("10.0.0.12", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); + expect(limiter.check("10.0.0.12", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET).allowed).toBe(false); + expect(limiter.check("10.0.0.12", AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN).allowed).toBe(true); + }); + + // ---------- loopback exemption ---------- + + it("exempts loopback addresses by default", () => { + limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); + limiter.recordFailure("127.0.0.1"); + // Should still be allowed even though maxAttempts is 1. + expect(limiter.check("127.0.0.1").allowed).toBe(true); + }); + + it("exempts IPv6 loopback by default", () => { + limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); + limiter.recordFailure("::1"); + expect(limiter.check("::1").allowed).toBe(true); + }); + + it("rate-limits loopback when exemptLoopback is false", () => { + limiter = createAuthRateLimiter({ + maxAttempts: 1, + windowMs: 60_000, + lockoutMs: 60_000, + exemptLoopback: false, + }); + limiter.recordFailure("127.0.0.1"); + expect(limiter.check("127.0.0.1").allowed).toBe(false); + }); + + // ---------- reset ---------- + + it("clears tracking state when reset is called", () => { + limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 60_000 }); + limiter.recordFailure("10.0.0.20"); + limiter.recordFailure("10.0.0.20"); + expect(limiter.check("10.0.0.20").allowed).toBe(false); + + limiter.reset("10.0.0.20"); + expect(limiter.check("10.0.0.20").allowed).toBe(true); + expect(limiter.check("10.0.0.20").remaining).toBe(2); + }); + + it("reset only clears the requested scope for an IP", () => { + limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); + limiter.recordFailure("10.0.0.21", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); + limiter.recordFailure("10.0.0.21", AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN); + expect(limiter.check("10.0.0.21", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET).allowed).toBe(false); + expect(limiter.check("10.0.0.21", AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN).allowed).toBe(false); + + limiter.reset("10.0.0.21", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); + expect(limiter.check("10.0.0.21", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET).allowed).toBe(true); + expect(limiter.check("10.0.0.21", AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN).allowed).toBe(false); + }); + + // ---------- prune ---------- + + it("prune removes stale entries", () => { + vi.useFakeTimers(); + try { + limiter = createAuthRateLimiter({ maxAttempts: 5, windowMs: 5_000, lockoutMs: 5_000 }); + limiter.recordFailure("10.0.0.30"); + expect(limiter.size()).toBe(1); + + vi.advanceTimersByTime(6_000); + limiter.prune(); + expect(limiter.size()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("prune keeps entries that are still locked out", () => { + vi.useFakeTimers(); + try { + limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 5_000, lockoutMs: 30_000 }); + limiter.recordFailure("10.0.0.31"); + expect(limiter.check("10.0.0.31").allowed).toBe(false); + + // Move past the window but NOT past the lockout. + vi.advanceTimersByTime(6_000); + limiter.prune(); + expect(limiter.size()).toBe(1); // Still locked-out, not pruned. + } finally { + vi.useRealTimers(); + } + }); + + // ---------- undefined / empty IP ---------- + + it("normalizes undefined IP to 'unknown'", () => { + limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 60_000 }); + limiter.recordFailure(undefined); + limiter.recordFailure(undefined); + expect(limiter.check(undefined).allowed).toBe(false); + expect(limiter.size()).toBe(1); + }); + + it("normalizes empty-string IP to 'unknown'", () => { + limiter = createAuthRateLimiter({ maxAttempts: 2, windowMs: 60_000, lockoutMs: 60_000 }); + limiter.recordFailure(""); + limiter.recordFailure(""); + expect(limiter.check("").allowed).toBe(false); + }); + + // ---------- dispose ---------- + + it("dispose clears all entries", () => { + limiter = createAuthRateLimiter(); + limiter.recordFailure("10.0.0.40"); + expect(limiter.size()).toBe(1); + limiter.dispose(); + expect(limiter.size()).toBe(0); + }); +}); diff --git a/src/gateway/auth-rate-limit.ts b/src/gateway/auth-rate-limit.ts new file mode 100644 index 0000000000..8eeaa39562 --- /dev/null +++ b/src/gateway/auth-rate-limit.ts @@ -0,0 +1,218 @@ +/** + * In-memory sliding-window rate limiter for gateway authentication attempts. + * + * Tracks failed auth attempts by {scope, clientIp}. A scope lets callers keep + * independent counters for different credential classes (for example, shared + * gateway token/password vs device-token auth) while still sharing one + * limiter instance. + * + * Design decisions: + * - Pure in-memory Map – no external dependencies; suitable for a single + * gateway process. The Map is periodically pruned to avoid unbounded + * growth. + * - Loopback addresses (127.0.0.1 / ::1) are exempt by default so that local + * CLI sessions are never locked out. + * - The module is side-effect-free: callers create an instance via + * {@link createAuthRateLimiter} and pass it where needed. + */ + +import { isLoopbackAddress } from "./net.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface RateLimitConfig { + /** Maximum failed attempts before blocking. @default 10 */ + maxAttempts?: number; + /** Sliding window duration in milliseconds. @default 60_000 (1 min) */ + windowMs?: number; + /** Lockout duration in milliseconds after the limit is exceeded. @default 300_000 (5 min) */ + lockoutMs?: number; + /** Exempt loopback (localhost) addresses from rate limiting. @default true */ + exemptLoopback?: boolean; +} + +export const AUTH_RATE_LIMIT_SCOPE_DEFAULT = "default"; +export const AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET = "shared-secret"; +export const AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN = "device-token"; + +export interface RateLimitEntry { + /** Timestamps (epoch ms) of recent failed attempts inside the window. */ + attempts: number[]; + /** If set, requests from this IP are blocked until this epoch-ms instant. */ + lockedUntil?: number; +} + +export interface RateLimitCheckResult { + /** Whether the request is allowed to proceed. */ + allowed: boolean; + /** Number of remaining attempts before the limit is reached. */ + remaining: number; + /** Milliseconds until the lockout expires (0 when not locked). */ + retryAfterMs: number; +} + +export interface AuthRateLimiter { + /** Check whether `ip` is currently allowed to attempt authentication. */ + check(ip: string | undefined, scope?: string): RateLimitCheckResult; + /** Record a failed authentication attempt for `ip`. */ + recordFailure(ip: string | undefined, scope?: string): void; + /** Reset the rate-limit state for `ip` (e.g. after a successful login). */ + reset(ip: string | undefined, scope?: string): void; + /** Return the current number of tracked IPs (useful for diagnostics). */ + size(): number; + /** Remove expired entries and release memory. */ + prune(): void; + /** Dispose the limiter and cancel periodic cleanup timers. */ + dispose(): void; +} + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +const DEFAULT_MAX_ATTEMPTS = 10; +const DEFAULT_WINDOW_MS = 60_000; // 1 minute +const DEFAULT_LOCKOUT_MS = 300_000; // 5 minutes +const PRUNE_INTERVAL_MS = 60_000; // prune stale entries every minute + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter { + const maxAttempts = config?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + const windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS; + const lockoutMs = config?.lockoutMs ?? DEFAULT_LOCKOUT_MS; + const exemptLoopback = config?.exemptLoopback ?? true; + + const entries = new Map(); + + // Periodic cleanup to avoid unbounded map growth. + const pruneTimer = setInterval(() => prune(), PRUNE_INTERVAL_MS); + // Allow the Node.js process to exit even if the timer is still active. + if (pruneTimer.unref) { + pruneTimer.unref(); + } + + function normalizeScope(scope: string | undefined): string { + return (scope ?? AUTH_RATE_LIMIT_SCOPE_DEFAULT).trim() || AUTH_RATE_LIMIT_SCOPE_DEFAULT; + } + + function normalizeIp(ip: string | undefined): string { + return (ip ?? "").trim() || "unknown"; + } + + function resolveKey( + rawIp: string | undefined, + rawScope: string | undefined, + ): { + key: string; + ip: string; + } { + const ip = normalizeIp(rawIp); + const scope = normalizeScope(rawScope); + return { key: `${scope}:${ip}`, ip }; + } + + function isExempt(ip: string): boolean { + return exemptLoopback && isLoopbackAddress(ip); + } + + function slideWindow(entry: RateLimitEntry, now: number): void { + const cutoff = now - windowMs; + // Remove attempts that fell outside the window. + entry.attempts = entry.attempts.filter((ts) => ts > cutoff); + } + + function check(rawIp: string | undefined, rawScope?: string): RateLimitCheckResult { + const { key, ip } = resolveKey(rawIp, rawScope); + if (isExempt(ip)) { + return { allowed: true, remaining: maxAttempts, retryAfterMs: 0 }; + } + + const now = Date.now(); + const entry = entries.get(key); + + if (!entry) { + return { allowed: true, remaining: maxAttempts, retryAfterMs: 0 }; + } + + // Still locked out? + if (entry.lockedUntil && now < entry.lockedUntil) { + return { + allowed: false, + remaining: 0, + retryAfterMs: entry.lockedUntil - now, + }; + } + + // Lockout expired – clear it. + if (entry.lockedUntil && now >= entry.lockedUntil) { + entry.lockedUntil = undefined; + entry.attempts = []; + } + + slideWindow(entry, now); + const remaining = Math.max(0, maxAttempts - entry.attempts.length); + return { allowed: remaining > 0, remaining, retryAfterMs: 0 }; + } + + function recordFailure(rawIp: string | undefined, rawScope?: string): void { + const { key, ip } = resolveKey(rawIp, rawScope); + if (isExempt(ip)) { + return; + } + + const now = Date.now(); + let entry = entries.get(key); + + if (!entry) { + entry = { attempts: [] }; + entries.set(key, entry); + } + + // If currently locked, do nothing (already blocked). + if (entry.lockedUntil && now < entry.lockedUntil) { + return; + } + + slideWindow(entry, now); + entry.attempts.push(now); + + if (entry.attempts.length >= maxAttempts) { + entry.lockedUntil = now + lockoutMs; + } + } + + function reset(rawIp: string | undefined, rawScope?: string): void { + const { key } = resolveKey(rawIp, rawScope); + entries.delete(key); + } + + function prune(): void { + const now = Date.now(); + for (const [key, entry] of entries) { + // If locked out, keep the entry until the lockout expires. + if (entry.lockedUntil && now < entry.lockedUntil) { + continue; + } + slideWindow(entry, now); + if (entry.attempts.length === 0) { + entries.delete(key); + } + } + } + + function size(): number { + return entries.size; + } + + function dispose(): void { + clearInterval(pruneTimer); + entries.clear(); + } + + return { check, recordFailure, reset, size, prune, dispose }; +} diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 7910adeff7..75745f8811 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -1,6 +1,22 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { authorizeGatewayConnect } from "./auth.js"; +function createLimiterSpy(): AuthRateLimiter & { + check: ReturnType; + recordFailure: ReturnType; + reset: ReturnType; +} { + return { + check: vi.fn(() => ({ allowed: true, remaining: 10, retryAfterMs: 0 })), + recordFailure: vi.fn(), + reset: vi.fn(), + size: () => 0, + prune: () => {}, + dispose: () => {}, + }; +} + describe("gateway auth", () => { it("does not throw when req is missing socket", async () => { const res = await authorizeGatewayConnect({ @@ -98,4 +114,38 @@ describe("gateway auth", () => { expect(res.method).toBe("tailscale"); expect(res.user).toBe("peter"); }); + + it("uses proxy-aware request client IP by default for rate-limit checks", async () => { + const limiter = createLimiterSpy(); + const res = await authorizeGatewayConnect({ + auth: { mode: "token", token: "secret", allowTailscale: false }, + connectAuth: { token: "wrong" }, + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { "x-forwarded-for": "203.0.113.10" }, + } as never, + trustedProxies: ["127.0.0.1"], + rateLimiter: limiter, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_mismatch"); + expect(limiter.check).toHaveBeenCalledWith("203.0.113.10", "shared-secret"); + expect(limiter.recordFailure).toHaveBeenCalledWith("203.0.113.10", "shared-secret"); + }); + + it("passes custom rate-limit scope to limiter operations", async () => { + const limiter = createLimiterSpy(); + const res = await authorizeGatewayConnect({ + auth: { mode: "password", password: "secret", allowTailscale: false }, + connectAuth: { password: "wrong" }, + rateLimiter: limiter, + rateLimitScope: "custom-scope", + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("password_mismatch"); + expect(limiter.check).toHaveBeenCalledWith(undefined, "custom-scope"); + expect(limiter.recordFailure).toHaveBeenCalledWith(undefined, "custom-scope"); + }); }); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index dce9f7a8ec..04a6f9e54f 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -2,12 +2,18 @@ import type { IncomingMessage } from "node:http"; import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; import { safeEqualSecret } from "../security/secret-equal.js"; +import { + AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, + type AuthRateLimiter, + type RateLimitCheckResult, +} from "./auth-rate-limit.js"; import { isLoopbackAddress, isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp, } from "./net.js"; + export type ResolvedGatewayAuthMode = "token" | "password"; export type ResolvedGatewayAuth = { @@ -22,6 +28,10 @@ export type GatewayAuthResult = { method?: "token" | "password" | "tailscale" | "device-token"; user?: string; reason?: string; + /** Present when the request was blocked by the rate limiter. */ + rateLimited?: boolean; + /** Milliseconds the client should wait before retrying (when rate-limited). */ + retryAfterMs?: number; }; type ConnectAuth = { @@ -220,17 +230,42 @@ export async function authorizeGatewayConnect(params: { req?: IncomingMessage; trustedProxies?: string[]; tailscaleWhois?: TailscaleWhoisLookup; + /** Optional rate limiter instance; when provided, failed attempts are tracked per IP. */ + rateLimiter?: AuthRateLimiter; + /** Client IP used for rate-limit tracking. Falls back to proxy-aware request IP resolution. */ + clientIp?: string; + /** Optional limiter scope; defaults to shared-secret auth scope. */ + rateLimitScope?: string; }): Promise { const { auth, connectAuth, req, trustedProxies } = params; const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; const localDirect = isLocalDirectRequest(req, trustedProxies); + // --- Rate-limit gate --- + const limiter = params.rateLimiter; + const ip = + params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress; + const rateLimitScope = params.rateLimitScope ?? AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET; + if (limiter) { + const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope); + if (!rlCheck.allowed) { + return { + ok: false, + reason: "rate_limited", + rateLimited: true, + retryAfterMs: rlCheck.retryAfterMs, + }; + } + } + if (auth.allowTailscale && !localDirect) { const tailscaleCheck = await resolveVerifiedTailscaleUser({ req, tailscaleWhois, }); if (tailscaleCheck.ok) { + // Successful auth – reset rate-limit counter for this IP. + limiter?.reset(ip, rateLimitScope); return { ok: true, method: "tailscale", @@ -244,11 +279,14 @@ export async function authorizeGatewayConnect(params: { return { ok: false, reason: "token_missing_config" }; } if (!connectAuth?.token) { + limiter?.recordFailure(ip, rateLimitScope); return { ok: false, reason: "token_missing" }; } if (!safeEqualSecret(connectAuth.token, auth.token)) { + limiter?.recordFailure(ip, rateLimitScope); return { ok: false, reason: "token_mismatch" }; } + limiter?.reset(ip, rateLimitScope); return { ok: true, method: "token" }; } @@ -258,13 +296,17 @@ export async function authorizeGatewayConnect(params: { return { ok: false, reason: "password_missing_config" }; } if (!password) { + limiter?.recordFailure(ip, rateLimitScope); return { ok: false, reason: "password_missing" }; } if (!safeEqualSecret(password, auth.password)) { + limiter?.recordFailure(ip, rateLimitScope); return { ok: false, reason: "password_mismatch" }; } + limiter?.reset(ip, rateLimitScope); return { ok: true, method: "password" }; } + limiter?.recordFailure(ip, rateLimitScope); return { ok: false, reason: "unauthorized" }; } diff --git a/src/gateway/http-common.ts b/src/gateway/http-common.ts index c7abc82860..b978886180 100644 --- a/src/gateway/http-common.ts +++ b/src/gateway/http-common.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import type { GatewayAuthResult } from "./auth.js"; import { readJsonBody } from "./hooks.js"; export function sendJson(res: ServerResponse, status: number, body: unknown) { @@ -24,6 +25,26 @@ export function sendUnauthorized(res: ServerResponse) { }); } +export function sendRateLimited(res: ServerResponse, retryAfterMs?: number) { + if (retryAfterMs && retryAfterMs > 0) { + res.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1000))); + } + sendJson(res, 429, { + error: { + message: "Too many failed authentication attempts. Please try again later.", + type: "rate_limited", + }, + }); +} + +export function sendGatewayAuthFailure(res: ServerResponse, authResult: GatewayAuthResult) { + if (authResult.rateLimited) { + sendRateLimited(res, authResult.retryAfterMs); + return; + } + sendUnauthorized(res); +} + export function sendInvalidRequest(res: ServerResponse, message: string) { sendJson(res, 400, { error: { message, type: "invalid_request_error" }, diff --git a/src/gateway/openai-http.e2e.test.ts b/src/gateway/openai-http.e2e.test.ts index 713ab5e7ff..154b771d68 100644 --- a/src/gateway/openai-http.e2e.test.ts +++ b/src/gateway/openai-http.e2e.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; -import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js"; +import { agentCommand, getFreePort, installGatewayTestHooks, testState } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); @@ -344,6 +344,49 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { } }); + it("returns 429 for repeated failed auth when gateway.auth.rateLimit is configured", async () => { + const { startGatewayServer } = await import("./server.js"); + testState.gatewayAuth = { + mode: "token", + token: "secret", + rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: false }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const port = await getFreePort(); + const server = await startGatewayServer(port, { + host: "127.0.0.1", + controlUiEnabled: false, + openAiChatCompletionsEnabled: true, + }); + try { + const headers = { + "content-type": "application/json", + authorization: "Bearer wrong", + }; + const body = { + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }; + + const first = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + expect(first.status).toBe(401); + + const second = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + expect(second.status).toBe(429); + expect(second.headers.get("retry-after")).toBeTruthy(); + } finally { + await server.close({ reason: "rate-limit auth test done" }); + } + }); + it("streams SSE chunks when stream=true", async () => { const port = enabledPort; try { diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 9a623d75ee..e85e0aac96 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { randomUUID } from "node:crypto"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js"; import { createDefaultDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; @@ -8,9 +9,9 @@ import { defaultRuntime } from "../runtime.js"; import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; import { readJsonBodyOrError, + sendGatewayAuthFailure, sendJson, sendMethodNotAllowed, - sendUnauthorized, setSseHeaders, writeDone, } from "./http-common.js"; @@ -20,6 +21,7 @@ type OpenAiHttpOptions = { auth: ResolvedGatewayAuth; maxBodyBytes?: number; trustedProxies?: string[]; + rateLimiter?: AuthRateLimiter; }; type OpenAiChatMessage = { @@ -189,9 +191,10 @@ export async function handleOpenAiHttpRequest( connectAuth: { token, password: token }, req, trustedProxies: opts.trustedProxies, + rateLimiter: opts.rateLimiter, }); if (!authResult.ok) { - sendUnauthorized(res); + sendGatewayAuthFailure(res, authResult); return true; } diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 84a2bd7e98..dae20ac51d 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -11,6 +11,7 @@ import { randomUUID } from "node:crypto"; import type { ClientToolDefinition } from "../agents/pi-embedded-runner/run/params.js"; import type { ImageContent } from "../commands/agent/types.js"; import type { GatewayHttpResponsesConfig } from "../config/types.gateway.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js"; import { createDefaultDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; @@ -37,9 +38,9 @@ import { defaultRuntime } from "../runtime.js"; import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; import { readJsonBodyOrError, + sendGatewayAuthFailure, sendJson, sendMethodNotAllowed, - sendUnauthorized, setSseHeaders, writeDone, } from "./http-common.js"; @@ -60,6 +61,7 @@ type OpenResponsesHttpOptions = { maxBodyBytes?: number; config?: GatewayHttpResponsesConfig; trustedProxies?: string[]; + rateLimiter?: AuthRateLimiter; }; const DEFAULT_BODY_BYTES = 20 * 1024 * 1024; @@ -364,9 +366,10 @@ export async function handleOpenResponsesHttpRequest( connectAuth: { token, password: token }, req, trustedProxies: opts.trustedProxies, + rateLimiter: opts.rateLimiter, }); if (!authResult.ok) { - sendUnauthorized(res); + sendGatewayAuthFailure(res, authResult); return true; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 3a3a7faf04..9e57b1112a 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -9,6 +9,7 @@ import { import { createServer as createHttpsServer } from "node:https"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { GatewayWsClient } from "./server/ws-types.js"; import { resolveAgentAvatar } from "../agents/identity-avatar.js"; import { @@ -20,7 +21,12 @@ import { import { loadConfig } from "../config/config.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { handleSlackHttpRequest } from "../slack/http/index.js"; -import { authorizeGatewayConnect, isLocalDirectRequest, type ResolvedGatewayAuth } from "./auth.js"; +import { + authorizeGatewayConnect, + isLocalDirectRequest, + type GatewayAuthResult, + type ResolvedGatewayAuth, +} from "./auth.js"; import { handleControlUiAvatarRequest, handleControlUiHttpRequest, @@ -43,7 +49,7 @@ import { resolveHookChannel, resolveHookDeliver, } from "./hooks.js"; -import { sendUnauthorized } from "./http-common.js"; +import { sendGatewayAuthFailure } from "./http-common.js"; import { getBearerToken, getHeader } from "./http-utils.js"; import { resolveGatewayClientIp } from "./net.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; @@ -105,12 +111,14 @@ async function authorizeCanvasRequest(params: { auth: ResolvedGatewayAuth; trustedProxies: string[]; clients: Set; -}): Promise { - const { req, auth, trustedProxies, clients } = params; + rateLimiter?: AuthRateLimiter; +}): Promise { + const { req, auth, trustedProxies, clients, rateLimiter } = params; if (isLocalDirectRequest(req, trustedProxies)) { - return true; + return { ok: true }; } + let lastAuthFailure: GatewayAuthResult | null = null; const token = getBearerToken(req); if (token) { const authResult = await authorizeGatewayConnect({ @@ -118,10 +126,12 @@ async function authorizeCanvasRequest(params: { connectAuth: { token, password: token }, req, trustedProxies, + rateLimiter, }); if (authResult.ok) { - return true; + return authResult; } + lastAuthFailure = authResult; } const clientIp = resolveGatewayClientIp({ @@ -131,9 +141,41 @@ async function authorizeCanvasRequest(params: { trustedProxies, }); if (!clientIp) { - return false; + return lastAuthFailure ?? { ok: false, reason: "unauthorized" }; } - return hasAuthorizedWsClientForIp(clients, clientIp); + if (hasAuthorizedWsClientForIp(clients, clientIp)) { + return { ok: true }; + } + return lastAuthFailure ?? { ok: false, reason: "unauthorized" }; +} + +function writeUpgradeAuthFailure( + socket: { write: (chunk: string) => void }, + auth: GatewayAuthResult, +) { + if (auth.rateLimited) { + const retryAfterSeconds = + auth.retryAfterMs && auth.retryAfterMs > 0 ? Math.ceil(auth.retryAfterMs / 1000) : undefined; + socket.write( + [ + "HTTP/1.1 429 Too Many Requests", + retryAfterSeconds ? `Retry-After: ${retryAfterSeconds}` : undefined, + "Content-Type: application/json; charset=utf-8", + "Connection: close", + "", + JSON.stringify({ + error: { + message: "Too many failed authentication attempts. Please try again later.", + type: "rate_limited", + }, + }), + ] + .filter(Boolean) + .join("\r\n"), + ); + return; + } + socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); } export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise; @@ -372,6 +414,8 @@ export function createGatewayHttpServer(opts: { handleHooksRequest: HooksRequestHandler; handlePluginRequest?: HooksRequestHandler; resolvedAuth: ResolvedGatewayAuth; + /** Optional rate limiter for auth brute-force protection. */ + rateLimiter?: AuthRateLimiter; tlsOptions?: TlsOptions; }): HttpServer { const { @@ -386,6 +430,7 @@ export function createGatewayHttpServer(opts: { handleHooksRequest, handlePluginRequest, resolvedAuth, + rateLimiter, } = opts; const httpServer: HttpServer = opts.tlsOptions ? createHttpsServer(opts.tlsOptions, (req, res) => { @@ -412,6 +457,7 @@ export function createGatewayHttpServer(opts: { await handleToolsInvokeHttpRequest(req, res, { auth: resolvedAuth, trustedProxies, + rateLimiter, }) ) { return; @@ -430,9 +476,10 @@ export function createGatewayHttpServer(opts: { connectAuth: token ? { token, password: token } : null, req, trustedProxies, + rateLimiter, }); if (!authResult.ok) { - sendUnauthorized(res); + sendGatewayAuthFailure(res, authResult); return; } } @@ -446,6 +493,7 @@ export function createGatewayHttpServer(opts: { auth: resolvedAuth, config: openResponsesConfig, trustedProxies, + rateLimiter, }) ) { return; @@ -456,6 +504,7 @@ export function createGatewayHttpServer(opts: { await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth, trustedProxies, + rateLimiter, }) ) { return; @@ -468,9 +517,10 @@ export function createGatewayHttpServer(opts: { auth: resolvedAuth, trustedProxies, clients, + rateLimiter, }); - if (!ok) { - sendUnauthorized(res); + if (!ok.ok) { + sendGatewayAuthFailure(res, ok); return; } } @@ -520,8 +570,10 @@ export function attachGatewayUpgradeHandler(opts: { canvasHost: CanvasHostHandler | null; clients: Set; resolvedAuth: ResolvedGatewayAuth; + /** Optional rate limiter for auth brute-force protection. */ + rateLimiter?: AuthRateLimiter; }) { - const { httpServer, wss, canvasHost, clients, resolvedAuth } = opts; + const { httpServer, wss, canvasHost, clients, resolvedAuth, rateLimiter } = opts; httpServer.on("upgrade", (req, socket, head) => { void (async () => { if (canvasHost) { @@ -534,9 +586,10 @@ export function attachGatewayUpgradeHandler(opts: { auth: resolvedAuth, trustedProxies, clients, + rateLimiter, }); - if (!ok) { - socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); + if (!ok.ok) { + writeUpgradeAuthFailure(socket, ok); socket.destroy(); return; } diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 0312fc2e1d..03700757da 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -4,6 +4,7 @@ import type { CliDeps } from "../cli/deps.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import type { PluginRegistry } from "../plugins/registry.js"; import type { RuntimeEnv } from "../runtime.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import type { ChatAbortControllerEntry } from "./chat-abort.js"; import type { ControlUiRootState } from "./control-ui.js"; @@ -37,6 +38,8 @@ export async function createGatewayRuntimeState(params: { openResponsesEnabled: boolean; openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; resolvedAuth: ResolvedGatewayAuth; + /** Optional rate limiter for auth brute-force protection. */ + rateLimiter?: AuthRateLimiter; gatewayTls?: GatewayTlsRuntime; hooksConfig: () => HooksConfigResolved | null; pluginRegistry: PluginRegistry; @@ -139,6 +142,7 @@ export async function createGatewayRuntimeState(params: { handleHooksRequest, handlePluginRequest, resolvedAuth: params.resolvedAuth, + rateLimiter: params.rateLimiter, tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, }); try { @@ -174,6 +178,7 @@ export async function createGatewayRuntimeState(params: { canvasHost, clients, resolvedAuth: params.resolvedAuth, + rateLimiter: params.rateLimiter, }); } diff --git a/src/gateway/server-ws-runtime.ts b/src/gateway/server-ws-runtime.ts index 563caf89f4..4fc86ada36 100644 --- a/src/gateway/server-ws-runtime.ts +++ b/src/gateway/server-ws-runtime.ts @@ -1,5 +1,6 @@ import type { WebSocketServer } from "ws"; import type { createSubsystemLogger } from "../logging/subsystem.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./server-methods/types.js"; import type { GatewayWsClient } from "./server/ws-types.js"; @@ -13,6 +14,8 @@ export function attachGatewayWsHandlers(params: { canvasHostEnabled: boolean; canvasHostServerPort?: number; resolvedAuth: ResolvedGatewayAuth; + /** Optional rate limiter for auth brute-force protection. */ + rateLimiter?: AuthRateLimiter; gatewayMethods: string[]; events: string[]; logGateway: ReturnType; @@ -37,6 +40,7 @@ export function attachGatewayWsHandlers(params: { canvasHostEnabled: params.canvasHostEnabled, canvasHostServerPort: params.canvasHostServerPort, resolvedAuth: params.resolvedAuth, + rateLimiter: params.rateLimiter, gatewayMethods: params.gatewayMethods, events: params.events, logGateway: params.logGateway, diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 36bd8de840..dde47cb91d 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -657,6 +657,124 @@ describe("gateway server auth/connect", () => { } }); + test("keeps shared-secret lockout separate from device-token auth", async () => { + const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); + const { approveDevicePairing, getPairedDevice, listDevicePairing } = + await import("../infra/device-pairing.js"); + testState.gatewayAuth = { + mode: "token", + token: "secret", + rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: false }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; + const port = await getFreePort(); + const server = await startGatewayServer(port); + try { + const ws = await openWs(port); + const initial = await connectReq(ws, { token: "secret" }); + if (!initial.ok) { + const list = await listDevicePairing(); + const pending = list.pending.at(0); + expect(pending?.requestId).toBeDefined(); + if (pending?.requestId) { + await approveDevicePairing(pending.requestId); + } + } + const identity = loadOrCreateDeviceIdentity(); + const paired = await getPairedDevice(identity.deviceId); + const deviceToken = paired?.tokens?.operator?.token; + expect(deviceToken).toBeDefined(); + ws.close(); + + const wsBadShared = await openWs(port); + const badShared = await connectReq(wsBadShared, { token: "wrong", device: null }); + expect(badShared.ok).toBe(false); + wsBadShared.close(); + + const wsSharedLocked = await openWs(port); + const sharedLocked = await connectReq(wsSharedLocked, { token: "secret", device: null }); + expect(sharedLocked.ok).toBe(false); + expect(sharedLocked.error?.message ?? "").toContain("retry later"); + wsSharedLocked.close(); + + const wsDevice = await openWs(port); + const deviceOk = await connectReq(wsDevice, { token: deviceToken }); + expect(deviceOk.ok).toBe(true); + wsDevice.close(); + } finally { + await server.close(); + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } + } + }); + + test("keeps device-token lockout separate from shared-secret auth", async () => { + const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); + const { approveDevicePairing, getPairedDevice, listDevicePairing } = + await import("../infra/device-pairing.js"); + testState.gatewayAuth = { + mode: "token", + token: "secret", + rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: false }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; + const port = await getFreePort(); + const server = await startGatewayServer(port); + try { + const ws = await openWs(port); + const initial = await connectReq(ws, { token: "secret" }); + if (!initial.ok) { + const list = await listDevicePairing(); + const pending = list.pending.at(0); + expect(pending?.requestId).toBeDefined(); + if (pending?.requestId) { + await approveDevicePairing(pending.requestId); + } + } + const identity = loadOrCreateDeviceIdentity(); + const paired = await getPairedDevice(identity.deviceId); + const deviceToken = paired?.tokens?.operator?.token; + expect(deviceToken).toBeDefined(); + ws.close(); + + const wsBadDevice = await openWs(port); + const badDevice = await connectReq(wsBadDevice, { token: "wrong" }); + expect(badDevice.ok).toBe(false); + wsBadDevice.close(); + + const wsDeviceLocked = await openWs(port); + const deviceLocked = await connectReq(wsDeviceLocked, { token: "wrong" }); + expect(deviceLocked.ok).toBe(false); + expect(deviceLocked.error?.message ?? "").toContain("retry later"); + wsDeviceLocked.close(); + + const wsShared = await openWs(port); + const sharedOk = await connectReq(wsShared, { token: "secret", device: null }); + expect(sharedOk.ok).toBe(true); + wsShared.close(); + + const wsDeviceReal = await openWs(port); + const deviceStillLocked = await connectReq(wsDeviceReal, { token: deviceToken }); + expect(deviceStillLocked.ok).toBe(false); + expect(deviceStillLocked.error?.message ?? "").toContain("retry later"); + wsDeviceReal.close(); + } finally { + await server.close(); + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } + } + }); + test("requires pairing for scope upgrades", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); diff --git a/src/gateway/server.canvas-auth.e2e.test.ts b/src/gateway/server.canvas-auth.e2e.test.ts index 6fd85ac947..2fd85442bf 100644 --- a/src/gateway/server.canvas-auth.e2e.test.ts +++ b/src/gateway/server.canvas-auth.e2e.test.ts @@ -7,6 +7,7 @@ import type { CanvasHostHandler } from "../canvas-host/server.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import type { GatewayWsClient } from "./server/ws-types.js"; import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "../canvas-host/a2ui.js"; +import { createAuthRateLimiter } from "./auth-rate-limit.js"; import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js"; async function withTempConfig(params: { cfg: unknown; run: () => Promise }): Promise { @@ -54,7 +55,11 @@ async function listen(server: ReturnType): Promi }; } -async function expectWsRejected(url: string, headers: Record): Promise { +async function expectWsRejected( + url: string, + headers: Record, + expectedStatus = 401, +): Promise { await new Promise((resolve, reject) => { const ws = new WebSocket(url, { headers }); const timer = setTimeout(() => reject(new Error("timeout")), 10_000); @@ -65,7 +70,7 @@ async function expectWsRejected(url: string, headers: Record): P }); ws.once("unexpected-response", (_req, res) => { clearTimeout(timer); - expect(res.statusCode).toBe(401); + expect(res.statusCode).toBe(expectedStatus); resolve(); }); ws.once("error", () => { @@ -209,4 +214,90 @@ describe("gateway canvas host auth", () => { }, }); }, 60_000); + + test("returns 429 for repeated failed canvas auth attempts (HTTP + WS upgrade)", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "token", + token: "test-token", + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { + gateway: { + trustedProxies: ["127.0.0.1"], + }, + }, + run: async () => { + const clients = new Set(); + const rateLimiter = createAuthRateLimiter({ + maxAttempts: 1, + windowMs: 60_000, + lockoutMs: 60_000, + }); + const canvasWss = new WebSocketServer({ noServer: true }); + const canvasHost: CanvasHostHandler = { + rootDir: "test", + close: async () => {}, + handleUpgrade: (req, socket, head) => { + const url = new URL(req.url ?? "/", "http://localhost"); + if (url.pathname !== CANVAS_WS_PATH) { + return false; + } + canvasWss.handleUpgrade(req, socket, head, (ws) => ws.close()); + return true; + }, + handleHttpRequest: async (_req, _res) => false, + }; + + const httpServer = createGatewayHttpServer({ + canvasHost, + clients, + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + resolvedAuth, + rateLimiter, + }); + + const wss = new WebSocketServer({ noServer: true }); + attachGatewayUpgradeHandler({ + httpServer, + wss, + canvasHost, + clients, + resolvedAuth, + rateLimiter, + }); + + const listener = await listen(httpServer); + try { + const headers = { + authorization: "Bearer wrong", + "x-forwarded-for": "203.0.113.99", + }; + const first = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, { + headers, + }); + expect(first.status).toBe(401); + + const second = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, { + headers, + }); + expect(second.status).toBe(429); + expect(second.headers.get("retry-after")).toBeTruthy(); + + await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, headers, 429); + } finally { + await listener.close(); + rateLimiter.dispose(); + canvasWss.close(); + wss.close(); + } + }, + }); + }, 60_000); }); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 63b1ce7ca8..5b422a2bee 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -43,6 +43,7 @@ import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/di import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { runOnboardingWizard } from "../wizard/onboarding.js"; +import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js"; import { startGatewayConfigReloader } from "./config-reload.js"; import { ExecApprovalManager } from "./exec-approval-manager.js"; import { NodeRegistry } from "./node-registry.js"; @@ -270,6 +271,12 @@ export async function startGatewayServer( let hooksConfig = runtimeConfig.hooksConfig; const canvasHostEnabled = runtimeConfig.canvasHostEnabled; + // Create auth rate limiter only when explicitly configured. + const rateLimitConfig = cfgAtStart.gateway?.auth?.rateLimit; + const authRateLimiter: AuthRateLimiter | undefined = rateLimitConfig + ? createAuthRateLimiter(rateLimitConfig) + : undefined; + let controlUiRootState: ControlUiRootState | undefined; if (controlUiRootOverride) { const resolvedOverride = resolveControlUiRootOverrideSync(controlUiRootOverride); @@ -340,6 +347,7 @@ export async function startGatewayServer( openResponsesEnabled, openResponsesConfig, resolvedAuth, + rateLimiter: authRateLimiter, gatewayTls, hooksConfig: () => hooksConfig, pluginRegistry, @@ -478,6 +486,7 @@ export async function startGatewayServer( canvasHostEnabled: Boolean(canvasHost), canvasHostServerPort, resolvedAuth, + rateLimiter: authRateLimiter, gatewayMethods, events: GATEWAY_EVENTS, logGateway: log, @@ -657,6 +666,7 @@ export async function startGatewayServer( skillsRefreshTimer = null; } skillsChangeUnsub(); + authRateLimiter?.dispose(); await close(opts); }, }; diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 661ed17a24..070dec98d7 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -1,6 +1,7 @@ import type { WebSocket, WebSocketServer } from "ws"; import { randomUUID } from "node:crypto"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { AuthRateLimiter } from "../auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "../auth.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js"; import type { GatewayWsClient } from "./ws-types.js"; @@ -24,6 +25,8 @@ export function attachGatewayWsConnectionHandler(params: { canvasHostEnabled: boolean; canvasHostServerPort?: number; resolvedAuth: ResolvedGatewayAuth; + /** Optional rate limiter for auth brute-force protection. */ + rateLimiter?: AuthRateLimiter; gatewayMethods: string[]; events: string[]; logGateway: SubsystemLogger; @@ -48,6 +51,7 @@ export function attachGatewayWsConnectionHandler(params: { canvasHostEnabled, canvasHostServerPort, resolvedAuth, + rateLimiter, gatewayMethods, events, logGateway, @@ -240,6 +244,7 @@ export function attachGatewayWsConnectionHandler(params: { canvasHostUrl, connectNonce, resolvedAuth, + rateLimiter, gatewayMethods, events, extraHandlers, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 19eec9b1be..b17d71de5e 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -2,7 +2,7 @@ import type { IncomingMessage } from "node:http"; import type { WebSocket } from "ws"; import os from "node:os"; import type { createSubsystemLogger } from "../../../logging/subsystem.js"; -import type { ResolvedGatewayAuth } from "../../auth.js"; +import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js"; import type { GatewayWsClient } from "../ws-types.js"; import { loadConfig } from "../../../config/config.js"; @@ -25,6 +25,11 @@ import { upsertPresence } from "../../../infra/system-presence.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { rawDataToString } from "../../../infra/ws.js"; import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js"; +import { + AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, + AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, + type AuthRateLimiter, +} from "../../auth-rate-limit.js"; import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js"; @@ -117,6 +122,10 @@ function formatGatewayAuthFailureMessage(params: { return "unauthorized: tailscale identity check failed (use Tailscale Serve auth or gateway token/password)"; case "tailscale_user_mismatch": return "unauthorized: tailscale identity mismatch (use Tailscale Serve auth or gateway token/password)"; + case "rate_limited": + return "unauthorized: too many failed authentication attempts (retry later)"; + case "device_token_mismatch": + return "unauthorized: device token mismatch (rotate/reissue device token)"; default: break; } @@ -143,6 +152,8 @@ export function attachGatewayWsMessageHandler(params: { canvasHostUrl?: string; connectNonce: string; resolvedAuth: ResolvedGatewayAuth; + /** Optional rate limiter for auth brute-force protection. */ + rateLimiter?: AuthRateLimiter; gatewayMethods: string[]; events: string[]; extraHandlers: GatewayRequestHandlers; @@ -173,6 +184,7 @@ export function attachGatewayWsMessageHandler(params: { canvasHostUrl, connectNonce, resolvedAuth, + rateLimiter, gatewayMethods, events, extraHandlers, @@ -405,12 +417,36 @@ export function attachGatewayWsMessageHandler(params: { const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth; const device = disableControlUiDeviceAuth ? null : deviceRaw; - const authResult = await authorizeGatewayConnect({ + const hasDeviceTokenCandidate = Boolean(connectParams.auth?.token && device); + let authResult: GatewayAuthResult = await authorizeGatewayConnect({ auth: resolvedAuth, connectAuth: connectParams.auth, req: upgradeReq, trustedProxies, + rateLimiter: hasDeviceTokenCandidate ? undefined : rateLimiter, + clientIp, + rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, }); + + if ( + hasDeviceTokenCandidate && + authResult.ok && + rateLimiter && + (authResult.method === "token" || authResult.method === "password") + ) { + const sharedRateCheck = rateLimiter.check(clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); + if (!sharedRateCheck.allowed) { + authResult = { + ok: false, + reason: "rate_limited", + rateLimited: true, + retryAfterMs: sharedRateCheck.retryAfterMs, + }; + } else { + rateLimiter.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); + } + } + let authOk = authResult.ok; let authMethod = authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token"); @@ -420,15 +456,18 @@ export function attachGatewayWsMessageHandler(params: { connectAuth: connectParams.auth, req: upgradeReq, trustedProxies, + // Shared-auth probe only; rate-limit side effects are handled in + // the primary auth flow (or deferred for device-token candidates). + rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, }) : null; const sharedAuthOk = sharedAuthResult?.ok === true && (sharedAuthResult.method === "token" || sharedAuthResult.method === "password"); - const rejectUnauthorized = () => { + const rejectUnauthorized = (failedAuth: GatewayAuthResult) => { setHandshakeState("failed"); logWsControl.warn( - `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${authResult.reason ?? "unknown"}`, + `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${failedAuth.reason ?? "unknown"}`, ); const authProvided: AuthProvidedKind = connectParams.auth?.token ? "token" @@ -438,13 +477,13 @@ export function attachGatewayWsMessageHandler(params: { const authMessage = formatGatewayAuthFailureMessage({ authMode: resolvedAuth.mode, authProvided, - reason: authResult.reason, + reason: failedAuth.reason, client: connectParams.client, }); setCloseCause("unauthorized", { authMode: resolvedAuth.mode, authProvided, - authReason: authResult.reason, + authReason: failedAuth.reason, allowTailscale: resolvedAuth.allowTailscale, client: connectParams.client.id, clientDisplayName: connectParams.client.displayName, @@ -484,7 +523,7 @@ export function attachGatewayWsMessageHandler(params: { // Allow shared-secret authenticated connections (e.g., control-ui) to skip device identity if (!canSkipDevice) { if (!authOk && hasSharedAuth) { - rejectUnauthorized(); + rejectUnauthorized(authResult); return; } setHandshakeState("failed"); @@ -654,19 +693,36 @@ export function attachGatewayWsMessageHandler(params: { } if (!authOk && connectParams.auth?.token && device) { - const tokenCheck = await verifyDeviceToken({ - deviceId: device.id, - token: connectParams.auth.token, - role, - scopes, - }); - if (tokenCheck.ok) { - authOk = true; - authMethod = "device-token"; + if (rateLimiter) { + const deviceRateCheck = rateLimiter.check(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN); + if (!deviceRateCheck.allowed) { + authResult = { + ok: false, + reason: "rate_limited", + rateLimited: true, + retryAfterMs: deviceRateCheck.retryAfterMs, + }; + } + } + if (!authResult.rateLimited) { + const tokenCheck = await verifyDeviceToken({ + deviceId: device.id, + token: connectParams.auth.token, + role, + scopes, + }); + if (tokenCheck.ok) { + authOk = true; + authMethod = "device-token"; + rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN); + } else { + authResult = { ok: false, reason: "device_token_mismatch" }; + rateLimiter?.recordFailure(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN); + } } } if (!authOk) { - rejectUnauthorized(); + rejectUnauthorized(authResult); return; } diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 0962a6e411..813d122f78 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { createOpenClawTools } from "../agents/openclaw-tools.js"; import { filterToolsByPolicy, @@ -24,10 +25,10 @@ import { normalizeMessageChannel } from "../utils/message-channel.js"; import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; import { readJsonBodyOrError, + sendGatewayAuthFailure, sendInvalidRequest, sendJson, sendMethodNotAllowed, - sendUnauthorized, } from "./http-common.js"; import { getBearerToken, getHeader } from "./http-utils.js"; @@ -118,7 +119,12 @@ function mergeActionIntoArgsIfSupported(params: { export async function handleToolsInvokeHttpRequest( req: IncomingMessage, res: ServerResponse, - opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number; trustedProxies?: string[] }, + opts: { + auth: ResolvedGatewayAuth; + maxBodyBytes?: number; + trustedProxies?: string[]; + rateLimiter?: AuthRateLimiter; + }, ): Promise { const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); if (url.pathname !== "/tools/invoke") { @@ -137,9 +143,10 @@ export async function handleToolsInvokeHttpRequest( connectAuth: token ? { token, password: token } : null, req, trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, + rateLimiter: opts.rateLimiter, }); if (!authResult.ok) { - sendUnauthorized(res); + sendGatewayAuthFailure(res, authResult); return true; } diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index ed48ad1ec8..c71dffe390 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -67,6 +67,47 @@ describe("security audit", () => { ).toBe(true); }); + it("warns when non-loopback bind has auth but no auth rate limit", async () => { + const cfg: OpenClawConfig = { + gateway: { + bind: "lan", + auth: { token: "secret" }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect( + res.findings.some((f) => f.checkId === "gateway.auth_no_rate_limit" && f.severity === "warn"), + ).toBe(true); + }); + + it("does not warn for auth rate limiting when configured", async () => { + const cfg: OpenClawConfig = { + gateway: { + bind: "lan", + auth: { + token: "secret", + rateLimit: { maxAttempts: 10, windowMs: 60_000, lockoutMs: 300_000 }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings.some((f) => f.checkId === "gateway.auth_no_rate_limit")).toBe(false); + }); + it("warns when loopback control UI lacks trusted proxies", async () => { const cfg: OpenClawConfig = { gateway: { diff --git a/src/security/audit.ts b/src/security/audit.ts index 2ae96bc184..2dd9c4bb61 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -383,6 +383,19 @@ function collectGatewayConfigFindings( }); } + if (bind !== "loopback" && !cfg.gateway?.auth?.rateLimit) { + findings.push({ + checkId: "gateway.auth_no_rate_limit", + severity: "warn", + title: "No auth rate limiting configured", + detail: + "gateway.bind is not loopback but no gateway.auth.rateLimit is configured. " + + "Without rate limiting, brute-force auth attacks are not mitigated.", + remediation: + "Set gateway.auth.rateLimit (e.g. { maxAttempts: 10, windowMs: 60000, lockoutMs: 300000 }).", + }); + } + return findings; }