Skip to content
Open
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
4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@
"express": "^4.21.2",
"express-rate-limit": "^8.3.1",
"ioredis": "^5.4.1",
"mime-types": "^3.0.2",
"pino": "^9.6.0",
"pino-http": "^11.0.0",
"pino-pretty": "^13.0.0",
"prom-client": "^15.1.3",
"proper-lockfile": "^4.1.2",
"stellar-bounty-board": "file:..",
"swagger-ui-express": "^5.0.1",
"toidentifier": "^1.0.1",
"zod": "^3.23.8"
},
"devDependencies": {
Expand All @@ -53,4 +55,4 @@
"typescript": "^5.7.2",
"vitest": "^3.0.5"
}
}
}
18 changes: 14 additions & 4 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,16 @@ app.get('/worker/health', (_req: Request, res: Response) => {
});
});

app.get('/api/metrics', async (_req: Request, res: Response) => {
try {
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
const metrics = await getMetrics();
res.send(metrics);
} catch {
res.status(500).send('Error generating metrics');
}
});

app.use(readLimiter);

const swaggerDoc = generateOpenApiDocument();
Expand Down Expand Up @@ -805,11 +815,11 @@ app.post(

app.get('/api/open-issues', async (req: Request, res: Response) => {
try {
const data = await listOpenIssues();
const issues = await listOpenIssues();
res.set('Cache-Control', 'max-age=600');
res.json({ data });
res.json({ data: issues });
} catch (error) {
sendError(res, req, error, 502);
sendError(res, _req, error);
}
});

Expand Down Expand Up @@ -966,4 +976,4 @@ app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
return;
}
next(err);
});
});
9 changes: 5 additions & 4 deletions backend/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { StrKey } from "@stellar/stellar-sdk";
*
* Two tiers, both configurable via env:
* - `readLimiter` — global, GET-only, generous (default 120 req/min/IP).
* Health and metrics endpoints are exempted via EXEMPT_PATHS.
* - `mutationLimiter` — strict, applied to state-changing routes
* (create / reserve / submit / release / refund) so a single client cannot
* hammer them (default 10 req/min/IP), independent of the read limit.
Expand All @@ -20,10 +21,10 @@ const MUTATION_MAX = Number(process.env.RATE_LIMIT_MUTATION_MAX ?? 10);

const isTest = process.env.NODE_ENV === "test";

const HEALTH_PATHS = new Set(["/api/health", "/api/health/deep", "/worker/health"]);
const EXEMPT_PATHS = new Set(["/api/health", "/api/health/deep", "/worker/health", "/api/metrics"]);

