fix(security): rate limiting, CORS restriction, tableName allowlist, non-root Docker, migration entrypoint, aria-live
This commit is contained in:
+4
-1
@@ -10,5 +10,8 @@ CMD ["npm", "run", "dev"]
|
||||
|
||||
FROM base AS prod
|
||||
COPY . .
|
||||
# Generate Prisma client in the production image
|
||||
RUN npx prisma generate
|
||||
CMD ["npm", "start"]
|
||||
# Run as non-root user
|
||||
USER node
|
||||
CMD ["node", "src/index.js"]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running Prisma migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
echo "Starting server..."
|
||||
exec node src/index.js
|
||||
Generated
+39
@@ -11,6 +11,7 @@
|
||||
"@prisma/client": "^5.14.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"express-validator": "^7.1.0",
|
||||
"helmet": "^7.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
@@ -2064,6 +2065,23 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-validator": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
|
||||
@@ -2489,6 +2507,14 @@
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -6396,6 +6422,14 @@
|
||||
"vary": "~1.1.2"
|
||||
}
|
||||
},
|
||||
"express-rate-limit": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
||||
"requires": {
|
||||
"ip-address": "10.1.0"
|
||||
}
|
||||
},
|
||||
"express-validator": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
|
||||
@@ -6697,6 +6731,11 @@
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="
|
||||
},
|
||||
"ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@prisma/client": "^5.14.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"express-validator": "^7.1.0",
|
||||
"helmet": "^7.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
@@ -29,7 +30,9 @@
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"testMatch": ["**/tests/**/*.test.js"],
|
||||
"testMatch": [
|
||||
"**/tests/**/*.test.js"
|
||||
],
|
||||
"globalSetup": "./tests/setup.js",
|
||||
"globalTeardown": "./tests/teardown.js"
|
||||
}
|
||||
|
||||
+24
-2
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { globalErrorHandler } = require('./middleware/errorHandler');
|
||||
|
||||
const experimentsRouter = require('./routes/experiments');
|
||||
@@ -11,19 +12,40 @@ const auditLogsRouter = require('./routes/auditLogs');
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
// Security headers
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
|
||||
// CORS — restrict to the configured frontend origin in production
|
||||
const allowedOrigin = process.env.CORS_ORIGIN || '*';
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
origin: allowedOrigin,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
}));
|
||||
|
||||
// Rate limiting — 100 requests per minute per IP on all API routes
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later.' },
|
||||
skip: () => process.env.NODE_ENV === 'test',
|
||||
});
|
||||
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(morgan(process.env.NODE_ENV === 'test' ? 'silent' : process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
||||
|
||||
// Health check (no rate limit)
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Apply rate limiter to all API routes
|
||||
app.use('/api', apiLimiter);
|
||||
|
||||
app.use('/api/experiments', experimentsRouter);
|
||||
app.use('/api/animals', animalsRouter);
|
||||
app.use('/api/daily-statuses', dailyStatusesRouter);
|
||||
|
||||
@@ -7,12 +7,7 @@ async function startServer() {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
console.log('Database connected successfully');
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const { execSync } = require('child_process');
|
||||
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
// Note: migrations are run by docker-entrypoint.sh before this process starts
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ const { validateRequest } = require('../middleware/errorHandler');
|
||||
router.get(
|
||||
'/',
|
||||
[
|
||||
query('tableName').optional().isString(),
|
||||
query('tableName').optional().isIn(['experiments', 'animals', 'daily_statuses', 'audit_logs']).withMessage('tableName must be one of: experiments, animals, daily_statuses, audit_logs'),
|
||||
query('recordId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('recordId must be a valid UUID'),
|
||||
query('limit').optional().isInt({ min: 1, max: 200 }).withMessage('limit must be 1–200').toInt(),
|
||||
query('offset').optional().isInt({ min: 0 }).withMessage('offset must be ≥0').toInt(),
|
||||
|
||||
@@ -38,6 +38,11 @@ describe('GET /api/audit-logs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 422 for invalid tableName (not in allowlist)', async () => {
|
||||
const res = await request(app).get('/api/audit-logs?tableName=users');
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns 422 for invalid recordId', async () => {
|
||||
const res = await request(app).get('/api/audit-logs?recordId=not-a-uuid');
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
Reference in New Issue
Block a user