openclaw/src/gateway/auth.test.ts
Harald Buerbaumer 30b6eccae5
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>
2026-02-13 15:32:38 +01:00

152 lines
5.2 KiB
TypeScript

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({
auth: { mode: "token", token: "secret", allowTailscale: false },
connectAuth: { token: "secret" },
// Regression: avoid crashing on req.socket.remoteAddress when callers pass a non-IncomingMessage.
req: {} as never,
});
expect(res.ok).toBe(true);
});
it("reports missing and mismatched token reasons", async () => {
const missing = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: false },
connectAuth: null,
});
expect(missing.ok).toBe(false);
expect(missing.reason).toBe("token_missing");
const mismatch = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: false },
connectAuth: { token: "wrong" },
});
expect(mismatch.ok).toBe(false);
expect(mismatch.reason).toBe("token_mismatch");
});
it("reports missing token config reason", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "token", allowTailscale: false },
connectAuth: { token: "anything" },
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("token_missing_config");
});
it("reports missing and mismatched password reasons", async () => {
const missing = await authorizeGatewayConnect({
auth: { mode: "password", password: "secret", allowTailscale: false },
connectAuth: null,
});
expect(missing.ok).toBe(false);
expect(missing.reason).toBe("password_missing");
const mismatch = await authorizeGatewayConnect({
auth: { mode: "password", password: "secret", allowTailscale: false },
connectAuth: { password: "wrong" },
});
expect(mismatch.ok).toBe(false);
expect(mismatch.reason).toBe("password_mismatch");
});
it("reports missing password config reason", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "password", allowTailscale: false },
connectAuth: { password: "secret" },
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("password_missing_config");
});
it("treats local tailscale serve hostnames as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: true },
connectAuth: { token: "secret" },
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: { host: "gateway.tailnet-1234.ts.net:443" },
} as never,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("token");
});
it("allows tailscale identity to satisfy token mode auth", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: true },
connectAuth: null,
tailscaleWhois: async () => ({ login: "peter", name: "Peter" }),
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-for": "100.64.0.1",
"x-forwarded-proto": "https",
"x-forwarded-host": "ai-hub.bone-egret.ts.net",
"tailscale-user-login": "peter",
"tailscale-user-name": "Peter",
},
} as never,
});
expect(res.ok).toBe(true);
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");
});
});