diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md index 8300355..6ab1498 100644 --- a/docs/rate-limiting.md +++ b/docs/rate-limiting.md @@ -4,6 +4,11 @@ Public read endpoints (`GET /api/markets`, `GET /api/leaderboard`) are throttled per client IP using a sliding-window counter. Authenticated requests that include a `Bearer` token bypass the limiter. +Authentication endpoints under `/api/auth` are also rate-limited with a per-identity +window (default 5 requests per minute) to reduce abuse from repeated challenge or +verification attempts. The limiter uses the submitted Stellar address when present +and falls back to the client IP otherwise. + ## Configuration | Variable | Default | Description | diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index a2723a5..d28728b 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -153,6 +153,13 @@ export function createPerUserRateLimiter(options: Partial = {}): RateLi windowMs: 60 * 1000, limit: 60, ...options, - keyGenerator: (req: Request) => getAuthenticatedUserKey(req) ?? `ip:${getClientIp(req)}`, + keyGenerator: (req: Request) => { + const overrideKey = options.keyGenerator?.(req); + if (typeof overrideKey === "string" && overrideKey.trim().length > 0) { + return overrideKey; + } + + return getAuthenticatedUserKey(req) ?? `ip:${getClientIp(req)}`; + }, }); } diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 6305698..bf37e34 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { z } from "zod"; import { StrKey } from "@stellar/stellar-sdk"; +import { createPerUserRateLimiter } from "../middleware/rateLimit"; import { rotateRefreshToken, revokeFamily, @@ -11,6 +12,23 @@ import { RouteErrorFactory } from "../errors"; export const authRouter = Router(); +function getAuthRateLimitKey(req: { body?: unknown; socket?: { remoteAddress?: string | null } }): string { + const body = typeof req.body === "object" && req.body !== null ? req.body as Record : undefined; + const stellarAddress = typeof body?.stellarAddress === "string" ? body.stellarAddress.trim() : ""; + + if (stellarAddress.length > 0) { + return `auth:${stellarAddress}`; + } + + return `ip:${req.socket?.remoteAddress ?? "unknown"}`; +} + +authRouter.use(createPerUserRateLimiter({ + windowMs: 60 * 1000, + limit: 5, + keyGenerator: (req) => getAuthRateLimitKey(req), +})); + const refreshTokenBodySchema = z.object({ refreshToken: z.string().min(1), }); diff --git a/tests/authChallenge.test.ts b/tests/authChallenge.test.ts index bb30140..0cdc87c 100644 --- a/tests/authChallenge.test.ts +++ b/tests/authChallenge.test.ts @@ -6,6 +6,10 @@ process.env.PREDICTIFY_CONTRACT_ID = "CABC..."; import request from "supertest"; +jest.mock("../src/services/auditService", () => ({ + createAuditLog: jest.fn().mockResolvedValue(undefined), +})); + jest.mock("../src/services/authChallengeService", () => ({ generateNonce: jest.fn(() => "aaaa"), computeExpiresAt: jest.fn(() => new Date()), @@ -70,4 +74,26 @@ describe("POST /api/auth/challenge", () => { expect(res.status).toBe(400); expect(res.body.error.type).toBe("BadRequest"); }, 10000); + + it("rate limits repeated challenge attempts for the same authenticated identity", async () => { + const address = "GABSCDZCXMOO6CYNTHBGHAOE3RX72FRMNWK6O4FOXW6OBQATNWKBUUW6"; + + for (let index = 0; index < 5; index += 1) { + const res = await request(app) + .post("/api/auth/challenge") + .send({ stellarAddress: address }); + + if (index < 4) { + expect(res.status).toBe(201); + } + } + + const res = await request(app) + .post("/api/auth/challenge") + .send({ stellarAddress: address }); + + expect(res.status).toBe(429); + expect(res.body.error.code).toBe("rate_limit_exceeded"); + expect(res.body.error.retryAfter).toEqual(expect.any(Number)); + }, 10000); });