diff --git a/Dockerfile b/Dockerfile index b4b3ea7..d3a361c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index e98427a..6781679 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/docker-compose.yml b/docker-compose.yml index 48f1b88..2926a2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index b2442a8..8bb50f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 8605077..c228806 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/server/index.js b/server/index.js index 591b18b..414098c 100644 --- a/server/index.js +++ b/server/index.js @@ -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}`) })