From 338c8145ee20e1c7c39b55a298887d93436e92f7 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum Date: Wed, 22 Jul 2026 11:43:19 -0700 Subject: [PATCH 1/3] raise /api/lumens rate limit to 1000 per 15 min --- backend/routes.ts | 28 ++++++++++++++++++++++---- test/tests/integration/backend.ts | 33 +++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/backend/routes.ts b/backend/routes.ts index 5a033551..3442b3a6 100644 --- a/backend/routes.ts +++ b/backend/routes.ts @@ -28,6 +28,10 @@ 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. +const LUMENS_V1_PATH = "/api/lumens"; + // Global rate limiting for all requests (including static files) const globalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes @@ -39,14 +43,20 @@ const globalLimiter = rateLimit({ }, standardHeaders: true, legacyHeaders: false, - skip: () => process.env.DEV === "true", + skip: (req) => + process.env.DEV === "true" || req.baseUrl + req.path === LUMENS_V1_PATH, }); // 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, + skipPath?: string, +) => { return rateLimit({ windowMs, max, @@ -58,7 +68,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" || + (skipPath !== undefined && req.baseUrl + req.path === skipPath), }); }; @@ -67,6 +79,14 @@ const generalApiLimiter = createRateLimit( 15 * 60 * 1000, // 15 minutes 100, "Too many API requests from this IP, please try again later.", + LUMENS_V1_PATH, // /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 @@ -200,7 +220,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 */ diff --git a/test/tests/integration/backend.ts b/test/tests/integration/backend.ts index 36f85341..17b59162 100644 --- a/test/tests/integration/backend.ts +++ b/test/tests/integration/backend.ts @@ -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 () { @@ -194,6 +194,35 @@ 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"); + }); + + 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("/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"); From 28b7ee915e987848789ca79639516a39bcb97897 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum Date: Wed, 22 Jul 2026 12:02:44 -0700 Subject: [PATCH 2/3] scope lumens rate-limit exemption to GET route --- backend/routes.ts | 23 +++++++++++++++++------ test/tests/integration/backend.ts | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/backend/routes.ts b/backend/routes.ts index 3442b3a6..a8b784e4 100644 --- a/backend/routes.ts +++ b/backend/routes.ts @@ -29,8 +29,20 @@ 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. +// 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({ @@ -43,8 +55,7 @@ const globalLimiter = rateLimit({ }, standardHeaders: true, legacyHeaders: false, - skip: (req) => - process.env.DEV === "true" || req.baseUrl + req.path === LUMENS_V1_PATH, + skip: (req) => process.env.DEV === "true" || isLumensV1Request(req), }); // Apply global rate limiting to all requests @@ -55,7 +66,7 @@ const createRateLimit = ( windowMs: number, max: number, message: string, - skipPath?: string, + skipRequest?: (req: express.Request) => boolean, ) => { return rateLimit({ windowMs, @@ -70,7 +81,7 @@ const createRateLimit = ( // Skip rate limiting in development skip: (req) => process.env.DEV === "true" || - (skipPath !== undefined && req.baseUrl + req.path === skipPath), + (skipRequest !== undefined && skipRequest(req)), }); }; @@ -79,7 +90,7 @@ const generalApiLimiter = createRateLimit( 15 * 60 * 1000, // 15 minutes 100, "Too many API requests from this IP, please try again later.", - LUMENS_V1_PATH, // /api/lumens has its own, higher limit + 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. diff --git a/test/tests/integration/backend.ts b/test/tests/integration/backend.ts index 17b59162..f6aad4a0 100644 --- a/test/tests/integration/backend.ts +++ b/test/tests/integration/backend.ts @@ -221,6 +221,20 @@ describe("integration", function () { 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"); + }); }); it("/api/ledgers/public should return successfully with data", async function () { From 6105e3ca46065f176c3648a79343d741c619b6a3 Mon Sep 17 00:00:00 2001 From: Marcelo Salloum Date: Wed, 22 Jul 2026 12:03:18 -0700 Subject: [PATCH 3/3] test rate-limit budget isolation for /api/lumens --- test/tests/integration/backend.ts | 49 +++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/test/tests/integration/backend.ts b/test/tests/integration/backend.ts index f6aad4a0..8e0b978f 100644 --- a/test/tests/integration/backend.ts +++ b/test/tests/integration/backend.ts @@ -223,9 +223,7 @@ describe("integration", function () { }); it("path variants of GET /api/lumens should keep the higher limit", async function () { - const { headers } = await request(app) - .get("/api/lumens/") - .expect(200); + const { headers } = await request(app).get("/api/lumens/").expect(200); chai.expect(headers["ratelimit-limit"]).to.equal("1000"); }); @@ -235,6 +233,51 @@ describe("integration", function () { 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 () {