function isHealthPath(req: Request): boolean {
return HEALTH_PATHS.has(req.path);
function isExemptPath(req: Request): boolean {
return EXEMPT_PATHS.has(req.path);
}

/** No-op middleware so test suites can hit routes freely. */
Expand All @@ -40,7 +41,7 @@ function makeLimiter(limit: number, options: { getOnly?: boolean } = {}): Reques
legacyHeaders: false,
ipv6Subnet: 56,
...(options.getOnly
? { skip: (req: Request) => req.method !== "GET" || isHealthPath(req) }
? { skip: (req: Request) => req.method !== "GET" || isExemptPath(req) }
: {}),
handler: (_req: Request, res: Response) => {
res.setHeader("Retry-After", String(Math.ceil(WINDOW_MS / 1000)));
Expand Down
139 changes: 128 additions & 11 deletions backend/test/rateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,147 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import request from "supertest";
import express from "express";
import { readLimiter, mutationLimiter } from "../src/utils";
import { rateLimit } from "express-rate-limit";

describe("Rate Limiting", () => {
const WINDOW_MS = 60_000;
const READ_MAX = 120;
const OVER_LIMIT = READ_MAX + 10;

const EXEMPT_PATHS = new Set(["/api/health", "/api/health/deep", "/worker/health", "/api/metrics"]);

function isExemptPath(req: express.Request): boolean {
return EXEMPT_PATHS.has(req.path);
}

function buildReadLimiter() {
return rateLimit({
windowMs: WINDOW_MS,
limit: READ_MAX,
standardHeaders: "draft-8",
legacyHeaders: false,
ipv6Subnet: 56,
skip: (req: express.Request) => req.method !== "GET" || isExemptPath(req),
handler: (_req: express.Request, res: express.Response) => {
res.setHeader("Retry-After", String(Math.ceil(WINDOW_MS / 1000)));
res.status(429).json({ error: "Too many requests. Please retry later." });
},
});
}

describe("Rate-limit bypass for exempt endpoints", () => {
let app: express.Express;

beforeEach(() => {
app = express();
// Register exempt routes BEFORE the rate limiter (matching production ordering)
app.get("/api/health", (_req, res) => {
res.status(200).json({ status: "ok" });
});
app.get("/api/health/deep", (_req, res) => {
res.status(200).json({ status: "ok" });
});
app.get("/worker/health", (_req, res) => {
res.status(200).json({ status: "ok" });
});
app.get("/api/metrics", (_req, res) => {
res.status(200).send("metrics data");
});

// Apply a real rate limiter AFTER exempt routes
app.use(buildReadLimiter());
});

it("GET /api/health bypasses rate limiting under high load", async () => {
const promises = Array.from({ length: OVER_LIMIT }, () =>
request(app).get("/api/health"),
);
const responses = await Promise.all(promises);

for (const res of responses) {
expect(res.status).toBe(200);
}
});

it("GET /api/health/deep bypasses rate limiting under high load", async () => {
const promises = Array.from({ length: OVER_LIMIT }, () =>
request(app).get("/api/health/deep"),
);
const responses = await Promise.all(promises);

for (const res of responses) {
expect(res.status).toBe(200);
}
});

it("GET /worker/health bypasses rate limiting under high load", async () => {
const promises = Array.from({ length: OVER_LIMIT }, () =>
request(app).get("/worker/health"),
);
const responses = await Promise.all(promises);

for (const res of responses) {
expect(res.status).toBe(200);
}
});

it("GET /api/metrics bypasses rate limiting under high load", async () => {
const promises = Array.from({ length: OVER_LIMIT }, () =>
request(app).get("/api/metrics"),
);
const responses = await Promise.all(promises);

for (const res of responses) {
expect(res.status).toBe(200);
}
});
});

describe("Rate limiting is active for regular endpoints", () => {
let app: express.Express;

beforeEach(() => {
app = express();
app.use(readLimiter);
app.get("/test-read", (req, res) => {
// Register an exempt route (to match production setup)
app.get("/api/health", (_req, res) => {
res.status(200).json({ status: "ok" });
});

// Apply a real rate limiter
app.use(buildReadLimiter());

// Register a regular (non-exempt) endpoint AFTER the rate limiter
app.get("/api/regular-endpoint", (_req, res) => {
res.status(200).json({ ok: true });
});
});

afterEach(() => {
vi.unstubAllEnvs();
it("a regular GET endpoint is rate-limited after exceeding the limit", async () => {
let got429 = false;

const promises = Array.from({ length: OVER_LIMIT }, () =>
request(app).get("/api/regular-endpoint"),
);
const responses = await Promise.all(promises);

for (const res of responses) {
if (res.status === 429) {
got429 = true;
expect(res.body).toHaveProperty("error");
expect(res.body.error).toContain("Too many requests");
expect(res.headers).toHaveProperty("retry-after");
break;
}
}

expect(got429).toBe(true);
});

it("does not rate limit in test environment (NODE_ENV=test)", async () => {
// NODE_ENV is set to "test" by default in vitest
// Send 200 requests, they should all pass with 200 OK
const promises = Array.from({ length: 200 }, () =>
request(app).get("/test-read")
it("passes requests below the rate limit successfully", async () => {
const promises = Array.from({ length: READ_MAX - 10 }, () =>
request(app).get("/api/regular-endpoint"),
);
const responses = await Promise.all(promises);

for (const res of responses) {
expect(res.status).toBe(200);
}
Expand Down
Loading