* 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>
152 lines
5.2 KiB
TypeScript
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");
|
|
});
|
|
});
|