Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/rate-limiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 8 additions & 1 deletion src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ export function createPerUserRateLimiter(options: Partial<Options> = {}): 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)}`;
},
});
}
18 changes: 18 additions & 0 deletions src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string, unknown> : 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),
});
Expand Down
26 changes: 26 additions & 0 deletions tests/authChallenge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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);
});
Loading