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
39 changes: 35 additions & 4 deletions backend/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ app.set("trust proxy", proxyAddr.compile(trustProxyCidrs));

app.use(logger("combined"));

// /api/lumens serves straight from the Redis cache, so it gets its own
// higher limit and is excluded from the global and general limiters. The
// exemption only covers requests the GET route actually serves (Express
// routing is case-insensitive, tolerates trailing slashes, and answers HEAD
// through GET handlers) — anything else stays fully rate limited.
const LUMENS_V1_PATH = "/api/lumens";
const isLumensV1Request = (req: express.Request): boolean => {
const normalizedPath = (req.baseUrl + req.path)
.toLowerCase()
.replace(/\/+$/, "");
return (
(req.method === "GET" || req.method === "HEAD") &&
normalizedPath === LUMENS_V1_PATH
);
};

// Global rate limiting for all requests (including static files)
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
Expand All @@ -39,14 +55,19 @@ const globalLimiter = rateLimit({
},
standardHeaders: true,
legacyHeaders: false,
skip: () => process.env.DEV === "true",
skip: (req) => process.env.DEV === "true" || isLumensV1Request(req),
});

// Apply global rate limiting to all requests
app.use(globalLimiter);

// Rate limiting configuration
const createRateLimit = (windowMs: number, max: number, message: string) => {
const createRateLimit = (
windowMs: number,
max: number,
message: string,
skipRequest?: (req: express.Request) => boolean,
Comment thread
jeesunikim marked this conversation as resolved.
) => {
return rateLimit({
windowMs,
max,
Expand All @@ -58,7 +79,9 @@ const createRateLimit = (windowMs: number, max: number, message: string) => {
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
// Skip rate limiting in development
skip: () => process.env.DEV === "true",
skip: (req) =>
process.env.DEV === "true" ||
(skipRequest !== undefined && skipRequest(req)),
});
};

Expand All @@ -67,6 +90,14 @@ const generalApiLimiter = createRateLimit(
15 * 60 * 1000, // 15 minutes
100,
"Too many API requests from this IP, please try again later.",
isLumensV1Request, // /api/lumens has its own, higher limit
);
// /api/lumens is served from the Redis cache, so it can take a much higher
// rate than the general API limit.
const lumensLimiter = createRateLimit(
15 * 60 * 1000, // 15 minutes
1000,
"Too many API requests from this IP, please try again later.",
);

// Strict rate limit for resource-intensive endpoints: 20 requests per 5 minutes
Expand Down Expand Up @@ -200,7 +231,7 @@ if (process.env.DEV === "true") {

// API Routes with appropriate rate limiting
app.get("/api/ledgers/public", strictApiLimiter, ledgers.handler);
app.get("/api/lumens", lumens.v1Handler);
app.get("/api/lumens", lumensLimiter, lumens.v1Handler);

app.get("/api/v2/lumens", lumensV2V3.v2Handler);
/* For CoinMarketCap - heavily rate limited */
Expand Down
90 changes: 88 additions & 2 deletions test/tests/integration/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ describe("integration", function () {
chai
.expect(body.programs.productAndInnovation)
.to.match(DECIMAL_NUMBER_REGEX);
chai.expect(body.programs.growth).to.match(DECIMAL_NUMBER_REGEX);
chai
.expect(body.programs.growth)
.expect(body.programs.assetsAndLiquidity)
.to.match(DECIMAL_NUMBER_REGEX);
chai.expect(body.programs.assetsAndLiquidity).to.match(DECIMAL_NUMBER_REGEX);
});

it("/api/v2/lumens should return successfuly with data", async function () {
Expand Down Expand Up @@ -194,6 +194,92 @@ describe("integration", function () {
chai.expect(body).to.match(DECIMAL_NUMBER_REGEX);
});

describe("rate limiting", function () {
// Rate limiters are skipped when DEV=true (how tests run), so toggle
// it off to exercise them.
let originalDev: string | undefined;

before(function () {
originalDev = process.env.DEV;
process.env.DEV = "false";
});

after(function () {
process.env.DEV = originalDev;
});

it("/api/lumens should have its own higher rate limit", async function () {
const { headers } = await request(app).get("/api/lumens").expect(200);

chai.expect(headers["ratelimit-limit"]).to.equal("1000");
Comment thread
marcelosalloum marked this conversation as resolved.
});

it("other api endpoints should keep the general rate limit", async function () {
const { headers } = await request(app)
.get("/api/v2/lumens")
.expect(200);

chai.expect(headers["ratelimit-limit"]).to.equal("100");
});

it("path variants of GET /api/lumens should keep the higher limit", async function () {
const { headers } = await request(app).get("/api/lumens/").expect(200);

chai.expect(headers["ratelimit-limit"]).to.equal("1000");
});

it("non-GET requests to /api/lumens should stay rate limited", async function () {
const { headers } = await request(app).post("/api/lumens").expect(404);

chai.expect(headers["ratelimit-limit"]).to.equal("100");
});

// The limiters key on client IP, so spoofing X-Forwarded-For (trusted
// from loopback) gives each test a fresh, isolated budget.
it("general api traffic should not consume the /api/lumens budget", async function () {
const ip = "203.0.113.10";

// Exhaust the general API budget for this IP...
for (let i = 0; i < 100; i++) {
await request(app)
.get("/api/v2/lumens")
.set("X-Forwarded-For", ip)
.expect(200);
}
await request(app)
.get("/api/v2/lumens")
.set("X-Forwarded-For", ip)
.expect(429);

// ...and /api/lumens is still available, having only counted its own
// requests.
const { headers } = await request(app)
.get("/api/lumens")
.set("X-Forwarded-For", ip)
.expect(200);

chai.expect(headers["ratelimit-remaining"]).to.equal("999");
});

it("/api/lumens traffic should not consume the general api budget", async function () {
const ip = "203.0.113.20";

for (let i = 0; i < 5; i++) {
await request(app)
.get("/api/lumens")
.set("X-Forwarded-For", ip)
.expect(200);
}

const { headers } = await request(app)
.get("/api/v2/lumens")
.set("X-Forwarded-For", ip)
.expect(200);

chai.expect(headers["ratelimit-remaining"]).to.equal("99");
});
});

it("/api/ledgers/public should return successfully with data", async function () {
// The ledgers endpoint might not have data initially, so we handle both cases
const response = await request(app).get("/api/ledgers/public");
Expand Down
Loading