From a4ef21d099b0ebe33b18dbe6e9fe85627562b73b Mon Sep 17 00:00:00 2001 From: adamp Date: Mon, 9 Feb 2026 22:06:55 -0600 Subject: [PATCH] Add security headers via helmet and improve rate limiting Add helmet middleware for security headers (CSP, X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy) and disable X-Powered-By. Add a global API rate limiter (100 req/min/IP) using express-rate-limit. Replace the hand-rolled in-memory login rate limiter (~25 lines) with a dedicated express-rate-limit instance (5 attempts/min/IP) on the login route. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 40 +++++++++++++++++++++++++++++++++++++++- package.json | 4 +++- server/index.js | 12 ++++++++++++ server/routes/auth.js | 41 +++++++++-------------------------------- 4 files changed, 63 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0f63cfc..6adbe99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.21.1" + "express": "^4.21.1", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0" }, "devDependencies": { "concurrently": "^9.1.0" @@ -594,6 +596,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -750,6 +770,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", @@ -814,6 +843,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "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 d588770..0fe9f24 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.21.1" + "express": "^4.21.1", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0" }, "devDependencies": { "concurrently": "^9.1.0" diff --git a/server/index.js b/server/index.js index 7811086..376989a 100644 --- a/server/index.js +++ b/server/index.js @@ -2,6 +2,8 @@ require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') } const express = require('express'); const cookieParser = require('cookie-parser'); const path = require('path'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); const authRouter = require('./routes/auth'); const { authMiddleware } = require('./middleware/auth'); @@ -16,6 +18,8 @@ const PORT = process.env.PORT || 3002; app.set('trust proxy', 1); +app.use(helmet()); + if (process.env.NODE_ENV !== 'production') { const cors = require('cors'); app.use(cors({ origin: 'http://localhost:5173', credentials: true })); @@ -24,6 +28,14 @@ if (process.env.NODE_ENV !== 'production') { app.use(express.json()); app.use(cookieParser()); +const apiLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, +}); +app.use('/api', apiLimiter); + app.use('/api/auth', authRouter); app.use('/api', authMiddleware); app.use('/api/products', productsRouter); diff --git a/server/routes/auth.js b/server/routes/auth.js index 7945a59..60e4d62 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,5 +1,6 @@ const express = require('express'); const crypto = require('crypto'); +const rateLimit = require('express-rate-limit'); const router = express.Router(); const { COOKIE_NAME, @@ -8,43 +9,20 @@ const { clearSessionCookie, } = require('../middleware/auth'); -// In-memory rate limiter for login attempts -const loginAttempts = new Map(); -const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute -const RATE_LIMIT_MAX = 5; +const loginLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many login attempts. Try again later.' }, +}); -function isRateLimited(ip) { - const now = Date.now(); - const attempts = loginAttempts.get(ip); - if (!attempts) return false; - // Remove expired entries - const recent = attempts.filter(t => now - t < RATE_LIMIT_WINDOW_MS); - if (recent.length === 0) { - loginAttempts.delete(ip); - return false; - } - loginAttempts.set(ip, recent); - return recent.length >= RATE_LIMIT_MAX; -} - -function recordAttempt(ip) { - const now = Date.now(); - const attempts = loginAttempts.get(ip) || []; - attempts.push(now); - loginAttempts.set(ip, attempts); -} - -router.post('/login', (req, res) => { +router.post('/login', loginLimiter, (req, res) => { const password = process.env.APP_PASSWORD; if (!password) { return res.status(200).json({ ok: true }); } - const ip = req.ip; - if (isRateLimited(ip)) { - return res.status(429).json({ error: 'Too many login attempts. Try again later.' }); - } - const submitted = req.body?.password || ''; const submittedBuf = Buffer.from(String(submitted)); const passwordBuf = Buffer.from(password); @@ -55,7 +33,6 @@ router.post('/login', (req, res) => { } if (!match) { - recordAttempt(ip); return res.status(401).json({ error: 'Invalid password' }); }