| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160 |
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
- /**
- * Tests for the proxy auth pre-auth rate limiter.
- *
- * The rate limiter is a module-level LoginAbusePolicy instance inside
- * auth-guard.ts. Since it relies on ProxySession (which depends on Hono
- * Context), we test the underlying LoginAbusePolicy behaviour that the
- * guard delegates to, plus the IP extraction helper logic.
- */
- // We test the LoginAbusePolicy directly with proxy-specific config
- import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy";
- describe("Proxy pre-auth rate limiter (LoginAbusePolicy with proxy config)", () => {
- const nowMs = 1_700_000_000_000;
- beforeEach(() => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date(nowMs));
- });
- afterEach(() => {
- vi.useRealTimers();
- });
- it("allows requests below the proxy threshold (20)", () => {
- const policy = new LoginAbusePolicy({
- maxAttemptsPerIp: 20,
- maxAttemptsPerKey: 20,
- windowSeconds: 300,
- lockoutSeconds: 600,
- });
- const ip = "10.0.0.1";
- for (let i = 0; i < 19; i++) {
- policy.recordFailure(ip);
- }
- expect(policy.check(ip)).toEqual({ allowed: true });
- });
- it("blocks after 20 consecutive failures", () => {
- const policy = new LoginAbusePolicy({
- maxAttemptsPerIp: 20,
- maxAttemptsPerKey: 20,
- windowSeconds: 300,
- lockoutSeconds: 600,
- });
- const ip = "10.0.0.2";
- for (let i = 0; i < 20; i++) {
- policy.recordFailure(ip);
- }
- const decision = policy.check(ip);
- expect(decision.allowed).toBe(false);
- expect(decision.retryAfterSeconds).toBe(600);
- });
- it("resets failure count after success", () => {
- const policy = new LoginAbusePolicy({
- maxAttemptsPerIp: 20,
- maxAttemptsPerKey: 20,
- windowSeconds: 300,
- lockoutSeconds: 600,
- });
- const ip = "10.0.0.3";
- for (let i = 0; i < 15; i++) {
- policy.recordFailure(ip);
- }
- policy.recordSuccess(ip);
- // After success, counter is reset — 5 more failures should be allowed
- for (let i = 0; i < 5; i++) {
- policy.recordFailure(ip);
- }
- expect(policy.check(ip)).toEqual({ allowed: true });
- });
- it("unlocks after lockout period expires", () => {
- const policy = new LoginAbusePolicy({
- maxAttemptsPerIp: 20,
- maxAttemptsPerKey: 20,
- windowSeconds: 300,
- lockoutSeconds: 600,
- });
- const ip = "10.0.0.4";
- for (let i = 0; i < 20; i++) {
- policy.recordFailure(ip);
- }
- expect(policy.check(ip).allowed).toBe(false);
- // Advance past lockout
- vi.advanceTimersByTime(601_000);
- expect(policy.check(ip).allowed).toBe(true);
- });
- it("tracks different IPs independently", () => {
- const policy = new LoginAbusePolicy({
- maxAttemptsPerIp: 3,
- maxAttemptsPerKey: 3,
- windowSeconds: 300,
- lockoutSeconds: 600,
- });
- const ipA = "10.0.0.10";
- const ipB = "10.0.0.11";
- for (let i = 0; i < 3; i++) {
- policy.recordFailure(ipA);
- }
- expect(policy.check(ipA).allowed).toBe(false);
- expect(policy.check(ipB).allowed).toBe(true);
- });
- });
- describe("extractClientIp logic (rightmost x-forwarded-for)", () => {
- it("takes rightmost IP from x-forwarded-for", () => {
- // Simulates: client spoofs leftmost, proxy appends real IP
- const forwarded = "spoofed-ip, real-client-ip";
- const ips = forwarded
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean);
- expect(ips[ips.length - 1]).toBe("real-client-ip");
- });
- it("handles single IP in x-forwarded-for", () => {
- const forwarded = "192.168.1.1";
- const ips = forwarded
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean);
- expect(ips[ips.length - 1]).toBe("192.168.1.1");
- });
- it("prefers x-real-ip over x-forwarded-for", () => {
- // The implementation checks x-real-ip first
- const realIp = "10.0.0.1";
- const forwarded = "spoofed, 10.0.0.2";
- // x-real-ip is present and non-empty → use it
- const result = realIp.trim() || undefined;
- expect(result).toBe("10.0.0.1");
- });
- it("returns 'unknown' when no headers present", () => {
- const realIp: string | null = null;
- const forwarded: string | null = null;
- const result = realIp?.trim() || forwarded || "unknown";
- expect(result).toBe("unknown");
- });
- });
|