feat: rate limiting, helmet security headers, CORS, trust proxy, Docker env vars (v0.4.7)
This commit is contained in:
parent
39ee1fe537
commit
7257633d94
10
Dockerfile
10
Dockerfile
|
|
@ -26,6 +26,16 @@ RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
|
||||||
# Set environment
|
# Set environment
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV SERVER_PORT=3001
|
||||||
|
ENV RATE_LIMIT_PER_MINUTE=5
|
||||||
|
ENV CORS_ORIGIN=*
|
||||||
|
ENV LOG_LEVEL=info
|
||||||
|
ENV ZOHO_ENABLED=false
|
||||||
|
ENV ZOHO_API_DOMAIN=https://www.zohoapis.com
|
||||||
|
ENV ZOHO_CLIENT_ID=
|
||||||
|
ENV ZOHO_CLIENT_SECRET=
|
||||||
|
ENV ZOHO_REFRESH_TOKEN=
|
||||||
|
ENV ZOHO_REDIRECT_URI=
|
||||||
|
|
||||||
# Create app directory structure
|
# Create app directory structure
|
||||||
RUN mkdir -p /app/db /app/logs
|
RUN mkdir -p /app/db /app/logs
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ Version numbers correlate directly to the active phase:
|
||||||
- ~~Client-side validation + Sonner feedback~~ ✅
|
- ~~Client-side validation + Sonner feedback~~ ✅
|
||||||
- ~~Server-side validation + input sanitization~~ ✅
|
- ~~Server-side validation + input sanitization~~ ✅
|
||||||
- ~~Optional Zoho forwarding layer~~ ✅
|
- ~~Optional Zoho forwarding layer~~ ✅
|
||||||
|
- ~~Rate limiting + security headers + CORS~~ ✅
|
||||||
- Backend/API hardening as needed
|
- Backend/API hardening as needed
|
||||||
|
|
||||||
- **Phase 5 — Verification + Release Readiness**: `0.5.x`
|
- **Phase 5 — Verification + Release Readiness**: `0.5.x`
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,15 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- SERVER_PORT=3001
|
- SERVER_PORT=3001
|
||||||
|
- RATE_LIMIT_PER_MINUTE=5
|
||||||
|
- CORS_ORIGIN=https://queuenorth.com
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
- ZOHO_ENABLED=false
|
||||||
|
- ZOHO_API_DOMAIN=https://www.zohoapis.com
|
||||||
|
- ZOHO_CLIENT_ID=
|
||||||
|
- ZOHO_CLIENT_SECRET=
|
||||||
|
- ZOHO_REFRESH_TOKEN=
|
||||||
|
- ZOHO_REDIRECT_URI=
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Container runs as non-root user (UID 1001) for security
|
# Container runs as non-root user (UID 1001) for security
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
{
|
{
|
||||||
"name": "queuenorth-website",
|
"name": "queuenorth-website",
|
||||||
"version": "0.0.0",
|
"version": "0.4.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "queuenorth-website",
|
"name": "queuenorth-website",
|
||||||
"version": "0.0.0",
|
"version": "0.4.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.0",
|
"@radix-ui/react-dialog": "^1.1.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"better-sqlite3": "^11.8.0",
|
"better-sqlite3": "^11.8.0",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"express-rate-limit": "^8.5.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|
@ -2367,6 +2370,23 @@
|
||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
|
|
@ -2681,6 +2701,24 @@
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||||
|
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^10.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express/node_modules/debug": {
|
"node_modules/express/node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
|
@ -2981,6 +3019,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",
|
||||||
|
|
@ -3045,6 +3092,15 @@
|
||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
|
"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",
|
||||||
|
|
@ -3425,7 +3481,6 @@
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|
|
||||||
25
package.json
25
package.json
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "queuenorth-website",
|
"name": "queuenorth-website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.6",
|
"version": "0.4.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
||||||
|
|
@ -16,17 +16,20 @@
|
||||||
"docker:compose:logs": "docker-compose logs -f"
|
"docker:compose:logs": "docker-compose logs -f"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.0",
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"better-sqlite3": "^11.8.0",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-rate-limit": "^8.5.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.3",
|
"react-router-dom": "^7.1.3",
|
||||||
"express": "^4.21.2",
|
|
||||||
"better-sqlite3": "^11.8.0",
|
|
||||||
"zod": "^3.24.2",
|
|
||||||
"zustand": "^5.0.3",
|
|
||||||
"@tanstack/react-query": "^5.62.0",
|
|
||||||
"sonner": "^1.7.0",
|
"sonner": "^1.7.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.0",
|
"zod": "^3.24.2",
|
||||||
"lucide-react": "^0.468.0"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
|
@ -34,10 +37,10 @@
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"vite": "^6.0.7",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"concurrently": "^9.1.2"
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,17 @@ import { fileURLToPath } from 'url'
|
||||||
import { existsSync, mkdirSync, chmodSync } from 'fs'
|
import { existsSync, mkdirSync, chmodSync } from 'fs'
|
||||||
import sqlite3 from 'better-sqlite3'
|
import sqlite3 from 'better-sqlite3'
|
||||||
import z from 'zod'
|
import z from 'zod'
|
||||||
|
import rateLimit from 'express-rate-limit'
|
||||||
|
import helmet from 'helmet'
|
||||||
|
import cors from 'cors'
|
||||||
|
|
||||||
// --- Setup ---
|
// --- Setup ---
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
|
// Trust first proxy (Docker/reverse proxy) for correct client IP in rate limiting
|
||||||
|
app.set('trust proxy', 1)
|
||||||
const dbPath = path.join(__dirname, '../db/queuenorth.db')
|
const dbPath = path.join(__dirname, '../db/queuenorth.db')
|
||||||
const dbDir = path.dirname(dbPath)
|
const dbDir = path.dirname(dbPath)
|
||||||
|
|
||||||
|
|
@ -30,10 +36,80 @@ const log = {
|
||||||
debug: (...args) => { if (currentLevel >= LOG_LEVELS.debug) console.debug(`[${new Date().toISOString()}] DEBUG`, ...args) },
|
debug: (...args) => { if (currentLevel >= LOG_LEVELS.debug) console.debug(`[${new Date().toISOString()}] DEBUG`, ...args) },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Rate Limiting ---
|
||||||
|
const rateLimitWindowMs = 60 * 1000 // 1 minute
|
||||||
|
const rateLimitMax = parseInt(process.env.RATE_LIMIT_PER_MINUTE || '5', 10)
|
||||||
|
|
||||||
|
const apiLimiter = rateLimit({
|
||||||
|
windowMs: rateLimitWindowMs,
|
||||||
|
max: rateLimitMax,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: (req, res) => {
|
||||||
|
log.warn(`Rate limit exceeded for IP: ${req.ip}`)
|
||||||
|
res.status(429).json({
|
||||||
|
error: 'Too Many Requests',
|
||||||
|
message: 'Please try again later.',
|
||||||
|
retryAfter: Math.ceil(rateLimitWindowMs / 1000),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Security Headers (Helmet) ---
|
||||||
|
const cspDirectives = {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
||||||
|
imgSrc: ["'self'", 'data:'],
|
||||||
|
connectSrc: ["'self'"],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
baseUri: ["'self'"],
|
||||||
|
formAction: ["'self'"],
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: cspDirectives,
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: false, // Prevent CSP issues with embedded content
|
||||||
|
crossOriginOpenerPolicy: false,
|
||||||
|
crossOriginResourcePolicy: { policy: 'same-origin' },
|
||||||
|
dnsPrefetchControl: { allow: false },
|
||||||
|
frameguard: { action: 'deny' },
|
||||||
|
hidePoweredBy: true,
|
||||||
|
hsts: { maxAge: 31536000, includeSubDomains: true },
|
||||||
|
ieNoOpen: true,
|
||||||
|
noSniff: true,
|
||||||
|
originAgentCluster: true,
|
||||||
|
permittedCrossDomainPolicies: { permittedPolicies: 'none' },
|
||||||
|
referrerPolicy: { policy: 'same-origin' },
|
||||||
|
xssFilter: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
log.info('[Security] Helmet enabled with CSP configured')
|
||||||
|
|
||||||
|
// --- CORS Configuration ---
|
||||||
|
const corsOrigin = process.env.CORS_ORIGIN || '*' // Default to * for development
|
||||||
|
const corsConfig = cors({
|
||||||
|
origin: corsOrigin === '*' ? corsOrigin : (corsOrigin === 'null' ? undefined : corsOrigin),
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
exposedHeaders: ['X-RateLimit-Remaining', 'X-RateLimit-Reset'],
|
||||||
|
maxAge: 86400, // 24 hours
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use(corsConfig)
|
||||||
|
log.info(`[CORS] Enabled with origin: ${corsOrigin}`)
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json({ limit: '1mb' }))
|
app.use(express.json({ limit: '1mb' }))
|
||||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }))
|
app.use(express.urlencoded({ extended: true, limit: '1mb' }))
|
||||||
|
|
||||||
|
// Rate limiting for API routes only
|
||||||
|
app.use('/api', apiLimiter)
|
||||||
|
|
||||||
// Request logging middleware
|
// Request logging middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
|
|
@ -345,4 +421,7 @@ app.listen(PORT, () => {
|
||||||
} else {
|
} else {
|
||||||
log.info('Zoho CRM forwarding: DISABLED (set ZOHO_ENABLED=true to enable)')
|
log.info('Zoho CRM forwarding: DISABLED (set ZOHO_ENABLED=true to enable)')
|
||||||
}
|
}
|
||||||
|
log.info(`Rate limiting: ${rateLimitMax} requests per ${rateLimitWindowMs / 1000} seconds`)
|
||||||
|
log.info(`Security headers: Helmet enabled with CSP configured`)
|
||||||
|
log.info(`CORS origin: ${corsOrigin}`)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue