feat(gateway): add auth rate-limiting & brute-force protection (#15035)

* feat(gateway): add auth rate-limiting & brute-force protection

Add a per-IP sliding-window rate limiter to Gateway authentication
endpoints (HTTP, WebSocket upgrade, and WS message-level auth).

When gateway.auth.rateLimit is configured, failed auth attempts are
tracked per client IP. Once the threshold is exceeded within the
sliding window, further attempts are blocked with HTTP 429 + Retry-After
until the lockout period expires. Loopback addresses are exempt by
default so local CLI sessions are never locked out.

The limiter is only created when explicitly configured (undefined
otherwise), keeping the feature fully opt-in and backward-compatible.

* fix(gateway): isolate auth rate-limit scopes and normalize 429 responses

---------

Co-authored-by: buerbaumer <buerbaumer@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Harald Buerbaumer
2026-02-13 15:32:38 +01:00
committed by GitHub
parent 9131b22a28
commit 30b6eccae5
24 changed files with 1063 additions and 42 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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";

View File

@@ -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);
});
});

View File

@@ -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<string, RateLimitEntry>();
// 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 };
}

View File

@@ -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<typeof vi.fn>;
recordFailure: ReturnType<typeof vi.fn>;
reset: ReturnType<typeof vi.fn>;
} {
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");
});
});

View File

@@ -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<GatewayAuthResult> {
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" };
}

View File

@@ -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" },

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<GatewayWsClient>;
}): Promise<boolean> {
const { req, auth, trustedProxies, clients } = params;
rateLimiter?: AuthRateLimiter;
}): Promise<GatewayAuthResult> {
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<boolean>;
@@ -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<GatewayWsClient>;
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;
}

View File

@@ -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,
});
}

View File

@@ -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<typeof createSubsystemLogger>;
@@ -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,

View File

@@ -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");

View File

@@ -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<void> }): Promise<void> {
@@ -54,7 +55,11 @@ async function listen(server: ReturnType<typeof createGatewayHttpServer>): Promi
};
}
async function expectWsRejected(url: string, headers: Record<string, string>): Promise<void> {
async function expectWsRejected(
url: string,
headers: Record<string, string>,
expectedStatus = 401,
): Promise<void> {
await new Promise<void>((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<string, string>): 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<GatewayWsClient>();
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);
});

View File

@@ -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);
},
};

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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<boolean> {
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;
}

View File

@@ -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: {

View File

@@ -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;
}