From e9eb0583becda84bc3f78cfbf976046e0b0c48a1 Mon Sep 17 00:00:00 2001 From: snkk2x-collab Date: Sat, 20 Jun 2026 06:46:14 +0800 Subject: [PATCH 1/2] feat: add Redis-backed API rate limits --- README.md | 7 + .../__tests__/rate-limit.middleware.test.ts | 124 ++++++++++++ .../src/middleware/rate-limit.middleware.ts | 185 ++++++++++++++++++ Server/src/routes/auth.routes.ts | 25 ++- Server/src/routes/events.routes.ts | 8 +- Server/src/routes/user.routes.ts | 2 + 6 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 Server/src/middleware/__tests__/rate-limit.middleware.test.ts create mode 100644 Server/src/middleware/rate-limit.middleware.ts diff --git a/README.md b/README.md index 8b76cde..7194751 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,13 @@ copy Front\.env.example Front\.env.local | `ADMIN_SECRET` | `dev_admin_secret_local` | Key for `POST /api/platforms/register` | | `STELLAR_NETWORK` | `testnet` | `testnet` or `mainnet` | | `PORT` | `3000` | API port | +| `UPSTASH_REDIS_REST_URL` | optional | Upstash Redis REST URL for public API rate limits | +| `UPSTASH_REDIS_REST_TOKEN` | optional | Upstash Redis REST token for public API rate limits | + +Public API rate limits protect `POST /api/auth/challenge`, signed auth routes, +`POST /api/events/report`, and `GET /api/user/{wallet}/score`. If Redis is not +configured or is temporarily unavailable, the API logs a warning and allows the +request so health checks and local development are not blocked. #### `Front/.env.local` diff --git a/Server/src/middleware/__tests__/rate-limit.middleware.test.ts b/Server/src/middleware/__tests__/rate-limit.middleware.test.ts new file mode 100644 index 0000000..96d1a0c --- /dev/null +++ b/Server/src/middleware/__tests__/rate-limit.middleware.test.ts @@ -0,0 +1,124 @@ +import { NextFunction, Request, Response } from "express"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createRateLimitMiddleware, + resetRateLimitWarningsForTests, +} from "../rate-limit.middleware"; + +const ORIGINAL_REDIS_URL = process.env.UPSTASH_REDIS_REST_URL; +const ORIGINAL_REDIS_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN; + +function mockRequest(overrides: Partial = {}) { + return { + ip: "203.0.113.10", + socket: { remoteAddress: "203.0.113.11" }, + headers: {}, + body: {}, + ...overrides, + } as Request; +} + +function mockResponse() { + const res = { + setHeader: vi.fn(), + status: vi.fn(), + json: vi.fn(), + }; + res.status.mockReturnValue(res); + res.json.mockReturnValue(res); + return res as unknown as Response; +} + +function middleware(limit = 2) { + return createRateLimitMiddleware({ + name: "test-route", + limit, + windowSeconds: 60, + identity: (req) => req.ip || "unknown", + }); +} + +describe("rate limit middleware", () => { + beforeEach(() => { + resetRateLimitWarningsForTests(); + delete process.env.UPSTASH_REDIS_REST_URL; + delete process.env.UPSTASH_REDIS_REST_TOKEN; + vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + if (ORIGINAL_REDIS_URL === undefined) { + delete process.env.UPSTASH_REDIS_REST_URL; + } else { + process.env.UPSTASH_REDIS_REST_URL = ORIGINAL_REDIS_URL; + } + + if (ORIGINAL_REDIS_TOKEN === undefined) { + delete process.env.UPSTASH_REDIS_REST_TOKEN; + } else { + process.env.UPSTASH_REDIS_REST_TOKEN = ORIGINAL_REDIS_TOKEN; + } + + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("allows requests when Redis is not configured", async () => { + const req = mockRequest(); + const res = mockResponse(); + const next = vi.fn() as NextFunction; + + await middleware()(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("Rate limiting disabled") + ); + }); + + it("returns 429 with Retry-After when the Redis counter exceeds the limit", async () => { + process.env.UPSTASH_REDIS_REST_URL = "https://redis.example.com"; + process.env.UPSTASH_REDIS_REST_TOKEN = "token"; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => [{ result: 3 }, { result: 1 }], + }) + ); + + const req = mockRequest(); + const res = mockResponse(); + const next = vi.fn() as NextFunction; + + await middleware(2)(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.setHeader).toHaveBeenCalledWith("Retry-After", expect.any(String)); + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: "Rate limit exceeded", + retryAfter: expect.any(Number), + }); + }); + + it("fails open when Redis returns an error", async () => { + process.env.UPSTASH_REDIS_REST_URL = "https://redis.example.com/"; + process.env.UPSTASH_REDIS_REST_TOKEN = "token"; + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down"))); + + const req = mockRequest(); + const res = mockResponse(); + const next = vi.fn() as NextFunction; + + await middleware(2)(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("Rate limiting unavailable") + ); + }); +}); diff --git a/Server/src/middleware/rate-limit.middleware.ts b/Server/src/middleware/rate-limit.middleware.ts new file mode 100644 index 0000000..aea4f98 --- /dev/null +++ b/Server/src/middleware/rate-limit.middleware.ts @@ -0,0 +1,185 @@ +import { NextFunction, Request, RequestHandler, Response } from "express"; + +type RateLimitIdentity = (req: Request) => string; + +type RateLimitOptions = { + name: string; + limit: number; + windowSeconds: number; + identity: RateLimitIdentity; +}; + +type RateLimitResult = { + allowed: boolean; + retryAfter: number; +}; + +let warnedMissingRedis = false; +let warnedRedisFailure = false; + +const redisUrl = () => process.env.UPSTASH_REDIS_REST_URL?.replace(/\/$/, ""); +const redisToken = () => process.env.UPSTASH_REDIS_REST_TOKEN; + +function warnOnce(kind: "missing" | "failure", message: string) { + if (kind === "missing") { + if (!warnedMissingRedis) { + warnedMissingRedis = true; + console.warn(message); + } + return; + } + + if (!warnedRedisFailure) { + warnedRedisFailure = true; + console.warn(message); + } +} + +function encodeIdentity(identity: string) { + return Buffer.from(identity).toString("base64url"); +} + +function currentWindow(windowSeconds: number) { + const now = Date.now(); + const windowMs = windowSeconds * 1000; + const id = Math.floor(now / windowMs); + const resetAt = (id + 1) * windowMs; + return { + id, + retryAfter: Math.max(1, Math.ceil((resetAt - now) / 1000)), + }; +} + +async function checkRateLimit( + keyPrefix: string, + identity: string, + limit: number, + windowSeconds: number +): Promise { + const url = redisUrl(); + const token = redisToken(); + + if (!url || !token) { + warnOnce( + "missing", + "Rate limiting disabled: UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN is not configured." + ); + return { allowed: true, retryAfter: 0 }; + } + + const window = currentWindow(windowSeconds); + const redisKey = `rate-limit:${keyPrefix}:${encodeIdentity(identity)}:${window.id}`; + + try { + const response = await fetch(`${url}/pipeline`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify([ + ["INCR", redisKey], + ["EXPIRE", redisKey, windowSeconds + 1], + ]), + }); + + if (!response.ok) { + throw new Error(`Redis rate limit request failed with ${response.status}`); + } + + const payload = (await response.json()) as Array<{ result?: unknown }>; + const count = Number(payload[0]?.result); + + if (!Number.isFinite(count)) { + throw new Error("Redis rate limit response did not include a numeric count"); + } + + return { + allowed: count <= limit, + retryAfter: window.retryAfter, + }; + } catch (error) { + warnOnce( + "failure", + `Rate limiting unavailable; allowing request. ${ + error instanceof Error ? error.message : String(error) + }` + ); + return { allowed: true, retryAfter: 0 }; + } +} + +export function createRateLimitMiddleware(options: RateLimitOptions): RequestHandler { + return async (req: Request, res: Response, next: NextFunction) => { + const identity = options.identity(req); + const result = await checkRateLimit( + options.name, + identity, + options.limit, + options.windowSeconds + ); + + if (!result.allowed) { + res.setHeader("Retry-After", result.retryAfter.toString()); + return res.status(429).json({ + success: false, + error: "Rate limit exceeded", + retryAfter: result.retryAfter, + }); + } + + return next(); + }; +} + +function requestIp(req: Request) { + return req.ip || req.socket.remoteAddress || "unknown-ip"; +} + +function headerValue(req: Request, header: string) { + const value = req.headers[header.toLowerCase()]; + if (Array.isArray(value)) { + return value[0] || requestIp(req); + } + return value || requestIp(req); +} + +function bodyApiKey(req: Request) { + const body = req.body as { apiKey?: unknown } | undefined; + return typeof body?.apiKey === "string" && body.apiKey.trim() + ? body.apiKey + : requestIp(req); +} + +export const authChallengeRateLimit: RequestHandler = createRateLimitMiddleware({ + name: "auth-challenge", + limit: 10, + windowSeconds: 60, + identity: requestIp, +}); + +export const signedAuthRateLimit: RequestHandler = createRateLimitMiddleware({ + name: "signed-auth", + limit: 5, + windowSeconds: 60, + identity: requestIp, +}); + +export const eventReportRateLimit: RequestHandler = createRateLimitMiddleware({ + name: "events-report", + limit: 100, + windowSeconds: 60, + identity: bodyApiKey, +}); + +export const lenderScoreRateLimit: RequestHandler = createRateLimitMiddleware({ + name: "lender-score", + limit: 60, + windowSeconds: 60, + identity: (req) => headerValue(req, "x-api-key"), +}); + +export function resetRateLimitWarningsForTests() { + warnedMissingRedis = false; + warnedRedisFailure = false; +} diff --git a/Server/src/routes/auth.routes.ts b/Server/src/routes/auth.routes.ts index ae544ca..614d1a3 100644 --- a/Server/src/routes/auth.routes.ts +++ b/Server/src/routes/auth.routes.ts @@ -13,13 +13,32 @@ import { SignedAuthSchema, ChallengeRequestSchema, } from "../middleware/schemas"; +import { + authChallengeRateLimit, + signedAuthRateLimit, +} from "../middleware/rate-limit.middleware"; const router = Router(); -router.post("/challenge", validate(ChallengeRequestSchema), requestChallenge); +router.post( + "/challenge", + authChallengeRateLimit, + validate(ChallengeRequestSchema), + requestChallenge +); router.post("/register", validate(RegisterSchema), registerUser); router.post("/login", validate(LoginSchema), loginUser); -router.post("/register/signed", validate(SignedAuthSchema), registerWithSignature); -router.post("/login/signed", validate(SignedAuthSchema), loginWithSignature); +router.post( + "/register/signed", + signedAuthRateLimit, + validate(SignedAuthSchema), + registerWithSignature +); +router.post( + "/login/signed", + signedAuthRateLimit, + validate(SignedAuthSchema), + loginWithSignature +); export default router; diff --git a/Server/src/routes/events.routes.ts b/Server/src/routes/events.routes.ts index 41ac281..ed73f3b 100644 --- a/Server/src/routes/events.routes.ts +++ b/Server/src/routes/events.routes.ts @@ -2,9 +2,15 @@ import { Router } from "express"; import { reportCreditEvent } from "../controllers/events.controller"; import { validate } from "../middleware/validation.middleware"; import { CreditEventSchema } from "../middleware/schemas"; +import { eventReportRateLimit } from "../middleware/rate-limit.middleware"; const router = Router(); -router.post("/report", validate(CreditEventSchema), reportCreditEvent); +router.post( + "/report", + eventReportRateLimit, + validate(CreditEventSchema), + reportCreditEvent +); export default router; diff --git a/Server/src/routes/user.routes.ts b/Server/src/routes/user.routes.ts index 3a1133e..643fb0a 100644 --- a/Server/src/routes/user.routes.ts +++ b/Server/src/routes/user.routes.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import { lenderScoreRateLimit } from "../middleware/rate-limit.middleware"; import { getProfile, getScore, @@ -19,6 +20,7 @@ import { const router = Router(); +router.use("/:wallet/score", lenderScoreRateLimit); router.post("/request", validate(ScoringRequestSchema), requestScoring); router.get("/:wallet/on-chain", validateParams(WalletParamSchema), getOnChainScore); router.post( From 284bd3df4ec5f8d0b7e71268431b1a8a9d98176b Mon Sep 17 00:00:00 2001 From: snkk2x-collab Date: Sat, 20 Jun 2026 07:47:39 +0800 Subject: [PATCH 2/2] fix: align rate limiting with upstream --- Server/package-lock.json | 429 +++++++++++++++++- Server/package.json | 9 + .../__tests__/rate-limit.middleware.test.ts | 102 ++--- .../src/middleware/rate-limit.middleware.ts | 205 ++------- Server/src/routes/auth.routes.ts | 24 +- Server/src/routes/events.routes.ts | 12 +- Server/src/routes/user.routes.ts | 57 ++- Server/src/services/rate-limit.service.ts | 121 +++++ Server/src/utils/logger.util.ts | 28 ++ 9 files changed, 750 insertions(+), 237 deletions(-) create mode 100644 Server/src/services/rate-limit.service.ts create mode 100644 Server/src/utils/logger.util.ts diff --git a/Server/package-lock.json b/Server/package-lock.json index 69c5a9c..db81ea7 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -11,9 +11,13 @@ "dependencies": { "@prisma/client": "^5.7.0", "@stellar/stellar-sdk": "^16.0.0", + "@upstash/ratelimit": "^2.0.5", + "@upstash/redis": "^1.34.3", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "prom-client": "^15.1.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "zod": "^3.22.4" @@ -21,12 +25,15 @@ "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.10.5", + "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.3", "@vitest/coverage-v8": "^3.2.4", "nodemon": "^3.0.2", "prisma": "^5.7.0", + "supertest": "^7.1.1", "ts-node": "^10.9.2", "typescript": "^5.3.3", "vitest": "^3.2.4" @@ -721,6 +728,38 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@paralleldrive/cuid2/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1262,6 +1301,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1325,6 +1371,24 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1332,6 +1396,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", @@ -1389,6 +1460,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/swagger-jsdoc": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", @@ -1407,6 +1502,39 @@ "@types/serve-static": "*" } }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", + "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", + "license": "MIT", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@upstash/ratelimit": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.8.tgz", + "integrity": "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==", + "license": "MIT", + "dependencies": { + "@upstash/core-analytics": "^0.0.10" + }, + "peerDependencies": { + "@upstash/redis": "^1.34.3" + } + }, + "node_modules/@upstash/redis": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.0.tgz", + "integrity": "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", @@ -1687,6 +1815,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1792,6 +1927,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -1863,6 +2004,12 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2010,6 +2157,16 @@ "node": ">= 6" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2052,6 +2209,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2134,6 +2298,17 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -2189,6 +2364,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2416,6 +2600,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/feaxios": { "version": "0.0.23", "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", @@ -2509,6 +2700,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3036,6 +3245,55 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -3043,6 +3301,18 @@ "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", @@ -3050,12 +3320,42 @@ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.mergewith": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -3497,6 +3797,19 @@ "fsevents": "2.3.3" } }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3653,7 +3966,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4019,6 +4331,106 @@ "dev": true, "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/superagent/node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -4088,6 +4500,15 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/test-exclude": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", @@ -4404,6 +4825,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/Server/package.json b/Server/package.json index 752b141..c66e2b6 100644 --- a/Server/package.json +++ b/Server/package.json @@ -10,6 +10,8 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:integration": "vitest run --config vitest.integration.config.ts", + "recalc:scores": "ts-node scripts/recalculate-scores.ts", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", @@ -29,9 +31,13 @@ "dependencies": { "@prisma/client": "^5.7.0", "@stellar/stellar-sdk": "^16.0.0", + "@upstash/ratelimit": "^2.0.5", + "@upstash/redis": "^1.34.3", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "prom-client": "^15.1.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "zod": "^3.22.4" @@ -39,12 +45,15 @@ "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.10.5", + "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.3", "@vitest/coverage-v8": "^3.2.4", "nodemon": "^3.0.2", "prisma": "^5.7.0", + "supertest": "^7.1.1", "ts-node": "^10.9.2", "typescript": "^5.3.3", "vitest": "^3.2.4" diff --git a/Server/src/middleware/__tests__/rate-limit.middleware.test.ts b/Server/src/middleware/__tests__/rate-limit.middleware.test.ts index 96d1a0c..a4a0660 100644 --- a/Server/src/middleware/__tests__/rate-limit.middleware.test.ts +++ b/Server/src/middleware/__tests__/rate-limit.middleware.test.ts @@ -1,12 +1,18 @@ import { NextFunction, Request, Response } from "express"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { checkRateLimit } from "../../services/rate-limit.service"; import { - createRateLimitMiddleware, - resetRateLimitWarningsForTests, + bodyApiKey, + clientIpKey, + createRateLimiter, + headerApiKey, } from "../rate-limit.middleware"; -const ORIGINAL_REDIS_URL = process.env.UPSTASH_REDIS_REST_URL; -const ORIGINAL_REDIS_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN; +vi.mock("../../services/rate-limit.service", () => ({ + checkRateLimit: vi.fn(), +})); + +const mockedCheckRateLimit = vi.mocked(checkRateLimit); function mockRequest(overrides: Partial = {}) { return { @@ -30,64 +36,41 @@ function mockResponse() { } function middleware(limit = 2) { - return createRateLimitMiddleware({ + return createRateLimiter({ name: "test-route", limit, - windowSeconds: 60, - identity: (req) => req.ip || "unknown", + windowSec: 60, + keyGenerator: (req) => req.ip || "unknown", }); } describe("rate limit middleware", () => { beforeEach(() => { - resetRateLimitWarningsForTests(); - delete process.env.UPSTASH_REDIS_REST_URL; - delete process.env.UPSTASH_REDIS_REST_TOKEN; - vi.spyOn(console, "warn").mockImplementation(() => undefined); - }); - - afterEach(() => { - if (ORIGINAL_REDIS_URL === undefined) { - delete process.env.UPSTASH_REDIS_REST_URL; - } else { - process.env.UPSTASH_REDIS_REST_URL = ORIGINAL_REDIS_URL; - } - - if (ORIGINAL_REDIS_TOKEN === undefined) { - delete process.env.UPSTASH_REDIS_REST_TOKEN; - } else { - process.env.UPSTASH_REDIS_REST_TOKEN = ORIGINAL_REDIS_TOKEN; - } - - vi.unstubAllGlobals(); - vi.restoreAllMocks(); + mockedCheckRateLimit.mockReset(); }); - it("allows requests when Redis is not configured", async () => { + it("allows requests when the backing limiter allows the generated route key", async () => { + mockedCheckRateLimit.mockResolvedValue({ allowed: true }); const req = mockRequest(); const res = mockResponse(); const next = vi.fn() as NextFunction; await middleware()(req, res, next); + expect(mockedCheckRateLimit).toHaveBeenCalledWith( + "test-route:203.0.113.10", + 2, + 60 + ); expect(next).toHaveBeenCalledOnce(); expect(res.status).not.toHaveBeenCalled(); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining("Rate limiting disabled") - ); }); - it("returns 429 with Retry-After when the Redis counter exceeds the limit", async () => { - process.env.UPSTASH_REDIS_REST_URL = "https://redis.example.com"; - process.env.UPSTASH_REDIS_REST_TOKEN = "token"; - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => [{ result: 3 }, { result: 1 }], - }) - ); - + it("returns 429 with Retry-After when the backing limiter rejects", async () => { + mockedCheckRateLimit.mockResolvedValue({ + allowed: false, + retryAfterSec: 17, + }); const req = mockRequest(); const res = mockResponse(); const next = vi.fn() as NextFunction; @@ -95,30 +78,39 @@ describe("rate limit middleware", () => { await middleware(2)(req, res, next); expect(next).not.toHaveBeenCalled(); - expect(res.setHeader).toHaveBeenCalledWith("Retry-After", expect.any(String)); + expect(res.setHeader).toHaveBeenCalledWith("Retry-After", "17"); expect(res.status).toHaveBeenCalledWith(429); expect(res.json).toHaveBeenCalledWith({ success: false, - error: "Rate limit exceeded", - retryAfter: expect.any(Number), + error: "Too many requests. Please try again later.", + retryAfterSec: 17, }); }); - it("fails open when Redis returns an error", async () => { - process.env.UPSTASH_REDIS_REST_URL = "https://redis.example.com/"; - process.env.UPSTASH_REDIS_REST_TOKEN = "token"; - vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down"))); - + it("forwards limiter errors to Express error handling", async () => { + const error = new Error("rate limit backend down"); + mockedCheckRateLimit.mockRejectedValue(error); const req = mockRequest(); const res = mockResponse(); const next = vi.fn() as NextFunction; await middleware(2)(req, res, next); - expect(next).toHaveBeenCalledOnce(); + expect(next).toHaveBeenCalledWith(error); expect(res.status).not.toHaveBeenCalled(); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining("Rate limiting unavailable") + }); + + it("builds stable client and api-key identities", () => { + expect( + clientIpKey( + mockRequest({ headers: { "x-forwarded-for": "198.51.100.4, proxy" } }) + ) + ).toBe("198.51.100.4"); + expect(bodyApiKey(mockRequest({ body: { apiKey: "body-key" } }))).toBe( + "body-key" + ); + expect(headerApiKey(mockRequest({ headers: { "x-api-key": "header-key" } }))).toBe( + "header-key" ); }); }); diff --git a/Server/src/middleware/rate-limit.middleware.ts b/Server/src/middleware/rate-limit.middleware.ts index aea4f98..36c9a61 100644 --- a/Server/src/middleware/rate-limit.middleware.ts +++ b/Server/src/middleware/rate-limit.middleware.ts @@ -1,185 +1,52 @@ -import { NextFunction, Request, RequestHandler, Response } from "express"; +import { NextFunction, Request, Response } from "express"; +import { checkRateLimit } from "../services/rate-limit.service"; -type RateLimitIdentity = (req: Request) => string; - -type RateLimitOptions = { +export interface RateLimitOptions { name: string; limit: number; - windowSeconds: number; - identity: RateLimitIdentity; -}; - -type RateLimitResult = { - allowed: boolean; - retryAfter: number; -}; - -let warnedMissingRedis = false; -let warnedRedisFailure = false; - -const redisUrl = () => process.env.UPSTASH_REDIS_REST_URL?.replace(/\/$/, ""); -const redisToken = () => process.env.UPSTASH_REDIS_REST_TOKEN; - -function warnOnce(kind: "missing" | "failure", message: string) { - if (kind === "missing") { - if (!warnedMissingRedis) { - warnedMissingRedis = true; - console.warn(message); - } - return; - } - - if (!warnedRedisFailure) { - warnedRedisFailure = true; - console.warn(message); - } + windowSec: number; + keyGenerator: (req: Request) => string; } -function encodeIdentity(identity: string) { - return Buffer.from(identity).toString("base64url"); -} - -function currentWindow(windowSeconds: number) { - const now = Date.now(); - const windowMs = windowSeconds * 1000; - const id = Math.floor(now / windowMs); - const resetAt = (id + 1) * windowMs; - return { - id, - retryAfter: Math.max(1, Math.ceil((resetAt - now) / 1000)), - }; -} - -async function checkRateLimit( - keyPrefix: string, - identity: string, - limit: number, - windowSeconds: number -): Promise { - const url = redisUrl(); - const token = redisToken(); - - if (!url || !token) { - warnOnce( - "missing", - "Rate limiting disabled: UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN is not configured." - ); - return { allowed: true, retryAfter: 0 }; - } - - const window = currentWindow(windowSeconds); - const redisKey = `rate-limit:${keyPrefix}:${encodeIdentity(identity)}:${window.id}`; - - try { - const response = await fetch(`${url}/pipeline`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify([ - ["INCR", redisKey], - ["EXPIRE", redisKey, windowSeconds + 1], - ]), - }); - - if (!response.ok) { - throw new Error(`Redis rate limit request failed with ${response.status}`); - } - - const payload = (await response.json()) as Array<{ result?: unknown }>; - const count = Number(payload[0]?.result); - - if (!Number.isFinite(count)) { - throw new Error("Redis rate limit response did not include a numeric count"); - } - - return { - allowed: count <= limit, - retryAfter: window.retryAfter, - }; - } catch (error) { - warnOnce( - "failure", - `Rate limiting unavailable; allowing request. ${ - error instanceof Error ? error.message : String(error) - }` - ); - return { allowed: true, retryAfter: 0 }; - } -} - -export function createRateLimitMiddleware(options: RateLimitOptions): RequestHandler { +export function createRateLimiter(options: RateLimitOptions) { return async (req: Request, res: Response, next: NextFunction) => { - const identity = options.identity(req); - const result = await checkRateLimit( - options.name, - identity, - options.limit, - options.windowSeconds - ); - - if (!result.allowed) { - res.setHeader("Retry-After", result.retryAfter.toString()); - return res.status(429).json({ - success: false, - error: "Rate limit exceeded", - retryAfter: result.retryAfter, - }); + try { + const identity = options.keyGenerator(req); + const key = `${options.name}:${identity}`; + const result = await checkRateLimit(key, options.limit, options.windowSec); + + if (!result.allowed) { + if (result.retryAfterSec) { + res.setHeader("Retry-After", String(result.retryAfterSec)); + } + return res.status(429).json({ + success: false, + error: "Too many requests. Please try again later.", + retryAfterSec: result.retryAfterSec, + }); + } + + return next(); + } catch (error) { + return next(error); } - - return next(); }; } -function requestIp(req: Request) { - return req.ip || req.socket.remoteAddress || "unknown-ip"; -} - -function headerValue(req: Request, header: string) { - const value = req.headers[header.toLowerCase()]; - if (Array.isArray(value)) { - return value[0] || requestIp(req); +export function clientIpKey(req: Request): string { + const forwarded = req.headers["x-forwarded-for"]; + if (typeof forwarded === "string" && forwarded.length > 0) { + return forwarded.split(",")[0]?.trim() ?? "unknown"; } - return value || requestIp(req); + return req.ip ?? "unknown"; } -function bodyApiKey(req: Request) { - const body = req.body as { apiKey?: unknown } | undefined; - return typeof body?.apiKey === "string" && body.apiKey.trim() - ? body.apiKey - : requestIp(req); +export function bodyApiKey(req: Request): string { + const body = req.body as { apiKey?: string }; + return body.apiKey ?? "missing-api-key"; } -export const authChallengeRateLimit: RequestHandler = createRateLimitMiddleware({ - name: "auth-challenge", - limit: 10, - windowSeconds: 60, - identity: requestIp, -}); - -export const signedAuthRateLimit: RequestHandler = createRateLimitMiddleware({ - name: "signed-auth", - limit: 5, - windowSeconds: 60, - identity: requestIp, -}); - -export const eventReportRateLimit: RequestHandler = createRateLimitMiddleware({ - name: "events-report", - limit: 100, - windowSeconds: 60, - identity: bodyApiKey, -}); - -export const lenderScoreRateLimit: RequestHandler = createRateLimitMiddleware({ - name: "lender-score", - limit: 60, - windowSeconds: 60, - identity: (req) => headerValue(req, "x-api-key"), -}); - -export function resetRateLimitWarningsForTests() { - warnedMissingRedis = false; - warnedRedisFailure = false; +export function headerApiKey(req: Request): string { + const key = req.headers["x-api-key"]; + return typeof key === "string" ? key : "missing-api-key"; } diff --git a/Server/src/routes/auth.routes.ts b/Server/src/routes/auth.routes.ts index 614d1a3..365970e 100644 --- a/Server/src/routes/auth.routes.ts +++ b/Server/src/routes/auth.routes.ts @@ -7,22 +7,36 @@ import { registerWithSignature, } from "../controllers/auth.controller"; import { validate } from "../middleware/validation.middleware"; +import { + createRateLimiter, + clientIpKey, +} from "../middleware/rate-limit.middleware"; import { LoginSchema, RegisterSchema, SignedAuthSchema, ChallengeRequestSchema, } from "../middleware/schemas"; -import { - authChallengeRateLimit, - signedAuthRateLimit, -} from "../middleware/rate-limit.middleware"; const router = Router(); +const challengeRateLimit = createRateLimiter({ + name: "auth_challenge", + limit: 10, + windowSec: 60, + keyGenerator: clientIpKey, +}); + +const signedAuthRateLimit = createRateLimiter({ + name: "auth_signed", + limit: 5, + windowSec: 60, + keyGenerator: clientIpKey, +}); + router.post( "/challenge", - authChallengeRateLimit, + challengeRateLimit, validate(ChallengeRequestSchema), requestChallenge ); diff --git a/Server/src/routes/events.routes.ts b/Server/src/routes/events.routes.ts index ed73f3b..5f46b78 100644 --- a/Server/src/routes/events.routes.ts +++ b/Server/src/routes/events.routes.ts @@ -1,11 +1,21 @@ import { Router } from "express"; import { reportCreditEvent } from "../controllers/events.controller"; import { validate } from "../middleware/validation.middleware"; +import { + createRateLimiter, + bodyApiKey, +} from "../middleware/rate-limit.middleware"; import { CreditEventSchema } from "../middleware/schemas"; -import { eventReportRateLimit } from "../middleware/rate-limit.middleware"; const router = Router(); +const eventReportRateLimit = createRateLimiter({ + name: "events_report", + limit: 100, + windowSec: 60, + keyGenerator: bodyApiKey, +}); + router.post( "/report", eventReportRateLimit, diff --git a/Server/src/routes/user.routes.ts b/Server/src/routes/user.routes.ts index 643fb0a..a5f4231 100644 --- a/Server/src/routes/user.routes.ts +++ b/Server/src/routes/user.routes.ts @@ -1,36 +1,81 @@ import { Router } from "express"; -import { lenderScoreRateLimit } from "../middleware/rate-limit.middleware"; import { getProfile, getScore, getCreditHistory, + getScoreHistory, + getUserBreakdown, requestScoring, } from "../controllers/user.controller"; import { attestScore, getOnChainScore, } from "../controllers/contracts.controller"; -import { validate, validateParams } from "../middleware/validation.middleware"; +import { validate, validateParams, validateQuery } from "../middleware/validation.middleware"; import { validateLenderKey } from "../middleware/lender-auth.middleware"; +import { requireWalletSession } from "../middleware/session.middleware"; +import { + createRateLimiter, + headerApiKey, +} from "../middleware/rate-limit.middleware"; import { ScoringRequestSchema, SignedAuthSchema, WalletParamSchema, + PaginationQuerySchema, + ScoreHistoryQuerySchema, } from "../middleware/schemas"; const router = Router(); -router.use("/:wallet/score", lenderScoreRateLimit); +const lenderScoreRateLimit = createRateLimiter({ + name: "lender_score", + limit: 60, + windowSec: 60, + keyGenerator: headerApiKey, +}); + router.post("/request", validate(ScoringRequestSchema), requestScoring); router.get("/:wallet/on-chain", validateParams(WalletParamSchema), getOnChainScore); router.post( "/:wallet/attest", validateParams(WalletParamSchema), + requireWalletSession, validate(SignedAuthSchema), attestScore ); -router.get("/:wallet/score", validateLenderKey, validateParams(WalletParamSchema), getScore); -router.get("/:wallet/history", validateParams(WalletParamSchema), getCreditHistory); -router.get("/:wallet/profile", validateParams(WalletParamSchema), getProfile); +router.get( + "/:wallet/score", + validateParams(WalletParamSchema), + lenderScoreRateLimit, + validateLenderKey, + getScore +); +router.get( + "/:wallet/breakdown", + validateParams(WalletParamSchema), + requireWalletSession, + getUserBreakdown +); +router.get( + "/:wallet/history", + validateParams(WalletParamSchema), + validateQuery(PaginationQuerySchema), + requireWalletSession, + getCreditHistory +); +router.get( + "/:wallet/score-history", + validateParams(WalletParamSchema), + validateQuery(ScoreHistoryQuerySchema), + requireWalletSession, + getScoreHistory +); +router.get( + "/:wallet/profile", + validateParams(WalletParamSchema), + requireWalletSession, + getProfile +); export default router; diff --git a/Server/src/services/rate-limit.service.ts b/Server/src/services/rate-limit.service.ts new file mode 100644 index 0000000..8f19f70 --- /dev/null +++ b/Server/src/services/rate-limit.service.ts @@ -0,0 +1,121 @@ +import { logStructured } from "../utils/logger.util"; + +export interface RateLimitResult { + allowed: boolean; + retryAfterSec?: number; +} + +interface BucketState { + count: number; + resetAt: number; +} + +const memoryBuckets = new Map(); +let warnedFallback = false; + +type UpstashLimiter = { + limit: (key: string) => Promise<{ + success: boolean; + reset: number; + }>; +}; + +let upstashLimiters: Map | undefined; + +async function getUpstashLimiter( + limit: number, + windowSec: number +): Promise { + const cacheKey = `${limit}:${windowSec}`; + + if (upstashLimiters?.has(cacheKey)) { + return upstashLimiters.get(cacheKey) ?? null; + } + + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + + if (!url || !token) { + return null; + } + + const { Ratelimit } = await import("@upstash/ratelimit"); + const { Redis } = await import("@upstash/redis"); + + const limiter = new Ratelimit({ + redis: new Redis({ url, token }), + limiter: Ratelimit.slidingWindow(limit, `${windowSec} s`), + analytics: false, + prefix: `zcore_rl_${limit}_${windowSec}`, + }); + + if (!upstashLimiters) { + upstashLimiters = new Map(); + } + upstashLimiters.set(cacheKey, limiter); + return limiter; +} + +function checkMemoryRateLimit( + key: string, + limit: number, + windowSec: number +): RateLimitResult { + const now = Date.now(); + const existing = memoryBuckets.get(key); + + if (!existing || existing.resetAt <= now) { + memoryBuckets.set(key, { + count: 1, + resetAt: now + windowSec * 1000, + }); + return { allowed: true }; + } + + if (existing.count >= limit) { + return { + allowed: false, + retryAfterSec: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)), + }; + } + + existing.count += 1; + return { allowed: true }; +} + +export async function checkRateLimit( + key: string, + limit: number, + windowSec: number +): Promise { + const limiter = await getUpstashLimiter(limit, windowSec); + + if (!limiter) { + if (!warnedFallback) { + warnedFallback = true; + logStructured("warn", "rate_limit_fallback", { + message: "UPSTASH env not set; using in-memory rate limiting", + }); + } + return checkMemoryRateLimit(key, limit, windowSec); + } + + const result = await limiter.limit(key); + if (result.success) { + return { allowed: true }; + } + + return { + allowed: false, + retryAfterSec: Math.max( + 1, + Math.ceil((result.reset - Date.now()) / 1000) + ), + }; +} + +export function resetRateLimitStateForTests(): void { + memoryBuckets.clear(); + upstashLimiters = undefined; + warnedFallback = false; +} diff --git a/Server/src/utils/logger.util.ts b/Server/src/utils/logger.util.ts new file mode 100644 index 0000000..6789442 --- /dev/null +++ b/Server/src/utils/logger.util.ts @@ -0,0 +1,28 @@ +type LogLevel = "info" | "warn" | "error"; + +export function maskWallet(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +export function logStructured( + level: LogLevel, + message: string, + fields: Record = {} +): void { + const entry = { + level, + message, + timestamp: new Date().toISOString(), + ...fields, + }; + + const line = JSON.stringify(entry); + if (level === "error") { + console.error(line); + } else if (level === "warn") { + console.warn(line); + } else { + console.log(line); + } +}