proxy-auth-rate-limit.test.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. /**
  3. * Tests for the proxy auth pre-auth rate limiter.
  4. *
  5. * The rate limiter is a module-level LoginAbusePolicy instance inside
  6. * auth-guard.ts. Since it relies on ProxySession (which depends on Hono
  7. * Context), we test the underlying LoginAbusePolicy behaviour that the
  8. * guard delegates to, plus the IP extraction helper logic.
  9. */
  10. // We test the LoginAbusePolicy directly with proxy-specific config
  11. import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy";
  12. describe("Proxy pre-auth rate limiter (LoginAbusePolicy with proxy config)", () => {
  13. const nowMs = 1_700_000_000_000;
  14. beforeEach(() => {
  15. vi.useFakeTimers();
  16. vi.setSystemTime(new Date(nowMs));
  17. });
  18. afterEach(() => {
  19. vi.useRealTimers();
  20. });
  21. it("allows requests below the proxy threshold (20)", () => {
  22. const policy = new LoginAbusePolicy({
  23. maxAttemptsPerIp: 20,
  24. maxAttemptsPerKey: 20,
  25. windowSeconds: 300,
  26. lockoutSeconds: 600,
  27. });
  28. const ip = "10.0.0.1";
  29. for (let i = 0; i < 19; i++) {
  30. policy.recordFailure(ip);
  31. }
  32. expect(policy.check(ip)).toEqual({ allowed: true });
  33. });
  34. it("blocks after 20 consecutive failures", () => {
  35. const policy = new LoginAbusePolicy({
  36. maxAttemptsPerIp: 20,
  37. maxAttemptsPerKey: 20,
  38. windowSeconds: 300,
  39. lockoutSeconds: 600,
  40. });
  41. const ip = "10.0.0.2";
  42. for (let i = 0; i < 20; i++) {
  43. policy.recordFailure(ip);
  44. }
  45. const decision = policy.check(ip);
  46. expect(decision.allowed).toBe(false);
  47. expect(decision.retryAfterSeconds).toBe(600);
  48. });
  49. it("resets failure count after success", () => {
  50. const policy = new LoginAbusePolicy({
  51. maxAttemptsPerIp: 20,
  52. maxAttemptsPerKey: 20,
  53. windowSeconds: 300,
  54. lockoutSeconds: 600,
  55. });
  56. const ip = "10.0.0.3";
  57. for (let i = 0; i < 15; i++) {
  58. policy.recordFailure(ip);
  59. }
  60. policy.recordSuccess(ip);
  61. // After success, counter is reset — 5 more failures should be allowed
  62. for (let i = 0; i < 5; i++) {
  63. policy.recordFailure(ip);
  64. }
  65. expect(policy.check(ip)).toEqual({ allowed: true });
  66. });
  67. it("unlocks after lockout period expires", () => {
  68. const policy = new LoginAbusePolicy({
  69. maxAttemptsPerIp: 20,
  70. maxAttemptsPerKey: 20,
  71. windowSeconds: 300,
  72. lockoutSeconds: 600,
  73. });
  74. const ip = "10.0.0.4";
  75. for (let i = 0; i < 20; i++) {
  76. policy.recordFailure(ip);
  77. }
  78. expect(policy.check(ip).allowed).toBe(false);
  79. // Advance past lockout
  80. vi.advanceTimersByTime(601_000);
  81. expect(policy.check(ip).allowed).toBe(true);
  82. });
  83. it("tracks different IPs independently", () => {
  84. const policy = new LoginAbusePolicy({
  85. maxAttemptsPerIp: 3,
  86. maxAttemptsPerKey: 3,
  87. windowSeconds: 300,
  88. lockoutSeconds: 600,
  89. });
  90. const ipA = "10.0.0.10";
  91. const ipB = "10.0.0.11";
  92. for (let i = 0; i < 3; i++) {
  93. policy.recordFailure(ipA);
  94. }
  95. expect(policy.check(ipA).allowed).toBe(false);
  96. expect(policy.check(ipB).allowed).toBe(true);
  97. });
  98. });
  99. describe("extractClientIp logic (rightmost x-forwarded-for)", () => {
  100. it("takes rightmost IP from x-forwarded-for", () => {
  101. // Simulates: client spoofs leftmost, proxy appends real IP
  102. const forwarded = "spoofed-ip, real-client-ip";
  103. const ips = forwarded
  104. .split(",")
  105. .map((s) => s.trim())
  106. .filter(Boolean);
  107. expect(ips[ips.length - 1]).toBe("real-client-ip");
  108. });
  109. it("handles single IP in x-forwarded-for", () => {
  110. const forwarded = "192.168.1.1";
  111. const ips = forwarded
  112. .split(",")
  113. .map((s) => s.trim())
  114. .filter(Boolean);
  115. expect(ips[ips.length - 1]).toBe("192.168.1.1");
  116. });
  117. it("prefers x-real-ip over x-forwarded-for", () => {
  118. // The implementation checks x-real-ip first
  119. const realIp = "10.0.0.1";
  120. const forwarded = "spoofed, 10.0.0.2";
  121. // x-real-ip is present and non-empty → use it
  122. const result = realIp.trim() || undefined;
  123. expect(result).toBe("10.0.0.1");
  124. });
  125. it("returns 'unknown' when no headers present", () => {
  126. const realIp: string | null = null;
  127. const forwarded: string | null = null;
  128. const result = realIp?.trim() || forwarded || "unknown";
  129. expect(result).toBe("unknown");
  130. });
  131. });