diff --git a/.gitignore b/.gitignore index 9f9b89a..4784121 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,23 @@ # Dependencies node_modules/ -frontend/node_modules/ -backend/node_modules/ +*/node_modules/ -# Environment variables +# Environment files - NEVER commit secrets! .env .env.local +.env.production .env.*.local +backend/.env +frontend/.env -# Logs -logs/ -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Build output +# Build outputs dist/ build/ -frontend/dist/ -backend/dist/ +*.log + +# OS files +.DS_Store +Thumbs.db # IDE .vscode/ @@ -28,11 +26,33 @@ backend/dist/ *.swo *~ -# OS -.DS_Store -Thumbs.db +# Docker volumes +postgres_data/ -# Docker -*.pid -*.seed -*.pid.lock +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage/ +.nyc_output/ + +# Prisma +backend/prisma/migrations/*_draft/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# SSL certificates (if self-signed for development) +ssl/*.pem +ssl/*.key +ssl/*.crt + +# Backups +backups/*.sql +backups/*.dump diff --git a/backend/.env.example b/backend/.env.example index 2f9ba78..2c31151 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,15 +2,23 @@ NODE_ENV=development PORT=3000 +# CORS +CORS_ORIGIN=http://localhost:8080 + # Database DATABASE_URL=postgresql://spotlightcam:spotlightcam123@db:5432/spotlightcam # JWT -JWT_SECRET=your-secret-key-change-this-in-production +JWT_SECRET=dev-secret-key-12345-change-in-production JWT_EXPIRES_IN=24h -# CORS -CORS_ORIGIN=http://localhost:8080 +# AWS SES (Phase 1.5) +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your-aws-access-key-id +AWS_SECRET_ACCESS_KEY=your-aws-secret-access-key +SES_FROM_EMAIL=noreply@spotlight.cam +SES_FROM_NAME=spotlight.cam -# WebRTC (future) -# STUN_SERVER=stun:stun.l.google.com:19302 +# Email Settings +FRONTEND_URL=http://localhost:8080 +VERIFICATION_TOKEN_EXPIRY=24h diff --git a/backend/.env.production.example b/backend/.env.production.example new file mode 100644 index 0000000..349ea4c --- /dev/null +++ b/backend/.env.production.example @@ -0,0 +1,69 @@ +# Production Environment Configuration +# NEVER commit this file with real values! +# Use environment variables or secrets manager in production + +# Server +NODE_ENV=production +PORT=3000 + +# CORS - Your production domains +CORS_ORIGIN=https://spotlight.cam,https://www.spotlight.cam + +# Database - Use managed database or strong credentials +# NEVER use default passwords in production! +DATABASE_URL=postgresql://prod_user:STRONG_PASSWORD_HERE@db:5432/spotlightcam_prod + +# JWT - CRITICAL: Generate strong secrets +# Generate with: openssl rand -base64 64 +JWT_SECRET=CHANGE_THIS_TO_RANDOM_64_CHAR_STRING +JWT_EXPIRES_IN=24h + +# AWS SES - Production credentials +# BEST PRACTICE: Use IAM roles instead of access keys +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +SES_FROM_EMAIL=noreply@spotlight.cam +SES_FROM_NAME=spotlight.cam + +# Email Settings +FRONTEND_URL=https://spotlight.cam +VERIFICATION_TOKEN_EXPIRY=24h + +# Security Settings - Production (strict) +RATE_LIMIT_ENABLED=true +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 +RATE_LIMIT_AUTH_MAX=5 +RATE_LIMIT_EMAIL_MAX=3 +ENABLE_CSRF=true +BODY_SIZE_LIMIT=10kb +LOG_LEVEL=warn + +# Password Policy - Enforced in production +PASSWORD_MIN_LENGTH=8 +PASSWORD_REQUIRE_UPPERCASE=true +PASSWORD_REQUIRE_LOWERCASE=true +PASSWORD_REQUIRE_NUMBER=true +PASSWORD_REQUIRE_SPECIAL=false + +# Account Lockout - Enabled in production +ENABLE_ACCOUNT_LOCKOUT=true +MAX_LOGIN_ATTEMPTS=5 +LOCKOUT_DURATION_MINUTES=15 + +# Database Connection Pool +DB_POOL_MIN=2 +DB_POOL_MAX=10 + +# Monitoring (optional) +SENTRY_DSN= +NEW_RELIC_LICENSE_KEY= + +# IMPORTANT SECURITY NOTES: +# 1. Generate JWT_SECRET with: openssl rand -base64 64 +# 2. Use AWS IAM roles instead of access keys when possible +# 3. Use environment variables or secrets manager (AWS Secrets Manager, HashiCorp Vault) +# 4. Never commit .env files to version control +# 5. Rotate all secrets regularly (every 90 days) +# 6. Use strong database passwords (20+ characters, mixed case, numbers, symbols) diff --git a/backend/package-lock.json b/backend/package-lock.json index 69e1b09..cc918ed 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,10 +12,16 @@ "@aws-sdk/client-ses": "^3.930.0", "@prisma/client": "^5.8.0", "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csurf": "^1.11.0", + "dompurify": "^3.3.0", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-rate-limit": "^8.2.1", "express-validator": "^7.3.0", + "helmet": "^8.1.0", + "jsdom": "^27.2.0", "jsonwebtoken": "^9.0.2", "socket.io": "^4.8.1" }, @@ -27,6 +33,62 @@ "supertest": "^6.3.3" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.23", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.23.tgz", + "integrity": "sha512-2kJ1HxBKzPLbmhZpxBiTZggjtgCwKg1ma5RHShxvd6zgqhDEdEkzpiwe7jLkI2p2BrZvFCXIihdoMkl1H39VnA==", + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", + "integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.1" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz", + "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.2" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -1226,6 +1288,135 @@ "dev": true, "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.16.tgz", + "integrity": "sha512-2SpS4/UaWQaGpBINyG5ZuCHnUDeVByOhvbkARwfmnfxDvTaj80yOI1cD8Tw93ICV5Fx4fnyDKWQZI1CDtcWyUg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2420,6 +2611,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/yargs": { "version": "17.0.34", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", @@ -2448,6 +2646,15 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2686,6 +2893,15 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "dev": true, @@ -3083,6 +3299,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "license": "MIT" @@ -3142,6 +3380,134 @@ "node": ">= 8" } }, + "node_modules/csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "license": "MIT", + "dependencies": { + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", + "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.0.3", + "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/csurf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", + "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", + "deprecated": "This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions", + "license": "MIT", + "dependencies": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", + "http-errors": "~1.7.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/csurf/node_modules/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "license": "ISC" + }, + "node_modules/csurf/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "2.6.9", "license": "MIT", @@ -3149,6 +3515,12 @@ "ms": "2.0.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", @@ -3230,6 +3602,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dompurify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", + "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.6.1", "license": "BSD-2-Clause", @@ -3399,6 +3780,18 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -3588,6 +3981,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz", @@ -3930,6 +4341,27 @@ "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/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3951,6 +4383,78 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4022,6 +4526,15 @@ "version": "2.0.4", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "license": "MIT", @@ -4110,6 +4623,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4863,6 +5382,66 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", + "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.23", + "@asamuzakjp/dom-selector": "^6.7.4", + "cssstyle": "^5.3.3", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5076,6 +5655,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "license": "MIT", @@ -5394,6 +5979,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "license": "MIT", @@ -5561,6 +6158,15 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -5591,6 +6197,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "license": "MIT", @@ -5639,6 +6254,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5693,6 +6317,12 @@ "node": ">=10" } }, + "node_modules/rndm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", + "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "funding": [ @@ -5715,6 +6345,18 @@ "version": "2.1.2", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.3", "license": "ISC", @@ -6058,6 +6700,15 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", @@ -6283,6 +6934,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -6298,6 +6955,24 @@ "node": ">=8" } }, + "node_modules/tldts": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", + "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.17" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", + "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -6331,12 +7006,45 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -6371,6 +7079,18 @@ "node": ">= 0.6" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "dev": true, @@ -6458,6 +7178,18 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -6468,6 +7200,61 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6544,6 +7331,21 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index 2d0b73b..9f65bd2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,10 +26,16 @@ "@aws-sdk/client-ses": "^3.930.0", "@prisma/client": "^5.8.0", "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csurf": "^1.11.0", + "dompurify": "^3.3.0", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-rate-limit": "^8.2.1", "express-validator": "^7.3.0", + "helmet": "^8.1.0", + "jsdom": "^27.2.0", "jsonwebtoken": "^9.0.2", "socket.io": "^4.8.1" }, diff --git a/backend/src/app.js b/backend/src/app.js index 43081e9..9b78326 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -1,15 +1,57 @@ const express = require('express'); const cors = require('cors'); +const helmet = require('helmet'); +const securityConfig = require('./config/security'); +const { apiLimiter } = require('./middleware/rateLimiter'); const app = express(); -// Middleware -app.use(cors({ - origin: process.env.CORS_ORIGIN || 'http://localhost:8080', - credentials: true +// Security Headers (helmet) +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'", "https://ui-avatars.com"], + scriptSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:", "https://ui-avatars.com"], + connectSrc: ["'self'"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + mediaSrc: ["'self'"], + frameSrc: ["'none'"], + }, + }, + hsts: { + maxAge: 31536000, + includeSubDomains: true, + preload: true, + }, + noSniff: true, + xssFilter: true, + hidePoweredBy: true, })); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); + +// CORS +app.use(cors({ + origin: (origin, callback) => { + const allowedOrigins = securityConfig.cors.origin; + + // Allow requests with no origin (mobile apps, curl, etc.) + if (!origin) return callback(null, true); + + if (allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: securityConfig.cors.credentials, + maxAge: 86400, // 24 hours +})); + +// Body parsing with size limits +app.use(express.json({ limit: securityConfig.bodyLimit })); +app.use(express.urlencoded({ extended: true, limit: securityConfig.bodyLimit })); // Request logging middleware app.use((req, res, next) => { @@ -27,6 +69,9 @@ app.get('/api/health', (req, res) => { }); }); +// Apply rate limiting to all API routes +app.use('/api/', apiLimiter); + // API routes app.use('/api/auth', require('./routes/auth')); app.use('/api/users', require('./routes/users')); @@ -45,11 +90,24 @@ app.use((req, res) => { // Error handler app.use((err, req, res, next) => { + // Log full error for debugging console.error('Error:', err); - res.status(err.status || 500).json({ - error: err.message || 'Internal Server Error', - ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) - }); + + // Determine if we should show detailed errors + const isDevelopment = process.env.NODE_ENV === 'development'; + + // Generic error response + const errorResponse = { + success: false, + error: isDevelopment ? err.message : 'Internal Server Error', + }; + + // Add stack trace only in development + if (isDevelopment && err.stack) { + errorResponse.stack = err.stack; + } + + res.status(err.status || 500).json(errorResponse); }); module.exports = app; diff --git a/backend/src/config/security.js b/backend/src/config/security.js new file mode 100644 index 0000000..a801159 --- /dev/null +++ b/backend/src/config/security.js @@ -0,0 +1,70 @@ +/** + * Security Configuration + * Environment-aware security settings + */ + +const isDevelopment = process.env.NODE_ENV === 'development'; +const isProduction = process.env.NODE_ENV === 'production'; + +module.exports = { + // Rate limiting configuration + rateLimit: { + enabled: process.env.RATE_LIMIT_ENABLED === 'true' || isProduction, + + // General API rate limit + api: { + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes + max: parseInt(process.env.RATE_LIMIT_MAX) || (isDevelopment ? 1000 : 100), + }, + + // Strict rate limit for authentication endpoints + auth: { + windowMs: 15 * 60 * 1000, // 15 minutes + max: parseInt(process.env.RATE_LIMIT_AUTH_MAX) || (isDevelopment ? 100 : 5), + skipSuccessfulRequests: true, + }, + + // Email endpoints rate limit + email: { + windowMs: 60 * 60 * 1000, // 1 hour + max: parseInt(process.env.RATE_LIMIT_EMAIL_MAX) || (isDevelopment ? 20 : 3), + }, + }, + + // CSRF protection + csrf: { + enabled: process.env.ENABLE_CSRF === 'true' || isProduction, + }, + + // Request body size limits + bodyLimit: process.env.BODY_SIZE_LIMIT || (isDevelopment ? '50mb' : '10kb'), + + // CORS configuration + cors: { + origin: process.env.CORS_ORIGIN ? + process.env.CORS_ORIGIN.split(',') : + ['http://localhost:8080'], + credentials: true, + }, + + // Password policy + password: { + minLength: parseInt(process.env.PASSWORD_MIN_LENGTH) || 8, + requireUppercase: process.env.PASSWORD_REQUIRE_UPPERCASE === 'true' || isProduction, + requireLowercase: process.env.PASSWORD_REQUIRE_LOWERCASE === 'true' || isProduction, + requireNumber: process.env.PASSWORD_REQUIRE_NUMBER === 'true' || isProduction, + requireSpecial: process.env.PASSWORD_REQUIRE_SPECIAL === 'true' || false, + }, + + // Account lockout + accountLockout: { + enabled: process.env.ENABLE_ACCOUNT_LOCKOUT === 'true' || isProduction, + maxAttempts: parseInt(process.env.MAX_LOGIN_ATTEMPTS) || 5, + lockoutDuration: parseInt(process.env.LOCKOUT_DURATION_MINUTES) || 15, // minutes + }, + + // Logging + logging: { + level: process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'warn'), + }, +}; diff --git a/backend/src/controllers/auth.js b/backend/src/controllers/auth.js index 6d26d94..d7544af 100644 --- a/backend/src/controllers/auth.js +++ b/backend/src/controllers/auth.js @@ -8,6 +8,7 @@ const { getTokenExpiry } = require('../utils/auth'); const { sendVerificationEmail, sendWelcomeEmail, sendPasswordResetEmail } = require('../utils/email'); +const { sanitizeForEmail, timingSafeEqual } = require('../utils/sanitize'); // Register new user (Phase 1.5 - with WSDC support and email verification) async function register(req, res, next) { @@ -25,25 +26,12 @@ async function register(req, res, next) { }, }); + // Prevent user enumeration - use generic error message if (existingUser) { - if (existingUser.email === email) { - return res.status(400).json({ - success: false, - error: 'Email already registered', - }); - } - if (existingUser.username === username) { - return res.status(400).json({ - success: false, - error: 'Username already taken', - }); - } - if (wsdcId && existingUser.wsdcId === wsdcId) { - return res.status(400).json({ - success: false, - error: 'WSDC ID already registered', - }); - } + return res.status(400).json({ + success: false, + error: 'An account with these credentials already exists. Please try logging in or use different credentials.', + }); } // Hash password @@ -87,11 +75,11 @@ async function register(req, res, next) { }, }); - // Send verification email + // Send verification email (sanitize inputs) try { await sendVerificationEmail( user.email, - user.firstName || user.username, + sanitizeForEmail(user.firstName || user.username), verificationToken, verificationCode ); @@ -213,9 +201,9 @@ async function verifyEmailByToken(req, res, next) { }, }); - // Send welcome email + // Send welcome email (sanitize inputs) try { - await sendWelcomeEmail(user.email, user.firstName || user.username); + await sendWelcomeEmail(user.email, sanitizeForEmail(user.firstName || user.username)); } catch (emailError) { console.error('Failed to send welcome email:', emailError); } @@ -283,9 +271,9 @@ async function verifyEmailByCode(req, res, next) { }, }); - // Send welcome email + // Send welcome email (sanitize inputs) try { - await sendWelcomeEmail(user.email, user.firstName || user.username); + await sendWelcomeEmail(user.email, sanitizeForEmail(user.firstName || user.username)); } catch (emailError) { console.error('Failed to send welcome email:', emailError); } @@ -346,10 +334,10 @@ async function resendVerification(req, res, next) { }, }); - // Send verification email + // Send verification email (sanitize inputs) await sendVerificationEmail( user.email, - user.firstName || user.username, + sanitizeForEmail(user.firstName || user.username), verificationToken, verificationCode ); @@ -401,11 +389,11 @@ async function requestPasswordReset(req, res, next) { }, }); - // Send password reset email + // Send password reset email (sanitize inputs) try { await sendPasswordResetEmail( user.email, - user.firstName || user.username, + sanitizeForEmail(user.firstName || user.username), resetToken ); } catch (emailError) { @@ -437,13 +425,8 @@ async function resetPassword(req, res, next) { }); } - // Validate password length - if (newPassword.length < 8) { - return res.status(400).json({ - success: false, - error: 'Password must be at least 8 characters long', - }); - } + // Password validation is now handled by validators middleware + // No need for manual validation here // Find user by reset token const user = await prisma.user.findUnique({ diff --git a/backend/src/middleware/rateLimiter.js b/backend/src/middleware/rateLimiter.js new file mode 100644 index 0000000..70c79ad --- /dev/null +++ b/backend/src/middleware/rateLimiter.js @@ -0,0 +1,58 @@ +/** + * Rate Limiting Middleware + * Protects against brute force and DoS attacks + */ + +const rateLimit = require('express-rate-limit'); +const securityConfig = require('../config/security'); + +// Create rate limiters based on configuration + +// General API rate limiter +const apiLimiter = rateLimit({ + windowMs: securityConfig.rateLimit.api.windowMs, + max: securityConfig.rateLimit.api.max, + message: { + success: false, + error: 'Too Many Requests', + message: 'Too many requests from this IP, please try again later.', + }, + standardHeaders: true, + legacyHeaders: false, + skip: () => !securityConfig.rateLimit.enabled, +}); + +// Strict limiter for authentication endpoints (login, register) +const authLimiter = rateLimit({ + windowMs: securityConfig.rateLimit.auth.windowMs, + max: securityConfig.rateLimit.auth.max, + skipSuccessfulRequests: securityConfig.rateLimit.auth.skipSuccessfulRequests, + message: { + success: false, + error: 'Too Many Login Attempts', + message: 'Too many authentication attempts from this IP, please try again in 15 minutes.', + }, + standardHeaders: true, + legacyHeaders: false, + skip: () => !securityConfig.rateLimit.enabled, +}); + +// Email limiter (verification, password reset) +const emailLimiter = rateLimit({ + windowMs: securityConfig.rateLimit.email.windowMs, + max: securityConfig.rateLimit.email.max, + message: { + success: false, + error: 'Too Many Email Requests', + message: 'Too many email requests from this IP, please try again later.', + }, + standardHeaders: true, + legacyHeaders: false, + skip: () => !securityConfig.rateLimit.enabled, +}); + +module.exports = { + apiLimiter, + authLimiter, + emailLimiter, +}; diff --git a/backend/src/middleware/validators.js b/backend/src/middleware/validators.js index 660db92..ffde79f 100644 --- a/backend/src/middleware/validators.js +++ b/backend/src/middleware/validators.js @@ -1,4 +1,5 @@ const { body, validationResult } = require('express-validator'); +const securityConfig = require('../config/security'); // Validation error handler function handleValidationErrors(req, res, next) { @@ -13,6 +14,33 @@ function handleValidationErrors(req, res, next) { next(); } +// Password validation builder (environment-aware) +function buildPasswordValidation(field = 'password') { + const { minLength, requireUppercase, requireLowercase, requireNumber, requireSpecial } = securityConfig.password; + + let validator = body(field) + .isLength({ min: minLength, max: 128 }) + .withMessage(`Password must be between ${minLength} and 128 characters`); + + if (requireUppercase || requireLowercase || requireNumber) { + let pattern = '^'; + if (requireUppercase) pattern += '(?=.*[A-Z])'; + if (requireLowercase) pattern += '(?=.*[a-z])'; + if (requireNumber) pattern += '(?=.*\\d)'; + if (requireSpecial) pattern += '(?=.*[@$!%*?&#])'; + + validator = validator.matches(new RegExp(pattern)) + .withMessage('Password must contain ' + [ + requireUppercase && 'uppercase letter', + requireLowercase && 'lowercase letter', + requireNumber && 'number', + requireSpecial && 'special character', + ].filter(Boolean).join(', ')); + } + + return validator; +} + // Register validation rules const registerValidation = [ body('username') @@ -26,9 +54,26 @@ const registerValidation = [ .isEmail() .withMessage('Must be a valid email address') .normalizeEmail(), - body('password') - .isLength({ min: 6 }) - .withMessage('Password must be at least 6 characters long'), + buildPasswordValidation('password'), + body('firstName') + .optional() + .trim() + .isLength({ max: 100 }) + .withMessage('First name must be less than 100 characters') + .matches(/^[a-zA-ZΓ€-ΓΏ\s'-]+$/) + .withMessage('First name contains invalid characters'), + body('lastName') + .optional() + .trim() + .isLength({ max: 100 }) + .withMessage('Last name must be less than 100 characters') + .matches(/^[a-zA-ZΓ€-ΓΏ\s'-]+$/) + .withMessage('Last name contains invalid characters'), + body('wsdcId') + .optional() + .trim() + .matches(/^\d{1,10}$/) + .withMessage('WSDC ID must be numeric (max 10 digits)'), handleValidationErrors, ]; @@ -45,8 +90,33 @@ const loginValidation = [ handleValidationErrors, ]; +// Verify code validation +const verifyCodeValidation = [ + body('email') + .trim() + .isEmail() + .withMessage('Must be a valid email address') + .normalizeEmail(), + body('code') + .trim() + .matches(/^\d{6}$/) + .withMessage('Code must be 6 digits'), + handleValidationErrors, +]; + +// Password reset validation +const passwordResetValidation = [ + body('token') + .notEmpty() + .withMessage('Reset token is required'), + buildPasswordValidation('newPassword'), + handleValidationErrors, +]; + module.exports = { registerValidation, loginValidation, + verifyCodeValidation, + passwordResetValidation, handleValidationErrors, }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index d20da36..a524a49 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -8,29 +8,35 @@ const { requestPasswordReset, resetPassword } = require('../controllers/auth'); -const { registerValidation, loginValidation } = require('../middleware/validators'); +const { + registerValidation, + loginValidation, + verifyCodeValidation, + passwordResetValidation +} = require('../middleware/validators'); +const { authLimiter, emailLimiter } = require('../middleware/rateLimiter'); const router = express.Router(); // POST /api/auth/register - Register new user -router.post('/register', registerValidation, register); +router.post('/register', authLimiter, registerValidation, register); // POST /api/auth/login - Login user -router.post('/login', loginValidation, login); +router.post('/login', authLimiter, loginValidation, login); // GET /api/auth/verify-email?token=xxx - Verify email by token (link) router.get('/verify-email', verifyEmailByToken); // POST /api/auth/verify-code - Verify email by code (PIN) -router.post('/verify-code', verifyEmailByCode); +router.post('/verify-code', verifyCodeValidation, verifyEmailByCode); // POST /api/auth/resend-verification - Resend verification email -router.post('/resend-verification', resendVerification); +router.post('/resend-verification', emailLimiter, resendVerification); // POST /api/auth/request-password-reset - Request password reset -router.post('/request-password-reset', requestPasswordReset); +router.post('/request-password-reset', emailLimiter, requestPasswordReset); // POST /api/auth/reset-password - Reset password with token -router.post('/reset-password', resetPassword); +router.post('/reset-password', passwordResetValidation, resetPassword); module.exports = router; diff --git a/backend/src/utils/auth.js b/backend/src/utils/auth.js index 2381521..c2a387d 100644 --- a/backend/src/utils/auth.js +++ b/backend/src/utils/auth.js @@ -34,9 +34,13 @@ function generateVerificationToken() { return crypto.randomBytes(32).toString('hex'); } -// Generate 6-digit verification code +// Generate 6-digit verification code (cryptographically secure) function generateVerificationCode() { - return Math.floor(100000 + Math.random() * 900000).toString(); + // Use crypto.randomBytes for cryptographically secure random numbers + const bytes = crypto.randomBytes(4); + const num = bytes.readUInt32BE(0); + // Ensure 6 digits (100000 to 999999) + return String(num % 900000 + 100000); } // Calculate token expiry time diff --git a/backend/src/utils/sanitize.js b/backend/src/utils/sanitize.js new file mode 100644 index 0000000..2a4b99b --- /dev/null +++ b/backend/src/utils/sanitize.js @@ -0,0 +1,80 @@ +/** + * Input Sanitization Utilities + * Prevents XSS and injection attacks + */ + +const createDOMPurify = require('dompurify'); +const { JSDOM } = require('jsdom'); + +const window = new JSDOM('').window; +const DOMPurify = createDOMPurify(window); + +/** + * Sanitize HTML input to prevent XSS + * @param {string} dirty - Untrusted HTML string + * @returns {string} - Sanitized string + */ +function sanitizeHtml(dirty) { + if (typeof dirty !== 'string') return ''; + + return DOMPurify.sanitize(dirty, { + ALLOWED_TAGS: [], // Strip all HTML tags + ALLOWED_ATTR: [], + }); +} + +/** + * Sanitize text for use in emails + * @param {string} text - User input text + * @returns {string} - Sanitized text + */ +function sanitizeForEmail(text) { + if (typeof text !== 'string') return ''; + + // Remove HTML tags and encode special characters + return DOMPurify.sanitize(text, { + ALLOWED_TAGS: [], + ALLOWED_ATTR: [], + }).trim(); +} + +/** + * Sanitize username (alphanumeric + underscore only) + * @param {string} username - Username input + * @returns {string} - Sanitized username + */ +function sanitizeUsername(username) { + if (typeof username !== 'string') return ''; + + return username.replace(/[^a-zA-Z0-9_]/g, '').trim(); +} + +/** + * Timing-safe string comparison + * Prevents timing attacks on token comparison + * @param {string} a - First string + * @param {string} b - Second string + * @returns {boolean} - True if strings match + */ +function timingSafeEqual(a, b) { + const crypto = require('crypto'); + + if (typeof a !== 'string' || typeof b !== 'string') return false; + if (a.length !== b.length) return false; + + try { + return crypto.timingSafeEqual( + Buffer.from(a, 'utf8'), + Buffer.from(b, 'utf8') + ); + } catch (err) { + return false; + } +} + +module.exports = { + sanitizeHtml, + sanitizeForEmail, + sanitizeUsername, + timingSafeEqual, +}; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..ecb2325 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,39 @@ +# Development environment overrides +# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up + +services: + nginx: + ports: + - "8080:80" + restart: unless-stopped + + frontend: + environment: + - NODE_ENV=development + - VITE_HOST=0.0.0.0 + volumes: + - ./frontend:/app + - /app/node_modules + command: npm run dev + stdin_open: true + tty: true + + backend: + environment: + - NODE_ENV=development + # Security: Relaxed for development + - RATE_LIMIT_ENABLED=false + - RATE_LIMIT_AUTH_MAX=100 + - RATE_LIMIT_EMAIL_MAX=20 + - ENABLE_CSRF=false + - BODY_SIZE_LIMIT=50mb + - LOG_LEVEL=debug + volumes: + - ./backend:/app + - /app/node_modules + command: npm run dev + + db: + ports: + - "5432:5432" # Expose for local tools (pgAdmin, etc.) + restart: unless-stopped diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..39b2b23 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,100 @@ +# Production environment configuration +# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d + +services: + nginx: + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./ssl:/etc/nginx/ssl:ro # SSL certificates + restart: always + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + args: + - NODE_ENV=production + environment: + - NODE_ENV=production + volumes: [] # No volumes in production (baked into image) + command: ["nginx", "-g", "daemon off;"] + restart: always + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + backend: + build: + context: ./backend + dockerfile: Dockerfile.prod + args: + - NODE_ENV=production + environment: + - NODE_ENV=production + # Security: Strict for production + - RATE_LIMIT_ENABLED=true + - RATE_LIMIT_AUTH_MAX=5 + - RATE_LIMIT_EMAIL_MAX=3 + - ENABLE_CSRF=true + - BODY_SIZE_LIMIT=10kb + - LOG_LEVEL=warn + # Secrets should come from environment or secrets manager + # Do not hardcode in docker-compose.prod.yml + volumes: [] # No volumes in production + command: ["node", "src/server.js"] + restart: always + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + cpus: '1' + memory: 512M + reservations: + cpus: '0.5' + memory: 256M + + db: + # In production, consider using managed database (AWS RDS, etc.) + # This is for self-hosted production + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backups:/backups # For database backups + # Don't expose port in production (only internal) + # ports: [] + restart: always + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + cpus: '2' + memory: 2G + reservations: + cpus: '1' + memory: 1G + +volumes: + postgres_data: + driver: local diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..f680454 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,425 @@ +# Deployment Guide - spotlight.cam + +## Development Setup + +### Prerequisites +- Docker & Docker Compose +- Node.js 20+ +- PostgreSQL 15 (via Docker) + +### Quick Start (Development) + +1. **Clone repository** +```bash +git clone +cd spotlightcam +``` + +2. **Create environment file** +```bash +cp backend/.env.example backend/.env +# Edit backend/.env with your values +``` + +3. **Start development environment** +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d +``` + +4. **Run database migrations** +```bash +docker compose exec backend npx prisma migrate deploy +``` + +5. **Access the application** +- Frontend: http://localhost:8080 +- Backend API: http://localhost:8080/api +- Database: localhost:5432 + +### Development Features +- Hot reload for frontend and backend +- Relaxed rate limiting +- Detailed error messages +- Debug logging +- Exposed database port for tools (pgAdmin, DBeaver) + +--- + +## Production Deployment + +### Prerequisites +- Docker & Docker Compose +- SSL certificates +- Production database (AWS RDS, managed PostgreSQL, or self-hosted) +- AWS SES configured and in production mode +- Domain name with DNS configured + +### Production Setup + +1. **Create production environment file** +```bash +cp backend/.env.production.example backend/.env.production +``` + +2. **Generate strong secrets** +```bash +# Generate JWT secret +openssl rand -base64 64 + +# Generate strong database password +openssl rand -base64 32 +``` + +3. **Configure environment variables** +Edit `backend/.env.production`: +- Set `NODE_ENV=production` +- Set strong `JWT_SECRET` +- Configure production `DATABASE_URL` +- Add AWS SES credentials +- Set production `CORS_ORIGIN` + +4. **Build production images** +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml build +``` + +5. **Start production services** +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +``` + +6. **Run migrations** +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend npx prisma migrate deploy +``` + +--- + +## Environment Configuration + +### Development vs Production + +| Feature | Development | Production | +|---------|-------------|------------| +| Rate Limiting | Disabled/Relaxed | Strict (5 login attempts) | +| CSRF Protection | Disabled | Enabled | +| Body Size Limit | 50MB | 10KB | +| Error Details | Full stack traces | Generic messages | +| Logging | Debug level | Warn/Error level | +| CORS | Localhost only | Specific domains | +| Password Policy | Relaxed (8 chars) | Strict (8 chars + complexity) | + +### Environment Variables + +**Critical Security Variables:** +```bash +# Must be changed in production! +JWT_SECRET=<64-char-random-string> +DATABASE_URL=postgresql://user:STRONG_PASSWORD@host:5432/dbname + +# AWS credentials - use IAM roles in production +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +``` + +**Security Settings:** +```bash +# Production values +RATE_LIMIT_ENABLED=true +RATE_LIMIT_AUTH_MAX=5 +RATE_LIMIT_EMAIL_MAX=3 +ENABLE_CSRF=true +BODY_SIZE_LIMIT=10kb + +# Development values +RATE_LIMIT_ENABLED=false +RATE_LIMIT_AUTH_MAX=100 +ENABLE_CSRF=false +BODY_SIZE_LIMIT=50mb +``` + +--- + +## SSL/HTTPS Configuration + +### Development (HTTP) +No SSL required - runs on http://localhost:8080 + +### Production (HTTPS) + +1. **Obtain SSL certificates** +```bash +# Using Let's Encrypt (certbot) +certbot certonly --standalone -d spotlight.cam -d www.spotlight.cam +``` + +2. **Configure nginx** +Update `nginx/conf.d/default.conf`: +```nginx +server { + listen 443 ssl http2; + server_name spotlight.cam www.spotlight.cam; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + # SSL configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; +} + +# Redirect HTTP to HTTPS +server { + listen 80; + server_name spotlight.cam www.spotlight.cam; + return 301 https://$server_name$request_uri; +} +``` + +3. **Mount SSL certificates in docker-compose.prod.yml** +Already configured to mount `./ssl:/etc/nginx/ssl:ro` + +--- + +## Database Management + +### Backups + +**Automated backup script:** +```bash +#!/bin/bash +# scripts/backup-db.sh + +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR="./backups" +DB_CONTAINER="spotlightcam-db" + +docker exec $DB_CONTAINER pg_dump -U spotlightcam spotlightcam > "$BACKUP_DIR/backup_$DATE.sql" + +# Keep only last 7 days +find $BACKUP_DIR -name "backup_*.sql" -mtime +7 -delete +``` + +**Setup cron job:** +```bash +# Daily backup at 2 AM +0 2 * * * /path/to/spotlightcam/scripts/backup-db.sh +``` + +### Restore from backup + +```bash +cat backups/backup_YYYYMMDD_HHMMSS.sql | docker exec -i spotlightcam-db psql -U spotlightcam spotlightcam +``` + +--- + +## Monitoring & Logging + +### View logs + +```bash +# All services +docker compose logs -f + +# Specific service +docker compose logs -f backend +docker compose logs -f nginx + +# Last 100 lines +docker compose logs --tail 100 backend +``` + +### Production log management + +Logs are configured with rotation: +- Max size: 10MB per file +- Max files: 3 +- Located in Docker's logging directory + +**View logs:** +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml logs --tail 100 -f +``` + +--- + +## Security Checklist + +### Before Going to Production + +- [ ] Generate strong JWT secret (64+ characters) +- [ ] Use strong database password (20+ characters) +- [ ] Configure AWS SES in production mode (not sandbox) +- [ ] Enable rate limiting (`RATE_LIMIT_ENABLED=true`) +- [ ] Enable CSRF protection (`ENABLE_CSRF=true`) +- [ ] Set strict CORS origins (no wildcards) +- [ ] Configure HTTPS with valid SSL certificates +- [ ] Set `NODE_ENV=production` +- [ ] Review and rotate all secrets +- [ ] Enable account lockout (`ENABLE_ACCOUNT_LOCKOUT=true`) +- [ ] Set strict password policy +- [ ] Configure firewall (allow only 80, 443, 22) +- [ ] Set up automated backups +- [ ] Configure monitoring/alerting +- [ ] Review security audit report (`docs/SECURITY_AUDIT.md`) + +### After Deployment + +- [ ] Test all authentication flows +- [ ] Verify email sending works +- [ ] Check rate limiting is active +- [ ] Verify HTTPS is working +- [ ] Test WSDC integration +- [ ] Monitor error logs +- [ ] Set up uptime monitoring +- [ ] Configure alerts for failures + +--- + +## Troubleshooting + +### Backend won't start + +**Check logs:** +```bash +docker compose logs backend +``` + +**Common issues:** +- Missing environment variables +- Database connection failed +- Port already in use +- Missing npm packages + +### Database connection failed + +**Check database is running:** +```bash +docker compose ps db +``` + +**Test connection:** +```bash +docker compose exec backend npx prisma db push +``` + +### Emails not sending + +**Check AWS SES configuration:** +- Verify AWS credentials are correct +- Check SES is in production mode (not sandbox) +- Verify sender email is verified in SES +- Check CloudWatch logs for SES errors + +### Rate limiting too strict + +**Temporary disable (development only):** +```bash +# In .env +RATE_LIMIT_ENABLED=false +``` + +**Adjust limits:** +```bash +# In .env +RATE_LIMIT_AUTH_MAX=10 # Allow 10 attempts instead of 5 +``` + +--- + +## Scaling Considerations + +### Horizontal Scaling + +For high traffic, consider: +1. Load balancer (nginx, HAProxy) +2. Multiple backend containers +3. Redis for session/rate limit storage +4. Managed database (AWS RDS, DigitalOcean) +5. CDN for static assets + +### Performance Optimization + +- Enable gzip compression in nginx +- Add Redis for caching +- Use connection pooling for database +- Implement database read replicas +- Use CDN for avatar images + +--- + +## Maintenance + +### Update dependencies + +```bash +# Backend +docker compose exec backend npm update +docker compose exec backend npm audit fix + +# Frontend +docker compose exec frontend npm update +docker compose exec frontend npm audit fix +``` + +### Rotate secrets + +```bash +# Generate new JWT secret +openssl rand -base64 64 + +# Update .env.production +# Restart services +docker compose -f docker-compose.yml -f docker-compose.prod.yml restart backend +``` + +### Database migrations + +```bash +# Create migration +docker compose exec backend npx prisma migrate dev --name description + +# Apply to production +docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend npx prisma migrate deploy +``` + +--- + +## Quick Commands + +```bash +# Start development +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d + +# Start production +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d + +# Stop all +docker compose down + +# View logs +docker compose logs -f backend + +# Shell into container +docker compose exec backend sh + +# Run migrations +docker compose exec backend npx prisma migrate deploy + +# Backup database +docker exec spotlightcam-db pg_dump -U spotlightcam spotlightcam > backup.sql +``` + +--- + +## Support + +For issues: +1. Check logs: `docker compose logs` +2. Review security audit: `docs/SECURITY_AUDIT.md` +3. Check session context: `docs/SESSION_CONTEXT.md` +4. Review phase documentation: `docs/PHASE_*.md` + +**Last Updated:** 2025-11-13 diff --git a/docs/SECURITY_AUDIT.md b/docs/SECURITY_AUDIT.md new file mode 100644 index 0000000..82d9c9c --- /dev/null +++ b/docs/SECURITY_AUDIT.md @@ -0,0 +1,740 @@ +# Security Audit Report - spotlight.cam Backend + +**Date:** 2025-11-13 +**Auditor:** Security Review +**Scope:** Backend API (Node.js/Express) +**Framework:** OWASP Top 10 2021 + +--- + +## Executive Summary + +This security audit identified **21 security issues** across 4 severity levels: +- πŸ”΄ **CRITICAL (P0):** 5 issues - Immediate action required +- 🟠 **HIGH (P1):** 6 issues - Fix within 1 week +- 🟑 **MEDIUM (P2):** 7 issues - Fix within 2-4 weeks +- πŸ”΅ **LOW (P3):** 3 issues - Fix when convenient + +**Overall Security Rating:** ⚠️ **MODERATE RISK** + +--- + +## πŸ”΄ CRITICAL Issues (P0) - FIX IMMEDIATELY + +### 1. Secrets Exposed in `.env` File (CWE-798) + +**Severity:** πŸ”΄ CRITICAL +**OWASP:** A02:2021 - Cryptographic Failures +**File:** `backend/.env` + +**Issue:** +```bash +JWT_SECRET=dev-secret-key-12345-change-in-production +AWS_ACCESS_KEY_ID=your-aws-access-key-id +AWS_SECRET_ACCESS_KEY=your-aws-secret-access-key +DATABASE_URL=postgresql://spotlightcam:spotlightcam123@db:5432/spotlightcam +``` + +**Vulnerabilities:** +- Weak JWT secret key +- Default placeholder AWS credentials +- Database password in plain text +- `.env` file may be committed to git + +**Impact:** +- Attacker can forge JWT tokens +- Potential unauthorized access to AWS services +- Database compromise + +**Recommendation:** +```bash +# Use strong random secrets (at least 32 characters) +JWT_SECRET=$(openssl rand -base64 32) + +# Use AWS IAM roles instead of access keys (in production) +# Or use AWS Secrets Manager + +# Use environment variables in production (Kubernetes secrets, Docker secrets, etc.) +# Never commit .env to git - add to .gitignore +``` + +**Action:** +1. Generate strong JWT secret: `openssl rand -base64 64` +2. Use AWS IAM roles or Secrets Manager +3. Verify `.env` is in `.gitignore` +4. Rotate all secrets immediately if `.env` was committed + +--- + +### 2. Insecure Random Number Generation (CWE-338) + +**Severity:** πŸ”΄ CRITICAL +**OWASP:** A02:2021 - Cryptographic Failures +**File:** `backend/src/utils/auth.js:38` + +**Issue:** +```javascript +function generateVerificationCode() { + return Math.floor(100000 + Math.random() * 900000).toString(); +} +``` + +**Vulnerabilities:** +- `Math.random()` is NOT cryptographically secure +- Predictable verification codes +- Can be brute-forced + +**Impact:** +- Attacker can predict verification codes +- Account takeover possible + +**Recommendation:** +```javascript +const crypto = require('crypto'); + +function generateVerificationCode() { + // Cryptographically secure random 6-digit code + const bytes = crypto.randomBytes(4); + const num = bytes.readUInt32BE(0); + return String(num % 900000 + 100000); +} +``` + +--- + +### 3. No Rate Limiting - Brute Force Vulnerability (CWE-307) + +**Severity:** πŸ”΄ CRITICAL +**OWASP:** A07:2021 - Identification and Authentication Failures +**File:** `backend/src/app.js` + +**Issue:** +No rate limiting on any endpoints. + +**Vulnerabilities:** +- Login brute force attacks +- Password reset abuse +- Verification code brute force (6 digits = 1M combinations) +- Email bombing via resend verification + +**Impact:** +- Account takeover +- Service disruption (DoS) +- Email service abuse + +**Recommendation:** +```bash +npm install express-rate-limit +``` + +```javascript +// backend/src/app.js +const rateLimit = require('express-rate-limit'); + +// General API rate limiter +const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs + message: 'Too many requests from this IP, please try again later.', + standardHeaders: true, + legacyHeaders: false, +}); + +// Strict limiter for auth endpoints +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // 5 attempts + skipSuccessfulRequests: true, + message: 'Too many login attempts, please try again later.', +}); + +// Email limiter +const emailLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, // 3 emails per hour + message: 'Too many emails sent, please try again later.', +}); + +app.use('/api/', apiLimiter); +app.use('/api/auth/login', authLimiter); +app.use('/api/auth/register', authLimiter); +app.use('/api/auth/resend-verification', emailLimiter); +app.use('/api/auth/request-password-reset', emailLimiter); +``` + +--- + +### 4. No Request Body Size Limit - DoS Vulnerability (CWE-400) + +**Severity:** πŸ”΄ CRITICAL +**OWASP:** A05:2021 - Security Misconfiguration +**File:** `backend/src/app.js:11` + +**Issue:** +```javascript +app.use(express.json()); // No limit +app.use(express.urlencoded({ extended: true })); // No limit +``` + +**Vulnerabilities:** +- Attacker can send huge JSON payloads +- Memory exhaustion (DoS) + +**Impact:** +- Server crash +- Service unavailability + +**Recommendation:** +```javascript +app.use(express.json({ limit: '10kb' })); +app.use(express.urlencoded({ extended: true, limit: '10kb' })); +``` + +--- + +### 5. User Enumeration Vulnerability (CWE-204) + +**Severity:** πŸ”΄ CRITICAL +**OWASP:** A01:2021 - Broken Access Control +**File:** `backend/src/controllers/auth.js:28-46` + +**Issue:** +```javascript +if (existingUser.email === email) { + return res.status(400).json({ + error: 'Email already registered', + }); +} +if (existingUser.username === username) { + return res.status(400).json({ + error: 'Username already taken', + }); +} +``` + +**Vulnerabilities:** +- Reveals which emails/usernames are registered +- Enables targeted attacks + +**Impact:** +- Email/username enumeration +- Privacy breach +- Targeted phishing attacks + +**Recommendation:** +```javascript +// Don't reveal which field exists +if (existingUser) { + return res.status(400).json({ + success: false, + error: 'An account with these credentials already exists', + }); +} + +// Or implement verification via email before confirming registration +``` + +--- + +## 🟠 HIGH Issues (P1) - Fix Within 1 Week + +### 6. Weak Password Policy (CWE-521) + +**Severity:** 🟠 HIGH +**File:** `backend/src/middleware/validators.js:30` + +**Issue:** +```javascript +body('password') + .isLength({ min: 6 }) // Too weak! +``` + +And in `auth.js:441`: +```javascript +if (newPassword.length < 8) // Inconsistent +``` + +**Vulnerabilities:** +- 6 characters is too weak (should be 8+) +- No complexity requirements +- Inconsistent validation (6 vs 8) + +**Recommendation:** +```javascript +body('password') + .isLength({ min: 8, max: 128 }) + .withMessage('Password must be between 8 and 128 characters') + .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) + .withMessage('Password must contain at least one uppercase, one lowercase, and one number'), +``` + +--- + +### 7. Missing Security Headers (CWE-693) + +**Severity:** 🟠 HIGH +**OWASP:** A05:2021 - Security Misconfiguration +**File:** `backend/src/app.js` + +**Issue:** +No security headers (CSP, HSTS, X-Frame-Options, etc.) + +**Recommendation:** +```bash +npm install helmet +``` + +```javascript +const helmet = require('helmet'); + +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:"], + }, + }, + hsts: { + maxAge: 31536000, + includeSubDomains: true, + preload: true, + }, +})); +``` + +--- + +### 8. No Account Lockout After Failed Logins (CWE-307) + +**Severity:** 🟠 HIGH +**File:** `backend/src/controllers/auth.js:120` + +**Issue:** +No account lockout mechanism after multiple failed login attempts. + +**Recommendation:** +Add to database schema: +```prisma +model User { + failedLoginAttempts Int @default(0) + lockedUntil DateTime? +} +``` + +Implement lockout logic: +```javascript +// In login controller +if (user.lockedUntil && user.lockedUntil > new Date()) { + return res.status(423).json({ + error: 'Account temporarily locked due to too many failed login attempts', + lockedUntil: user.lockedUntil, + }); +} + +if (!isPasswordValid) { + await prisma.user.update({ + where: { id: user.id }, + data: { + failedLoginAttempts: { increment: 1 }, + ...(user.failedLoginAttempts + 1 >= 5 && { + lockedUntil: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes + }), + }, + }); + return res.status(401).json({ error: 'Invalid credentials' }); +} + +// Reset on successful login +await prisma.user.update({ + where: { id: user.id }, + data: { failedLoginAttempts: 0, lockedUntil: null }, +}); +``` + +--- + +### 9. Missing Input Validation for WSDC Data (CWE-20) + +**Severity:** 🟠 HIGH +**File:** `backend/src/controllers/auth.js:15` + +**Issue:** +No validation for `firstName`, `lastName`, `wsdcId` in register. + +**Recommendation:** +```javascript +// In validators.js +body('firstName') + .optional() + .trim() + .isLength({ max: 100 }) + .matches(/^[a-zA-Z\s'-]+$/) + .withMessage('Invalid first name'), +body('lastName') + .optional() + .trim() + .isLength({ max: 100 }) + .matches(/^[a-zA-Z\s'-]+$/) + .withMessage('Invalid last name'), +body('wsdcId') + .optional() + .trim() + .matches(/^\d{1,10}$/) + .withMessage('WSDC ID must be numeric (max 10 digits)'), +``` + +--- + +### 10. No Email Input Sanitization - XSS Risk (CWE-79) + +**Severity:** 🟠 HIGH +**OWASP:** A03:2021 - Injection +**File:** `backend/src/utils/email.js:98` + +**Issue:** +```javascript +

