security: QA Council audit fixes

- Remove JWT secret fallbacks (fail on startup if missing)
- Hash refresh tokens with SHA-256 before DB storage
- Add Zod validation to bulk-approve, bulk-reject, update-user, copy-week
- Add global API rate limiting (100 req/15min per IP)
- Fix nginx X-XSS-Protection header (align with Helmet)
- Remove --accept-data-loss from Dockerfile CMD
- Create .gitignore to protect secrets from commits
- Secure .env file permissions (chmod 600)
This commit is contained in:
BizzleBot
2026-02-17 02:09:26 +00:00
parent 0739f87f73
commit 47c35c1e3a
9 changed files with 83 additions and 30 deletions
+15
View File
@@ -0,0 +1,15 @@
# Secrets
.env
.env.local
.env.*.local
docker/.env
*.pem
*.key
# Dependencies
node_modules/
# Runtime
*.log
.DS_Store
dist/
+12
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const helmet = require('helmet'); const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { PrismaClient } = require('@prisma/client'); const { PrismaClient } = require('@prisma/client');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
@@ -38,6 +39,17 @@ app.use(
}) })
); );
// Global rate limiting (100 requests per 15 minutes per IP)
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: { error: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
app.use('/api/', apiLimiter);
// Body parsing // Body parsing
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
+16 -2
View File
@@ -1,7 +1,20 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-jwt-secret-change-me'; /**
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-me'; * Hash a refresh token for secure DB storage
*/
function hashRefreshToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
if (!JWT_SECRET || !JWT_REFRESH_SECRET) {
console.error('FATAL: JWT_SECRET and JWT_REFRESH_SECRET environment variables are required');
process.exit(1);
}
const ACCESS_TOKEN_EXPIRY = '15m'; const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d'; const REFRESH_TOKEN_EXPIRY = '7d';
@@ -93,6 +106,7 @@ module.exports = {
generateRefreshToken, generateRefreshToken,
verifyAccessToken, verifyAccessToken,
verifyRefreshToken, verifyRefreshToken,
hashRefreshToken,
authenticate, authenticate,
requireAdmin, requireAdmin,
requireSuperAdmin, requireSuperAdmin,
+8 -15
View File
@@ -3,7 +3,9 @@ const bcrypt = require('bcryptjs');
const { authenticate, requireAdmin } = require('../middleware/auth'); const { authenticate, requireAdmin } = require('../middleware/auth');
const { const {
approveRejectSchema, approveRejectSchema,
bulkOperationSchema,
createUserSchema, createUserSchema,
updateUserSchema,
createHomeownerSchema, createHomeownerSchema,
updateHomeownerSchema, updateHomeownerSchema,
reportQuerySchema, reportQuerySchema,
@@ -142,15 +144,9 @@ router.get('/timesheets/:id', async (req, res) => {
}); });
// ─────────────── POST /api/admin/timesheets/bulk-approve ─────────────── // ─────────────── POST /api/admin/timesheets/bulk-approve ───────────────
router.post('/timesheets/bulk-approve', async (req, res) => { router.post('/timesheets/bulk-approve', validateBody(bulkOperationSchema), async (req, res) => {
try { try {
const { timesheetIds, notes } = req.body; const { timesheetIds, notes } = req.validated;
if (!Array.isArray(timesheetIds) || timesheetIds.length === 0) {
return res.status(400).json({ error: 'timesheetIds array is required' });
}
if (timesheetIds.length > 100) {
return res.status(400).json({ error: 'Maximum 100 timesheets per batch' });
}
const results = { approved: 0, failed: [] }; const results = { approved: 0, failed: [] };
for (const id of timesheetIds) { for (const id of timesheetIds) {
@@ -173,12 +169,9 @@ router.post('/timesheets/bulk-approve', async (req, res) => {
}); });
// ─────────────── POST /api/admin/timesheets/bulk-reject ─────────────── // ─────────────── POST /api/admin/timesheets/bulk-reject ───────────────
router.post('/timesheets/bulk-reject', async (req, res) => { router.post('/timesheets/bulk-reject', validateBody(bulkOperationSchema), async (req, res) => {
try { try {
const { timesheetIds, notes } = req.body; const { timesheetIds, notes } = req.validated;
if (!Array.isArray(timesheetIds) || timesheetIds.length === 0) {
return res.status(400).json({ error: 'timesheetIds array is required' });
}
const results = { rejected: 0, failed: [] }; const results = { rejected: 0, failed: [] };
for (const id of timesheetIds) { for (const id of timesheetIds) {
@@ -395,10 +388,10 @@ router.post('/users', validateBody(createUserSchema), async (req, res) => {
}); });
// ─────────────── PUT /api/admin/users/:id ─────────────── // ─────────────── PUT /api/admin/users/:id ───────────────
router.put('/users/:id', async (req, res) => { router.put('/users/:id', validateBody(updateUserSchema), async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { name, role, isActive, password } = req.body; const { name, role, isActive, password } = req.validated;
const user = await req.prisma.user.findUnique({ where: { id } }); const user = await req.prisma.user.findUnique({ where: { id } });
if (!user) { if (!user) {
+7 -6
View File
@@ -5,6 +5,7 @@ const {
generateAccessToken, generateAccessToken,
generateRefreshToken, generateRefreshToken,
verifyRefreshToken, verifyRefreshToken,
hashRefreshToken,
authenticate, authenticate,
requireAdmin, requireAdmin,
} = require('../middleware/auth'); } = require('../middleware/auth');
@@ -49,10 +50,10 @@ router.post('/login', authLimiter, validateBody(loginSchema), async (req, res) =
const accessToken = generateAccessToken(user); const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user); const refreshToken = generateRefreshToken(user);
// Store refresh token hash in DB // Store hashed refresh token in DB
await req.prisma.user.update({ await req.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { refreshToken }, data: { refreshToken: hashRefreshToken(refreshToken) },
}); });
res.json({ res.json({
@@ -138,8 +139,8 @@ router.post('/refresh', validateBody(refreshSchema), async (req, res) => {
return res.status(401).json({ error: 'User not found or deactivated' }); return res.status(401).json({ error: 'User not found or deactivated' });
} }
// Verify the refresh token matches the stored one (token rotation) // Verify the refresh token hash matches the stored one (token rotation)
if (user.refreshToken !== refreshToken) { if (user.refreshToken !== hashRefreshToken(refreshToken)) {
// Possible token theft — invalidate all tokens for this user // Possible token theft — invalidate all tokens for this user
await req.prisma.user.update({ await req.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
@@ -151,10 +152,10 @@ router.post('/refresh', validateBody(refreshSchema), async (req, res) => {
const newAccessToken = generateAccessToken(user); const newAccessToken = generateAccessToken(user);
const newRefreshToken = generateRefreshToken(user); const newRefreshToken = generateRefreshToken(user);
// Rotate refresh token // Rotate refresh token (store hash)
await req.prisma.user.update({ await req.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { refreshToken: newRefreshToken }, data: { refreshToken: hashRefreshToken(newRefreshToken) },
}); });
res.json({ res.json({
+3 -5
View File
@@ -4,6 +4,7 @@ const {
createEntrySchema, createEntrySchema,
updateEntrySchema, updateEntrySchema,
weekQuerySchema, weekQuerySchema,
copyWeekSchema,
validateBody, validateBody,
validateQuery, validateQuery,
} = require('../utils/validation'); } = require('../utils/validation');
@@ -269,12 +270,9 @@ router.delete('/:id', async (req, res) => {
}); });
// ─────────────── POST /api/entries/copy-week ─────────────── // ─────────────── POST /api/entries/copy-week ───────────────
router.post('/copy-week', async (req, res) => { router.post('/copy-week', validateBody(copyWeekSchema), async (req, res) => {
try { try {
const { fromWeek, toWeek } = req.body; const { fromWeek, toWeek } = req.validated;
if (!fromWeek || !toWeek) {
return res.status(400).json({ error: 'fromWeek and toWeek are required (YYYY-MM-DD Monday)' });
}
const fromMonday = getWeekMonday(fromWeek); const fromMonday = getWeekMonday(fromWeek);
const toMonday = getWeekMonday(toWeek); const toMonday = getWeekMonday(toWeek);
+20
View File
@@ -69,6 +69,23 @@ const approveRejectSchema = z.object({
notes: z.string().max(1000).trim().optional(), notes: z.string().max(1000).trim().optional(),
}); });
const bulkOperationSchema = z.object({
timesheetIds: z.array(z.string().uuid('Invalid timesheet ID')).min(1, 'At least one timesheet ID required').max(100, 'Maximum 100 timesheets per batch'),
notes: z.string().max(1000).trim().optional(),
});
const updateUserSchema = z.object({
name: z.string().min(1).max(100).trim().optional(),
role: z.enum(['employee', 'office', 'admin', 'super_admin']).optional(),
isActive: z.boolean().optional(),
password: z.string().min(8, 'Password must be at least 8 characters').max(128).optional(),
});
const copyWeekSchema = z.object({
fromWeek: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'fromWeek must be YYYY-MM-DD'),
toWeek: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'toWeek must be YYYY-MM-DD'),
});
const createUserSchema = registerSchema; const createUserSchema = registerSchema;
const createHomeownerSchema = z.object({ const createHomeownerSchema = z.object({
@@ -138,6 +155,9 @@ module.exports = {
submitTimesheetSchema, submitTimesheetSchema,
emailTimesheetSchema, emailTimesheetSchema,
approveRejectSchema, approveRejectSchema,
bulkOperationSchema,
updateUserSchema,
copyWeekSchema,
createUserSchema, createUserSchema,
createHomeownerSchema, createHomeownerSchema,
updateHomeownerSchema, updateHomeownerSchema,
+1 -1
View File
@@ -55,4 +55,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://localhost:3001/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))" CMD node -e "fetch('http://localhost:3001/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"
# Start with migration and seed on first run # Start with migration and seed on first run
CMD ["sh", "-c", "npx prisma db push --accept-data-loss 2>/dev/null; node prisma/seed.js 2>/dev/null; node src/index.js"] CMD ["sh", "-c", "npx prisma db push 2>/dev/null; node prisma/seed.js 2>/dev/null; node src/index.js"]
+1 -1
View File
@@ -15,7 +15,7 @@ server {
# Security headers # Security headers
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression # Gzip compression