feat: rate limiting, helmet security headers, CORS, trust proxy, Docker env vars (v0.4.7)

This commit is contained in:
null 2026-05-13 18:37:32 -05:00
parent 39ee1fe537
commit 7257633d94
6 changed files with 171 additions and 14 deletions

View File

@ -26,6 +26,16 @@ RUN addgroup -g 1001 -S nodejs && \
# Set environment
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
RUN mkdir -p /app/db /app/logs

View File

@ -121,6 +121,7 @@ Version numbers correlate directly to the active phase:
- ~~Client-side validation + Sonner feedback~~
- ~~Server-side validation + input sanitization~~
- ~~Optional Zoho forwarding layer~~
- ~~Rate limiting + security headers + CORS~~
- Backend/API hardening as needed
- **Phase 5 — Verification + Release Readiness**: `0.5.x`

View File

@ -17,6 +17,15 @@ services:
environment:
- NODE_ENV=production
- 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
# Container runs as non-root user (UID 1001) for security

61
package-lock.json generated
View File

@ -1,17 +1,20 @@
{
"name": "queuenorth-website",
"version": "0.0.0",
"version": "0.4.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "queuenorth-website",
"version": "0.0.0",
"version": "0.4.6",
"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-dom": "^19.0.0",
@ -2367,6 +2370,23 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -2681,6 +2701,24 @@
"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": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -2981,6 +3019,15 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -3045,6 +3092,15 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"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": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -3425,7 +3481,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"

View File

@ -1,7 +1,7 @@
{
"name": "queuenorth-website",
"private": true,
"version": "0.4.6",
"version": "0.4.7",
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"node server/index.js\"",
@ -16,17 +16,20 @@
"docker:compose:logs": "docker-compose logs -f"
},
"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-dom": "^19.0.0",
"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",
"@radix-ui/react-dialog": "^1.1.0",
"lucide-react": "^0.468.0"
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/express": "^5.0.0",
@ -34,10 +37,10 @@
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7",
"tailwindcss": "^3.4.17",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.2",
"postcss": "^8.4.49",
"concurrently": "^9.1.2"
"tailwindcss": "^3.4.17",
"vite": "^6.0.7"
}
}

View File

@ -4,11 +4,17 @@ import { fileURLToPath } from 'url'
import { existsSync, mkdirSync, chmodSync } from 'fs'
import sqlite3 from 'better-sqlite3'
import z from 'zod'
import rateLimit from 'express-rate-limit'
import helmet from 'helmet'
import cors from 'cors'
// --- Setup ---
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
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 dbDir = path.dirname(dbPath)
@ -30,10 +36,80 @@ const log = {
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
app.use(express.json({ limit: '1mb' }))
app.use(express.urlencoded({ extended: true, limit: '1mb' }))
// Rate limiting for API routes only
app.use('/api', apiLimiter)
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now()
@ -345,4 +421,7 @@ app.listen(PORT, () => {
} else {
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}`)
})