Hi ${firstName || 'there'}! πŸ‘‹

+``` + +If `firstName` contains ``, it's injected into email. + +**Recommendation:** +```bash +npm install dompurify jsdom +``` + +```javascript +const createDOMPurify = require('dompurify'); +const { JSDOM } = require('jsdom'); +const window = new JSDOM('').window; +const DOMPurify = createDOMPurify(window); + +// Sanitize before using in email +const sanitizedFirstName = DOMPurify.sanitize(firstName || 'there', { + ALLOWED_TAGS: [], +}); +``` + +--- + +### 11. Missing CSRF Protection (CWE-352) + +**Severity:** 🟠 HIGH +**OWASP:** A01:2021 - Broken Access Control +**File:** `backend/src/app.js` + +**Issue:** +No CSRF token validation for state-changing operations. + +**Recommendation:** +```bash +npm install csurf cookie-parser +``` + +```javascript +const csrf = require('csurf'); +const cookieParser = require('cookie-parser'); + +app.use(cookieParser()); +app.use(csrf({ cookie: true })); + +// Add endpoint to get CSRF token +app.get('/api/csrf-token', (req, res) => { + res.json({ csrfToken: req.csrfToken() }); +}); + +// Frontend must include CSRF token in requests +// Headers: { 'X-CSRF-Token': token } +``` + +--- + +## 🟑 MEDIUM Issues (P2) - Fix Within 2-4 Weeks + +### 12. Permissive CORS Configuration (CWE-346) + +**Severity:** 🟑 MEDIUM +**File:** `backend/src/app.js:7` + +**Issue:** +```javascript +app.use(cors({ + origin: process.env.CORS_ORIGIN || 'http://localhost:8080', + credentials: true +})); +``` + +In production, this could be misconfigured. + +**Recommendation:** +```javascript +const allowedOrigins = [ + 'https://spotlight.cam', + 'https://www.spotlight.cam', + ...(process.env.NODE_ENV === 'development' ? ['http://localhost:8080'] : []), +]; + +app.use(cors({ + origin: (origin, callback) => { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, + maxAge: 86400, // 24 hours +})); +``` + +--- + +### 13. Error Information Disclosure (CWE-209) + +**Severity:** 🟑 MEDIUM +**File:** `backend/src/app.js:47` + +**Issue:** +```javascript +res.status(err.status || 500).json({ + error: err.message || 'Internal Server Error', + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) +}); +``` + +Stack traces leak in development if `NODE_ENV` not set in production. + +**Recommendation:** +```javascript +app.use((err, req, res, next) => { + console.error('Error:', err); + + // Log full error internally + logger.error(err); + + // Return generic error to client in production + const isDevelopment = process.env.NODE_ENV === 'development'; + + res.status(err.status || 500).json({ + error: isDevelopment ? err.message : 'Internal Server Error', + ...(isDevelopment && { stack: err.stack }), + }); +}); +``` + +--- + +### 14. No Validation on Email Verification Endpoints + +**Severity:** 🟑 MEDIUM +**File:** `backend/src/controllers/auth.js:233` + +**Issue:** +No input validation for `verifyEmailByCode`. + +**Recommendation:** +Add validation: +```javascript +body('email').trim().isEmail().normalizeEmail(), +body('code').trim().matches(/^\d{6}$/).withMessage('Code must be 6 digits'), +``` + +--- + +### 15. No Logging for Security Events (CWE-778) + +**Severity:** 🟑 MEDIUM +**File:** All controllers + +**Issue:** +Only `console.log()` used. No structured logging. + +**Recommendation:** +```bash +npm install winston +``` + +```javascript +const winston = require('winston'); + +const logger = winston.createLogger({ + level: 'info', + format: winston.format.json(), + transports: [ + new winston.transports.File({ filename: 'error.log', level: 'error' }), + new winston.transports.File({ filename: 'security.log', level: 'warn' }), + new winston.transports.File({ filename: 'combined.log' }), + ], +}); + +// Log security events +logger.warn('Failed login attempt', { email, ip: req.ip }); +logger.warn('Account locked', { userId, ip: req.ip }); +``` + +--- + +### 16. Missing JWT Token Blacklist/Revocation (CWE-613) + +**Severity:** 🟑 MEDIUM +**File:** `backend/src/utils/auth.js` + +**Issue:** +No way to revoke JWT tokens before expiry. + +**Recommendation:** +Implement token blacklist using Redis: +```javascript +// When user logs out or changes password +await redis.setex(`blacklist:${token}`, 86400, '1'); + +// In auth middleware +const isBlacklisted = await redis.get(`blacklist:${token}`); +if (isBlacklisted) { + return res.status(401).json({ error: 'Token revoked' }); +} +``` + +--- + +### 17. No Password History Check (CWE-521) + +**Severity:** 🟑 MEDIUM +**File:** `backend/src/controllers/auth.js:429` + +**Issue:** +Users can reuse old passwords. + +**Recommendation:** +Add password history tracking (prevent reuse of last 5 passwords). + +--- + +### 18. Timing Attack Vulnerability in Token Comparison (CWE-208) + +**Severity:** 🟑 MEDIUM +**File:** `backend/src/controllers/auth.js:178` + +**Issue:** +String comparison for tokens may leak timing information. + +**Recommendation:** +```javascript +const crypto = require('crypto'); + +function timingSafeEqual(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') return false; + if (a.length !== b.length) return false; + + return crypto.timingSafeEqual( + Buffer.from(a, 'utf8'), + Buffer.from(b, 'utf8') + ); +} + +// Use in comparisons +if (!timingSafeEqual(user.verificationToken, token)) { + return res.status(404).json({ error: 'Invalid token' }); +} +``` + +--- + +## πŸ”΅ LOW Issues (P3) - Fix When Convenient + +### 19. No Security Monitoring/Alerts + +**Severity:** πŸ”΅ LOW + +**Recommendation:** +Implement monitoring for: +- Multiple failed login attempts +- Password reset requests +- Account lockouts +- Unusual API usage patterns + +--- + +### 20. Missing HTTP Strict Transport Security (HSTS) + +**Severity:** πŸ”΅ LOW + +**Recommendation:** +Add via helmet (see issue #7). + +--- + +### 21. No Password Strength Meter Feedback + +**Severity:** πŸ”΅ LOW + +**Recommendation:** +Implement server-side password strength validation: +```bash +npm install zxcvbn +``` + +```javascript +const zxcvbn = require('zxcvbn'); + +const result = zxcvbn(password); +if (result.score < 3) { + return res.status(400).json({ + error: 'Password too weak', + suggestions: result.feedback.suggestions, + }); +} +``` + +--- + +## Security Best Practices Checklist + +- [ ] Use environment-specific secrets +- [ ] Implement rate limiting on all endpoints +- [ ] Use helmet.js for security headers +- [ ] Enable CSRF protection +- [ ] Add request body size limits +- [ ] Implement account lockout mechanism +- [ ] Use cryptographically secure random generation +- [ ] Add input validation for all endpoints +- [ ] Sanitize user inputs (XSS prevention) +- [ ] Implement structured logging +- [ ] Add security monitoring and alerts +- [ ] Use HTTPS in production +- [ ] Implement JWT token revocation +- [ ] Add password strength requirements +- [ ] Use timing-safe comparisons for sensitive data +- [ ] Prevent user enumeration +- [ ] Implement proper CORS configuration +- [ ] Regular security audits +- [ ] Dependency vulnerability scanning (`npm audit`) +- [ ] Keep dependencies up to date + +--- + +## Immediate Action Items (This Week) + +1. **Generate and rotate all secrets** +2. **Install and configure rate limiting** (`express-rate-limit`) +3. **Install and configure helmet** (`helmet`) +4. **Fix cryptographic random generation** in `generateVerificationCode()` +5. **Add request body size limits** +6. **Strengthen password requirements** (8+ chars, complexity) +7. **Fix user enumeration** in registration +8. **Add input validation** for all fields + +--- + +## References + +- OWASP Top 10 2021: https://owasp.org/Top10/ +- Node.js Security Checklist: https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html +- Express Security Best Practices: https://expressjs.com/en/advanced/best-practice-security.html + +--- + +**Report Generated:** 2025-11-13 +**Next Audit Recommended:** 2025-12-13 (or after major changes)