Skip to content

Commit 40c593a

Browse files
authored
security: add rate limiting to /api/user/:username endpoint (#222) (#230)
Install express-rate-limit v8.5.2 and apply a rate limiter (30 req/min per IP, configurable via API_RATE_LIMIT env var) to the API endpoint. Also sets trust proxy for correct client IP detection behind Render.com's reverse proxy. Returns Retry-After header and JSON error on 429.
1 parent cfd1000 commit 40c593a

3 files changed

Lines changed: 49 additions & 0 deletions

File tree

package-lock.json

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"axios": "^1.10.0",
2424
"cors": "^2.8.5",
2525
"express": "^5.1.0",
26+
"express-rate-limit": "^8.5.2",
2627
"helmet": "^8.2.0"
2728
},
2829
"devDependencies": {

server.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ const path = require("path");
55
const fs = require("fs");
66
const crypto = require("crypto");
77
const fetchUserInfo = require("./scripts/fetch-user-info");
8+
const { rateLimit } = require("express-rate-limit");
89

910
const app = express();
1011
const PORT = process.env.PORT || 3000;
1112

1213
app.use(cors());
1314

15+
// Trust Render.com proxy so req.ip returns real client IP
16+
app.set("trust proxy", 1);
17+
1418
// 1. Per-request nonce generator (used by CSP and HTML nonce injection)
1519
app.use((req, res, next) => {
1620
res.locals.nonce = crypto.randomBytes(16).toString("base64url");
@@ -124,6 +128,22 @@ app.get("/user/:username", (req, res) => {
124128
serveHtml(res, path.join(__dirname, "frontend", "user.html"));
125129
});
126130

131+
// ---- Rate limiter for API endpoint ----
132+
const apiLimiter = rateLimit({
133+
windowMs: 60 * 1000, // 1-minute window
134+
limit: parseInt(process.env.API_RATE_LIMIT, 10) || 30,
135+
standardHeaders: "draft-8",
136+
legacyHeaders: false,
137+
message: { error: "Rate limit exceeded", retryAfter: 60 },
138+
handler: (req, res, next, options) => {
139+
res.status(options.statusCode);
140+
res.set("Retry-After", Math.ceil(options.windowMs / 1000));
141+
res.json(options.message);
142+
},
143+
});
144+
145+
app.use("/api/user/:username", apiLimiter);
146+
127147
app.get("/api/user/:username", async (req, res) => {
128148
const username = req.params.username;
129149

0 commit comments

Comments
 (0)