From 304708fb6cefbc08a31299953caeaa6790fb8048 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 21 May 2026 23:15:42 +0300 Subject: [PATCH] Add rate limiting on /send-article Prevents abuse before exposing the app publicly. Limits each client IP to 20 article sends per hour. trust proxy is set so the real client IP is used (not Nginx's loopback) once the app is behind a reverse proxy. --- package-lock.json | 36 ++++++++++++++++++++++++++++++++---- package.json | 1 + src/app.ts | 15 +++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 77b3c88..3465250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { - "name": "send_to_kindle", - "version": "0.0.2", + "name": "send-to-ereader", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "send_to_kindle", - "version": "0.0.2", + "name": "send-to-ereader", + "version": "0.1.0", "license": "GPL-3.0-only", "dependencies": { "@lesjoursfr/html-to-epub": "^6.1.0", "@mozilla/readability": "^0.6.0", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "jsdom": "^29.1.1", "nodemailer": "^8.0.7", "sanitize-filename": "^1.6.4", @@ -1774,6 +1775,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -2287,6 +2306,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index 6557861..6712f89 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@mozilla/readability": "^0.6.0", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "jsdom": "^29.1.1", "nodemailer": "^8.0.7", "sanitize-filename": "^1.6.4", diff --git a/src/app.ts b/src/app.ts index f64de68..a3fba6a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,7 @@ import path from "path"; import fs from "fs"; import sanitizeFilename from "sanitize-filename"; import { EpubOptions, EPub } from "@lesjoursfr/html-to-epub"; +import { rateLimit } from "express-rate-limit"; import dotenv from "dotenv"; dotenv.config(); @@ -25,9 +26,22 @@ if (!SMTP_USER || !SMTP_PASSWORD || !MAIL_FROM) { } const app = express(); + +// Trust the first hop in front of us (Nginx) so req.ip reflects the real +// client IP for rate limiting, not the proxy's loopback address. +app.set("trust proxy", 1); + app.use(express.json()); app.use(express.static("public")); +const sendArticleLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + limit: 20, + standardHeaders: "draft-7", + legacyHeaders: false, + message: { error: "Too many send requests. Try again in an hour." }, +}); + const transporter = nodemailer.createTransport({ host: SMTP_HOST, port: SMTP_PORT, @@ -164,6 +178,7 @@ const emailShape = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; app.post( "/send-article", + sendArticleLimiter, async (req: Request, res: Response, next: NextFunction): Promise => { const { url, readerEmail } = req.body;