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.
This commit is contained in:
Stefan 2026-05-21 23:15:42 +03:00
parent 66bb1f497b
commit 304708fb6c
3 changed files with 48 additions and 4 deletions

36
package-lock.json generated
View file

@ -1,18 +1,19 @@
{ {
"name": "send_to_kindle", "name": "send-to-ereader",
"version": "0.0.2", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "send_to_kindle", "name": "send-to-ereader",
"version": "0.0.2", "version": "0.1.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@lesjoursfr/html-to-epub": "^6.1.0", "@lesjoursfr/html-to-epub": "^6.1.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"nodemailer": "^8.0.7", "nodemailer": "^8.0.7",
"sanitize-filename": "^1.6.4", "sanitize-filename": "^1.6.4",
@ -1774,6 +1775,24 @@
"url": "https://opencollective.com/express" "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": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@ -2287,6 +2306,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View file

@ -19,6 +19,7 @@
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"nodemailer": "^8.0.7", "nodemailer": "^8.0.7",
"sanitize-filename": "^1.6.4", "sanitize-filename": "^1.6.4",

View file

@ -6,6 +6,7 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import sanitizeFilename from "sanitize-filename"; import sanitizeFilename from "sanitize-filename";
import { EpubOptions, EPub } from "@lesjoursfr/html-to-epub"; import { EpubOptions, EPub } from "@lesjoursfr/html-to-epub";
import { rateLimit } from "express-rate-limit";
import dotenv from "dotenv"; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
@ -25,9 +26,22 @@ if (!SMTP_USER || !SMTP_PASSWORD || !MAIL_FROM) {
} }
const app = express(); 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.json());
app.use(express.static("public")); 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({ const transporter = nodemailer.createTransport({
host: SMTP_HOST, host: SMTP_HOST,
port: SMTP_PORT, port: SMTP_PORT,
@ -164,6 +178,7 @@ const emailShape = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
app.post( app.post(
"/send-article", "/send-article",
sendArticleLimiter,
async (req: Request, res: Response, next: NextFunction): Promise<void> => { async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const { url, readerEmail } = req.body; const { url, readerEmail } = req.body;