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 <noreply@anthropic.com>
This commit is contained in:
adamp 2026-02-09 22:06:55 -06:00
parent cc2c651dfe
commit a4ef21d099
4 changed files with 63 additions and 34 deletions

40
package-lock.json generated
View File

@ -10,7 +10,9 @@
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1" "express": "^4.21.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.0" "concurrently": "^9.1.0"
@ -594,6 +596,24 @@
"url": "https://opencollective.com/express" "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": { "node_modules/file-uri-to-path": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "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": ">= 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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -814,6 +843,15 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC" "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": { "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

@ -13,7 +13,9 @@
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1" "express": "^4.21.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.0" "concurrently": "^9.1.0"

View File

@ -2,6 +2,8 @@ require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }
const express = require('express'); const express = require('express');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const path = require('path'); const path = require('path');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const authRouter = require('./routes/auth'); const authRouter = require('./routes/auth');
const { authMiddleware } = require('./middleware/auth'); const { authMiddleware } = require('./middleware/auth');
@ -16,6 +18,8 @@ const PORT = process.env.PORT || 3002;
app.set('trust proxy', 1); app.set('trust proxy', 1);
app.use(helmet());
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
const cors = require('cors'); const cors = require('cors');
app.use(cors({ origin: 'http://localhost:5173', credentials: true })); 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(express.json());
app.use(cookieParser()); 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/auth', authRouter);
app.use('/api', authMiddleware); app.use('/api', authMiddleware);
app.use('/api/products', productsRouter); app.use('/api/products', productsRouter);

View File

@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const router = express.Router(); const router = express.Router();
const { const {
COOKIE_NAME, COOKIE_NAME,
@ -8,43 +9,20 @@ const {
clearSessionCookie, clearSessionCookie,
} = require('../middleware/auth'); } = require('../middleware/auth');
// In-memory rate limiter for login attempts const loginLimiter = rateLimit({
const loginAttempts = new Map(); windowMs: 60 * 1000,
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute max: 5,
const RATE_LIMIT_MAX = 5; standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts. Try again later.' },
});
function isRateLimited(ip) { router.post('/login', loginLimiter, (req, res) => {
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) => {
const password = process.env.APP_PASSWORD; const password = process.env.APP_PASSWORD;
if (!password) { if (!password) {
return res.status(200).json({ ok: true }); 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 submitted = req.body?.password || '';
const submittedBuf = Buffer.from(String(submitted)); const submittedBuf = Buffer.from(String(submitted));
const passwordBuf = Buffer.from(password); const passwordBuf = Buffer.from(password);
@ -55,7 +33,6 @@ router.post('/login', (req, res) => {
} }
if (!match) { if (!match) {
recordAttempt(ip);
return res.status(401).json({ error: 'Invalid password' }); return res.status(401).json({ error: 'Invalid password' });
} }