Spaces:
Running
Running
| import type { IncomingMessage } from "node:http"; | |
| import { beforeEach, describe, expect, it, vi } from "vitest"; | |
| let mockArgon2VerifyResult = true; | |
| let mockIsVerifiedToken = false; | |
| let mockRateLimiterShouldFail = false; | |
| const mockAddVerifiedToken = vi.fn(); | |
| const mockConsume = vi.fn(); | |
| vi.mock("hash-wasm", () => ({ | |
| argon2Verify: vi.fn(() => Promise.resolve(mockArgon2VerifyResult)), | |
| })); | |
| vi.mock("rate-limiter-flexible", () => ({ | |
| RateLimiterMemory: class { | |
| consume = vi.fn((key: string) => { | |
| mockConsume(key); | |
| if (mockRateLimiterShouldFail) { | |
| return Promise.reject(new Error("Rate limit exceeded")); | |
| } | |
| return Promise.resolve(undefined); | |
| }); | |
| }, | |
| })); | |
| vi.mock("./searchToken", () => ({ | |
| getSearchToken: vi.fn().mockReturnValue("dummy-token"), | |
| })); | |
| vi.mock("./verifiedTokens", () => ({ | |
| addVerifiedToken: vi.fn((token: string) => mockAddVerifiedToken(token)), | |
| isVerifiedToken: vi.fn(() => mockIsVerifiedToken), | |
| })); | |
| function makeMockRequest(ip: string): IncomingMessage { | |
| return { | |
| headers: { "x-forwarded-for": ip }, | |
| socket: { remoteAddress: "127.0.0.1" }, | |
| } as unknown as IncomingMessage; | |
| } | |
| describe("verifyTokenAndRateLimit", () => { | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| mockArgon2VerifyResult = true; | |
| mockIsVerifiedToken = false; | |
| mockRateLimiterShouldFail = false; | |
| }); | |
| it("should reject missing token", async () => { | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const result = await verifyTokenAndRateLimit(null); | |
| expect(result.isAuthorized).toBe(false); | |
| expect(result.statusCode).toBe(400); | |
| expect(result.error).toBe("Missing token."); | |
| }); | |
| it("should reject invalid token", async () => { | |
| mockArgon2VerifyResult = false; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const result = await verifyTokenAndRateLimit("invalid-token"); | |
| expect(result.isAuthorized).toBe(false); | |
| expect(result.statusCode).toBe(401); | |
| expect(result.error).toBe("Invalid token."); | |
| }); | |
| it("should accept valid token and add to verified tokens", async () => { | |
| mockArgon2VerifyResult = true; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const result = await verifyTokenAndRateLimit("valid-token"); | |
| expect(result.isAuthorized).toBe(true); | |
| expect(result).not.toHaveProperty("statusCode"); | |
| expect(mockAddVerifiedToken).toHaveBeenCalledWith("valid-token"); | |
| }); | |
| it("should skip verification for already verified tokens", async () => { | |
| mockIsVerifiedToken = true; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const hashWasm = await import("hash-wasm"); | |
| const result = await verifyTokenAndRateLimit("already-verified-token"); | |
| expect(result.isAuthorized).toBe(true); | |
| expect(hashWasm.argon2Verify).not.toHaveBeenCalled(); | |
| }); | |
| it("should enforce rate limiting", async () => { | |
| mockRateLimiterShouldFail = true; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const result = await verifyTokenAndRateLimit("rate-limit-token"); | |
| expect(result.isAuthorized).toBe(false); | |
| expect(result.statusCode).toBe(429); | |
| expect(result.error).toBe("Too many requests."); | |
| }); | |
| it("should key rate limiter on the socket address by default (untrusted proxy)", async () => { | |
| mockRateLimiterShouldFail = false; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| // makeMockRequest sets a spoofable X-Forwarded-For, but with TRUST_PROXY | |
| // off the real TCP peer (socket.remoteAddress) must be used instead. | |
| const mockReq = makeMockRequest("192.168.1.100"); | |
| const result = await verifyTokenAndRateLimit("valid-token", mockReq); | |
| expect(result.isAuthorized).toBe(true); | |
| expect(mockConsume).toHaveBeenCalledWith("127.0.0.1"); | |
| }); | |
| it("should key rate limiter on the forwarded client IP when TRUST_PROXY is enabled", async () => { | |
| mockRateLimiterShouldFail = false; | |
| vi.stubEnv("TRUST_PROXY", "true"); | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const mockReq = makeMockRequest("192.168.1.100"); | |
| const result = await verifyTokenAndRateLimit("valid-token", mockReq); | |
| expect(result.isAuthorized).toBe(true); | |
| expect(mockConsume).toHaveBeenCalledWith("192.168.1.100"); | |
| vi.unstubAllEnvs(); | |
| }); | |
| it("should fall back to token as rate limit key when no request", async () => { | |
| mockRateLimiterShouldFail = false; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const result = await verifyTokenAndRateLimit("fallback-token"); | |
| expect(result.isAuthorized).toBe(true); | |
| expect(mockConsume).toHaveBeenCalledWith("fallback-token"); | |
| }); | |
| }); | |
| describe("getClientIp", () => { | |
| describe("when TRUST_PROXY is enabled", () => { | |
| beforeEach(() => { | |
| vi.stubEnv("TRUST_PROXY", "true"); | |
| }); | |
| it("should extract last (trusted) IP from X-Forwarded-For", () => { | |
| vi.resetModules(); | |
| return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => { | |
| const req = { | |
| headers: { "x-forwarded-for": "10.0.0.1, 10.0.0.2, 192.168.1.50" }, | |
| socket: { remoteAddress: "127.0.0.1" }, | |
| } as unknown as IncomingMessage; | |
| expect(getClientIp(req)).toBe("192.168.1.50"); | |
| }); | |
| }); | |
| it("should reject spoofed leftmost X-Forwarded-For entry", () => { | |
| vi.resetModules(); | |
| return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => { | |
| const req = { | |
| headers: { "x-forwarded-for": "1.2.3.4, 192.168.1.50" }, | |
| socket: { remoteAddress: "127.0.0.1" }, | |
| } as unknown as IncomingMessage; | |
| expect(getClientIp(req)).toBe("192.168.1.50"); | |
| }); | |
| }); | |
| it("should fall back to X-Real-IP", () => { | |
| vi.resetModules(); | |
| return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => { | |
| const req = { | |
| headers: { "x-real-ip": "172.16.0.1" }, | |
| socket: { remoteAddress: "127.0.0.1" }, | |
| } as unknown as IncomingMessage; | |
| expect(getClientIp(req)).toBe("172.16.0.1"); | |
| }); | |
| }); | |
| it("should fall back to socket.remoteAddress", () => { | |
| vi.resetModules(); | |
| return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => { | |
| const req = { | |
| headers: {}, | |
| socket: { remoteAddress: "192.168.0.1" }, | |
| } as unknown as IncomingMessage; | |
| expect(getClientIp(req)).toBe("192.168.0.1"); | |
| }); | |
| }); | |
| it("should reject non-IP X-Forwarded-For entries", () => { | |
| vi.resetModules(); | |
| return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => { | |
| const req = { | |
| headers: { "x-forwarded-for": "not-an-ip, also-not-an-ip" }, | |
| socket: { remoteAddress: "10.0.0.5" }, | |
| } as unknown as IncomingMessage; | |
| expect(getClientIp(req)).toBe("10.0.0.5"); | |
| }); | |
| }); | |
| it("should handle array-valued X-Forwarded-For header", () => { | |
| vi.resetModules(); | |
| return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => { | |
| const req = { | |
| headers: { | |
| "x-forwarded-for": ["10.0.0.1", "10.0.0.2", "192.168.1.50"], | |
| }, | |
| socket: { remoteAddress: "127.0.0.1" }, | |
| } as unknown as IncomingMessage; | |
| expect(getClientIp(req)).toBe("192.168.1.50"); | |
| }); | |
| }); | |
| }); | |
| describe("when TRUST_PROXY is disabled (default)", () => { | |
| beforeEach(() => { | |
| vi.unstubAllEnvs(); | |
| }); | |
| it("should ignore a spoofable X-Forwarded-For and use the socket address", () => { | |
| vi.resetModules(); | |
| return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => { | |
| const req = { | |
| headers: { "x-forwarded-for": "1.2.3.4, 5.6.7.8" }, | |
| socket: { remoteAddress: "10.0.0.5" }, | |
| } as unknown as IncomingMessage; | |
| expect(getClientIp(req)).toBe("10.0.0.5"); | |
| }); | |
| }); | |
| it("should ignore X-Real-IP and use the socket address", () => { | |
| vi.resetModules(); | |
| return import("./verifyTokenAndRateLimit").then(({ getClientIp }) => { | |
| const req = { | |
| headers: { "x-real-ip": "172.16.0.1" }, | |
| socket: { remoteAddress: "10.0.0.5" }, | |
| } as unknown as IncomingMessage; | |
| expect(getClientIp(req)).toBe("10.0.0.5"); | |
| }); | |
| }); | |
| }); | |
| }); | |