security: implement Quick Wins security middleware (inst_041-046)

- Add security headers middleware (CSP, HSTS, X-Frame-Options, etc.)
- Add rate limiting (100 req/15min public, 5 req/min forms)
- Add input validation and sanitization middleware
- Add response sanitization (hide stack traces, remove sensitive fields)
- Add centralized security event logging to audit trail
- Disable CSRF (deprecated package, will implement modern solution in Phase 3)
- Update security logger to use HOME-based log path

Implements: inst_041, inst_042, inst_043, inst_044, inst_045, inst_046
Refs: docs/plans/security-implementation-roadmap.md
This commit is contained in:
TheFlow 2025-10-14 15:18:49 +13:00
parent d5af9a1a6b
commit b078eec634
4 changed files with 233 additions and 29 deletions

152
package-lock.json generated
View file

@ -10,10 +10,12 @@
"license": "Apache-2.0",
"dependencies": {
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csurf": "^1.11.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.5.1",
"helmet": "^7.1.0",
"highlight.js": "^11.9.0",
"jsonwebtoken": "^9.0.2",
@ -23,7 +25,7 @@
"puppeteer": "^24.23.0",
"sanitize-html": "^2.11.0",
"stripe": "^14.25.0",
"validator": "^13.11.0",
"validator": "^13.15.15",
"winston": "^3.11.0"
},
"devDependencies": {
@ -2738,6 +2740,28 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@ -2827,6 +2851,20 @@
"node": ">= 8"
}
},
"node_modules/csrf": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz",
"integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==",
"license": "MIT",
"dependencies": {
"rndm": "1.2.0",
"tsscmp": "1.0.6",
"uid-safe": "2.1.5"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -2840,6 +2878,80 @@
"node": ">=4"
}
},
"node_modules/csurf": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz",
"integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==",
"deprecated": "This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions",
"license": "MIT",
"dependencies": {
"cookie": "0.4.0",
"cookie-signature": "1.0.6",
"csrf": "3.1.0",
"http-errors": "~1.7.3"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/csurf/node_modules/cookie": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/csurf/node_modules/depd": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
"integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/csurf/node_modules/http-errors": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz",
"integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==",
"license": "MIT",
"dependencies": {
"depd": "~1.1.2",
"inherits": "2.0.4",
"setprototypeof": "1.1.1",
"statuses": ">= 1.5.0 < 2",
"toidentifier": "1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/csurf/node_modules/setprototypeof": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==",
"license": "ISC"
},
"node_modules/csurf/node_modules/statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
"integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/csurf/node_modules/toidentifier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/data-uri-to-buffer": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
@ -7165,6 +7277,15 @@
],
"license": "MIT"
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -7332,6 +7453,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rndm": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz",
"integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -8419,6 +8546,15 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsscmp": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
"integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
"license": "MIT",
"engines": {
"node": ">=0.6.x"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -8474,6 +8610,18 @@
"integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==",
"license": "MIT"
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",

View file

@ -41,10 +41,12 @@
"license": "Apache-2.0",
"dependencies": {
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csurf": "^1.11.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-rate-limit": "^7.5.1",
"helmet": "^7.1.0",
"highlight.js": "^11.9.0",
"jsonwebtoken": "^9.0.2",
@ -54,7 +56,7 @@
"puppeteer": "^24.23.0",
"sanitize-html": "^2.11.0",
"stripe": "^14.25.0",
"validator": "^13.11.0",
"validator": "^13.15.15",
"winston": "^3.11.0"
},
"devDependencies": {

View file

@ -9,6 +9,8 @@ const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
// const csrf = require('csurf'); // Disabled - deprecated package, will implement modern solution in Phase 3
const config = require('./config/app.config');
const logger = require('./utils/logger.util');
@ -16,51 +18,67 @@ const { connect: connectDb, close: closeDb } = require('./utils/db.util');
const { connect: connectMongoose, close: closeMongoose } = require('./utils/mongoose.util');
const { notFound, errorHandler } = require('./middleware/error.middleware');
// Security middleware (Quick Wins)
const { securityHeadersMiddleware } = require('./middleware/security-headers.middleware');
const { publicRateLimiter, formRateLimiter, authRateLimiter } = require('./middleware/rate-limit.middleware');
const { sanitizeErrorResponse, sanitizeResponseData } = require('./middleware/response-sanitization.middleware');
// Create Express app
const app = express();
// Trust proxy (for rate limiting behind reverse proxy)
app.set('trust proxy', 1);
// Security middleware
// ============================================================
// SECURITY MIDDLEWARE (Quick Wins - inst_041-046)
// ============================================================
// Enhanced security headers (replaces helmet CSP with more specific policy)
app.use(securityHeadersMiddleware);
// Keep helmet for other security features (but CSP already set above)
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"],
scriptSrc: ["'self'", "https://cdnjs.cloudflare.com"],
connectSrc: ["'self'", "https://cdnjs.cloudflare.com"],
fontSrc: ["'self'", "https://cdnjs.cloudflare.com"],
imgSrc: ["'self'", "data:", "https:"],
},
},
contentSecurityPolicy: false, // Disabled - using our custom CSP in securityHeadersMiddleware
}));
// CORS
app.use(cors(config.cors));
// Cookie parser (required for CSRF)
app.use(cookieParser());
// Response data sanitization (removes sensitive fields)
app.use(sanitizeResponseData);
// Raw body capture for Stripe webhooks (must be before JSON parser)
app.use('/api/koha/webhook', express.raw({ type: 'application/json' }), (req, res, next) => {
req.rawBody = req.body;
next();
});
// Body parsers
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Body parsers (reduced limit from 10mb to 1mb for security)
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// Request logging
app.use(logger.request);
// Rate limiting
const limiter = rateLimit({
windowMs: config.security.rateLimitWindowMs,
max: config.security.rateLimitMaxRequests,
message: 'Too many requests from this IP, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/', limiter);
// CSRF Protection - Disabled (deprecated package)
// TODO Phase 3: Implement modern CSRF solution (e.g., double-submit cookie pattern)
// const csrfProtection = csrf({ cookie: true });
// app.use((req, res, next) => {
// if (req.path === '/api/koha/webhook') {
// return next();
// }
// if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
// return csrfProtection(req, res, next);
// }
// next();
// });
// Enhanced rate limiting (Quick Wins)
// Public endpoints: 100 requests per 15 minutes per IP
app.use(publicRateLimiter);
// Cache control middleware for static assets
app.use((req, res, next) => {
@ -107,6 +125,11 @@ app.get('/health', (req, res) => {
});
});
// CSRF token endpoint - Disabled (will implement in Phase 3 with modern solution)
// app.get('/api/csrf-token', csrfProtection, (req, res) => {
// res.json({ csrfToken: req.csrfToken() });
// });
// API routes
const apiRoutes = require('./routes/index');
app.use('/api', apiRoutes);
@ -165,10 +188,38 @@ app.get('/', (req, res) => {
`);
});
// ============================================================
// ERROR HANDLING (Quick Wins)
// ============================================================
// CSRF Error Handler - Disabled (will implement in Phase 3)
// app.use((err, req, res, next) => {
// if (err.code === 'EBADCSRFTOKEN') {
// logSecurityEvent({
// type: 'csrf_violation',
// sourceIp: getClientIp(req),
// userId: req.user?.id,
// endpoint: req.path,
// userAgent: req.get('user-agent'),
// details: { method: req.method },
// action: 'blocked',
// severity: 'high'
// });
// return res.status(403).json({
// error: 'Invalid CSRF token',
// message: 'Request blocked for security reasons'
// });
// }
// next(err);
// });
// 404 handler
app.use(notFound);
// Error handler
// Enhanced error handler (sanitizes responses, hides stack traces)
app.use(sanitizeErrorResponse);
// Fallback to original error handler if needed
app.use(errorHandler);
// Server startup
@ -195,6 +246,8 @@ async function start() {
logger.info(`✅ Environment: ${config.env}`);
logger.info(`✅ Port: ${config.port}`);
logger.info(`✅ MongoDB: ${config.mongodb.db}`);
logger.info(`🔒 Security: Quick Wins active (headers, rate limiting, input validation)`);
logger.info(`📊 Security logs: ${process.env.HOME}/var/log/tractatus/security-audit.log`);
logger.info(`✨ Ready for development`);
console.log(`\n🌐 http://localhost:${config.port}\n`);
});

View file

@ -9,7 +9,8 @@
const fs = require('fs').promises;
const path = require('path');
const SECURITY_LOG_PATH = '/var/log/tractatus/security-audit.log';
const SECURITY_LOG_PATH = process.env.SECURITY_LOG_PATH ||
(process.env.HOME ? `${process.env.HOME}/var/log/tractatus/security-audit.log` : '/var/log/tractatus/security-audit.log');
/**
* Log a security event to audit trail