diff --git a/.env.example b/.env.example index 1d65d2c..ec094fd 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,12 @@ MINIO_USE_SSL=false MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET=pastes + +# Set true when running behind a reverse proxy (Nginx, Cloudflare, etc.) +TRUST_PROXY=false + +# API rate limiting +RATE_LIMIT_READ_WINDOW_MS=60000 +RATE_LIMIT_READ_MAX=240 +RATE_LIMIT_CREATE_WINDOW_MS=600000 +RATE_LIMIT_CREATE_MAX=40 diff --git a/package-lock.json b/package-lock.json index 6b152c8..e0e26aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { - "name": "minipaste", + "name": "binit.app", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "minipaste", + "name": "binit.app", "version": "1.0.0", "license": "ISC", "dependencies": { "dotenv": "^16.0.0", "express": "^4.21.0", + "express-rate-limit": "^7.5.0", + "helmet": "^8.0.0", "minio": "^8.0.0" } }, @@ -295,6 +297,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -336,6 +339,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-xml-builder": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.0.tgz", @@ -498,6 +516,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/package.json b/package.json index 11accdc..31006ca 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "license": "ISC", "dependencies": { "dotenv": "^16.0.0", + "express-rate-limit": "^7.5.0", "express": "^4.21.0", + "helmet": "^8.0.0", "minio": "^8.0.0" } } diff --git a/src/index.js b/src/index.js index 1d8de3f..a3b3065 100644 --- a/src/index.js +++ b/src/index.js @@ -1,15 +1,68 @@ require('dotenv').config(); const express = require('express'); const path = require('path'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); const { ensureBucket } = require('./lib/minio'); const pastesRouter = require('./routes/pastes'); const app = express(); const PORT = process.env.PORT || 3000; +function envInt(name, fallback) { + const raw = process.env[name]; + if (!raw) { + return fallback; + } + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +const READ_LIMIT_WINDOW_MS = envInt('RATE_LIMIT_READ_WINDOW_MS', 60 * 1000); +const READ_LIMIT_MAX = envInt('RATE_LIMIT_READ_MAX', 240); +const CREATE_LIMIT_WINDOW_MS = envInt('RATE_LIMIT_CREATE_WINDOW_MS', 10 * 60 * 1000); +const CREATE_LIMIT_MAX = envInt('RATE_LIMIT_CREATE_MAX', 40); + +app.disable('x-powered-by'); + +// If deployed behind a reverse proxy/load balancer, set TRUST_PROXY=true. +if (process.env.TRUST_PROXY === 'true') { + app.set('trust proxy', 1); +} + +app.use(helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, +})); + +const createPasteLimiter = rateLimit({ + windowMs: CREATE_LIMIT_WINDOW_MS, + max: CREATE_LIMIT_MAX, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many paste creations. Try again later.' }, +}); + +const readPasteLimiter = rateLimit({ + windowMs: READ_LIMIT_WINDOW_MS, + max: READ_LIMIT_MAX, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests. Slow down.' }, +}); + app.use(express.json({ limit: '110kb' })); +app.use(express.urlencoded({ extended: false, limit: '110kb' })); app.use(express.static(path.join(__dirname, 'public'))); +app.use('/api/pastes', readPasteLimiter); +app.use('/api/pastes', (req, res, next) => { + if (req.method === 'POST') { + return createPasteLimiter(req, res, next); + } + next(); +}); + app.use('/api/pastes', pastesRouter); // Serve the view page for any paste URL