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(); | |
| // Every limiter instance that a consume touches; one module should mean one. | |
| const consumeInstances = new Set<unknown>(); | |
| vi.mock("hash-wasm", () => ({ | |
| argon2Verify: vi.fn(() => Promise.resolve(mockArgon2VerifyResult)), | |
| })); | |
| vi.mock("rate-limiter-flexible", () => ({ | |
| RateLimiterMemory: class { | |
| consume = vi.fn((key: string) => { | |
| consumeInstances.add(this); | |
| 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"), | |
| hasSearchTokenFileChanged: vi.fn().mockReturnValue(false), | |
| })); | |
| 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; | |
| } | |
| /** | |
| * Builds a well-formed argon2id hash string with the client's fixed parameters | |
| * (m=512, t=16, p=1). The suffix makes each call unique so the same suffix is | |
| * treated as the same token by the rejection cache. | |
| */ | |
| function makeValidHash(suffix: string): string { | |
| const salt = Buffer.from(`${suffix}salt`).toString("base64url"); | |
| const digest = Buffer.from(`${suffix}digest`).toString("base64url"); | |
| return `$argon2id$v=19$m=512,t=16,p=1$${salt}$${digest}`; | |
| } | |
| 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).toEqual({ | |
| isAuthorized: false, | |
| statusCode: 400, | |
| error: "Missing token.", | |
| reason: "missingToken", | |
| }); | |
| }); | |
| it("should reject invalid token", async () => { | |
| mockArgon2VerifyResult = false; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const result = await verifyTokenAndRateLimit(makeValidHash("invalid")); | |
| expect(result).toEqual({ | |
| isAuthorized: false, | |
| statusCode: 401, | |
| error: "Invalid token.", | |
| reason: "invalidToken", | |
| }); | |
| }); | |
| it("refuses an already rejected token without a second argon2 verification", async () => { | |
| mockArgon2VerifyResult = false; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const hashWasm = await import("hash-wasm"); | |
| const { getAuthorizationStats } = await import( | |
| "./authorizationSinceLastRestart" | |
| ); | |
| const invalid = { | |
| isAuthorized: false, | |
| statusCode: 401, | |
| error: "Invalid token.", | |
| reason: "invalidToken", | |
| }; | |
| const cacheHitsBefore = getAuthorizationStats().rejectedTokenCacheHits; | |
| expect(await verifyTokenAndRateLimit(makeValidHash("dead"))).toEqual( | |
| invalid, | |
| ); | |
| expect(await verifyTokenAndRateLimit(makeValidHash("dead"))).toEqual( | |
| invalid, | |
| ); | |
| // The first refusal pays for the verification; the repeat is a cache hit. | |
| expect(hashWasm.argon2Verify).toHaveBeenCalledTimes(1); | |
| expect(getAuthorizationStats().rejectedTokenCacheHits).toBe( | |
| cacheHitsBefore + 1, | |
| ); | |
| }); | |
| it("does not record a token that verifies", async () => { | |
| mockArgon2VerifyResult = true; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const { isRejectedToken } = await import("./rejectedTokens"); | |
| const live = makeValidHash("live"); | |
| const result = await verifyTokenAndRateLimit(live); | |
| expect(result.isAuthorized).toBe(true); | |
| expect(isRejectedToken(live)).toBe(false); | |
| }); | |
| it("does not cache a token whose verification threw, so the same token verifies on the next attempt", async () => { | |
| mockArgon2VerifyResult = true; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const hashWasm = await import("hash-wasm"); | |
| const searchToken = await import("./searchToken"); | |
| const { isRejectedToken } = await import("./rejectedTokens"); | |
| // The transient throw: the token file could not be read, not the token | |
| // being dead. | |
| vi.mocked(searchToken.getSearchToken).mockImplementationOnce(() => { | |
| throw new Error("ENOENT"); | |
| }); | |
| const invalid = { | |
| isAuthorized: false, | |
| statusCode: 401, | |
| error: "Invalid token.", | |
| reason: "invalidToken", | |
| }; | |
| const flaky = makeValidHash("flaky"); | |
| expect(await verifyTokenAndRateLimit(flaky)).toEqual(invalid); | |
| // Nothing was cached, so once the read succeeds the same token verifies. | |
| expect((await verifyTokenAndRateLimit(flaky)).isAuthorized).toBe(true); | |
| expect(hashWasm.argon2Verify).toHaveBeenCalledTimes(1); | |
| expect(isRejectedToken(flaky)).toBe(false); | |
| }); | |
| it("does not cache a token when argon2 itself throws, as it does on an unparseable hash", async () => { | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const hashWasm = await import("hash-wasm"); | |
| const { isRejectedToken } = await import("./rejectedTokens"); | |
| vi.mocked(hashWasm.argon2Verify) | |
| .mockRejectedValueOnce(new Error("Invalid hash")) | |
| .mockRejectedValueOnce(new Error("Invalid hash")); | |
| const invalid = { | |
| isAuthorized: false, | |
| statusCode: 401, | |
| error: "Invalid token.", | |
| reason: "invalidToken", | |
| }; | |
| // A well-formed hash whose salt is invalid base64; the parameter block | |
| // passes our check, but argon2Verify itself rejects the encoding. | |
| const notAHash = | |
| "$argon2id$v=19$m=512,t=16,p=1$!!!invalid!!!$!!!invalid!!!"; | |
| expect(await verifyTokenAndRateLimit(notAHash)).toEqual(invalid); | |
| expect(await verifyTokenAndRateLimit(notAHash)).toEqual(invalid); | |
| // Junk costs a prefix reject plus a decode-time throw, not a full | |
| // verification, so caching it would only spend a slot: it is refused again | |
| // from scratch. | |
| expect(hashWasm.argon2Verify).toHaveBeenCalledTimes(2); | |
| expect(isRejectedToken(notAHash)).toBe(false); | |
| }); | |
| it("refuses a token whose parameter block differs from the client's before argon2Verify runs", async () => { | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const hashWasm = await import("hash-wasm"); | |
| // A hash with inflated parameters: m=4194304 would force a multi-gigabyte | |
| // allocation if argon2Verify ever saw it. | |
| const malicious = | |
| "$argon2id$v=19$m=4194304,t=1000,p=1$xJaao6+z/VEA4+CU/+LAKg$JCE6vg7EHYLNv+EfNk1R6oJsEoDOsv2zNAXzQrrvI0E"; | |
| const result = await verifyTokenAndRateLimit(malicious); | |
| expect(result).toEqual({ | |
| isAuthorized: false, | |
| statusCode: 401, | |
| error: "Invalid token.", | |
| reason: "invalidToken", | |
| }); | |
| expect(hashWasm.argon2Verify).not.toHaveBeenCalled(); | |
| }); | |
| it("accepts a well-formed token with the client's parameters", async () => { | |
| mockArgon2VerifyResult = true; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const hashWasm = await import("hash-wasm"); | |
| const valid = makeValidHash("wellformed"); | |
| const result = await verifyTokenAndRateLimit(valid); | |
| expect(result.isAuthorized).toBe(true); | |
| expect(hashWasm.argon2Verify).toHaveBeenCalledTimes(1); | |
| }); | |
| it("accepts a hash produced by hash-wasm with the shared argon2 parameters", async () => { | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const hashWasm = await import("hash-wasm"); | |
| const { ARGON2_PARAMETERS } = await import("../shared/argon2Parameters.ts"); | |
| const { argon2id } = | |
| await vi.importActual<typeof import("hash-wasm")>("hash-wasm"); | |
| const realHash = await argon2id({ | |
| password: "dummy-token", | |
| salt: new Uint8Array(16), | |
| ...ARGON2_PARAMETERS, | |
| outputType: "encoded", | |
| }); | |
| const result = await verifyTokenAndRateLimit(realHash); | |
| expect(result.isAuthorized).toBe(true); | |
| expect(hashWasm.argon2Verify).toHaveBeenCalledWith({ | |
| password: "dummy-token", | |
| hash: realHash, | |
| }); | |
| }); | |
| it("should accept valid token and add to verified tokens", async () => { | |
| mockArgon2VerifyResult = true; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const valid = makeValidHash("valid"); | |
| const result = await verifyTokenAndRateLimit(valid); | |
| expect(result.isAuthorized).toBe(true); | |
| expect(result).not.toHaveProperty("statusCode"); | |
| expect(mockAddVerifiedToken).toHaveBeenCalledWith(valid); | |
| }); | |
| 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 alreadyVerified = makeValidHash("alreadyverified"); | |
| const result = await verifyTokenAndRateLimit(alreadyVerified); | |
| 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(makeValidHash("ratelimit")); | |
| expect(result).toEqual({ | |
| isAuthorized: false, | |
| statusCode: 429, | |
| error: "Too many requests.", | |
| reason: "rateLimited", | |
| }); | |
| }); | |
| it("should rate-limit invalid tokens instead of skipping the limiter", async () => { | |
| mockArgon2VerifyResult = false; | |
| mockRateLimiterShouldFail = true; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const result = await verifyTokenAndRateLimit(makeValidHash("invalidrl")); | |
| expect(result).toEqual({ | |
| isAuthorized: false, | |
| statusCode: 429, | |
| error: "Too many requests.", | |
| reason: "rateLimited", | |
| }); | |
| }); | |
| it("should not run argon2 verification when the request is already rate limited", async () => { | |
| mockRateLimiterShouldFail = true; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const hashWasm = await import("hash-wasm"); | |
| const result = await verifyTokenAndRateLimit(makeValidHash("somelimit")); | |
| expect(result).toEqual({ | |
| isAuthorized: false, | |
| statusCode: 429, | |
| error: "Too many requests.", | |
| reason: "rateLimited", | |
| }); | |
| expect(hashWasm.argon2Verify).not.toHaveBeenCalled(); | |
| }); | |
| it("should rate-limit requests with a missing token", async () => { | |
| mockRateLimiterShouldFail = true; | |
| vi.resetModules(); | |
| const { verifyTokenAndRateLimit } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const result = await verifyTokenAndRateLimit(null); | |
| expect(result).toEqual({ | |
| isAuthorized: false, | |
| statusCode: 429, | |
| error: "Too many requests.", | |
| reason: "rateLimited", | |
| }); | |
| }); | |
| 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( | |
| makeValidHash("valid"), | |
| mockReq, | |
| ); | |
| expect(result.isAuthorized).toBe(true); | |
| expect(mockConsume).toHaveBeenCalledWith("127.0.0.1"); | |
| }); | |
| it("draws /thumbnail requests from a separate limiter, so one grid cannot exhaust the search budget", async () => { | |
| vi.resetModules(); | |
| const { | |
| verifyTokenAndRateLimit, | |
| thumbnailRateLimiter, | |
| RATE_LIMIT_POINTS, | |
| THUMBNAIL_RATE_LIMIT_POINTS, | |
| } = await import("./verifyTokenAndRateLimit"); | |
| consumeInstances.clear(); | |
| mockIsVerifiedToken = true; | |
| const mockReq = makeMockRequest("192.168.1.100"); | |
| await verifyTokenAndRateLimit("token", mockReq); | |
| const searchInstances = [...consumeInstances]; | |
| await verifyTokenAndRateLimit("token", mockReq, thumbnailRateLimiter); | |
| const thumbnailInstances = [...consumeInstances].filter( | |
| (instance) => !searchInstances.includes(instance), | |
| ); | |
| // The tile load drew from a different limiter than the search, so the | |
| // up-to-30-tile fan-out of one image search cannot eat the user's | |
| // text-search and page-read budget. | |
| expect(searchInstances).toHaveLength(1); | |
| expect(thumbnailInstances).toHaveLength(1); | |
| expect(THUMBNAIL_RATE_LIMIT_POINTS).toBeGreaterThan(RATE_LIMIT_POINTS); | |
| }); | |
| 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( | |
| makeValidHash("valid"), | |
| 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(makeValidHash("fallback")); | |
| expect(result.isAuthorized).toBe(true); | |
| expect(mockConsume).toHaveBeenCalledWith( | |
| expect.stringContaining("$argon2id"), | |
| ); | |
| }); | |
| }); | |
| describe("consumeRateLimitPoint", () => { | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| consumeInstances.clear(); | |
| mockRateLimiterShouldFail = false; | |
| }); | |
| it("consumes from the limiter keyed on socket.remoteAddress when not behind a trusted proxy", async () => { | |
| const { consumeRateLimitPoint } = await import("./verifyTokenAndRateLimit"); | |
| const mockReq = makeMockRequest("192.168.1.100"); | |
| expect(await consumeRateLimitPoint(mockReq)).toBe(true); | |
| expect(mockConsume).toHaveBeenCalledWith("127.0.0.1"); | |
| }); | |
| it("reports out of budget when the limiter refuses", async () => { | |
| mockRateLimiterShouldFail = true; | |
| const { consumeRateLimitPoint } = await import("./verifyTokenAndRateLimit"); | |
| expect(await consumeRateLimitPoint(makeMockRequest("192.168.1.100"))).toBe( | |
| false, | |
| ); | |
| }); | |
| it("draws from the same limiter instance and key as the search path", async () => { | |
| const { verifyTokenAndRateLimit, consumeRateLimitPoint } = await import( | |
| "./verifyTokenAndRateLimit" | |
| ); | |
| const mockReq = makeMockRequest("192.168.1.100"); | |
| await verifyTokenAndRateLimit(makeValidHash("valid"), mockReq); | |
| await consumeRateLimitPoint(mockReq); | |
| // Both went through the one shared limiter, keyed on the same address. | |
| expect(consumeInstances.size).toBe(1); | |
| expect(mockConsume).toHaveBeenCalledTimes(2); | |
| expect(mockConsume).toHaveBeenNthCalledWith(1, "127.0.0.1"); | |
| expect(mockConsume).toHaveBeenNthCalledWith(2, "127.0.0.1"); | |
| }); | |
| }); | |
| 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"); | |
| }); | |
| }); | |
| }); | |
| }); | |