diff --git a/backend/README.md b/backend/README.md index 9f1e88c..6421b65 100644 --- a/backend/README.md +++ b/backend/README.md @@ -10,29 +10,52 @@ npm run backend Server starts at `http://localhost:4000` by default. -## Database +## Security Layer -### Issue #26: Move from in-memory store to persistent DB +### Helmet-style security headers -- Uses a local JSON database file at `backend/data/brocode.json`. -- You can override the location with `BROCODE_DB_PATH=/custom/path.json npm run backend`. -- On first start, seed data is inserted for users, spots, catalog items, and a sample order. -- New orders are validated against DB data (known `spotId`, `userId`, `productId`) and item pricing is always derived from catalog prices in the database. +The server now sends strict security headers on every API response, including: + +- `Content-Security-Policy` +- `Strict-Transport-Security` +- `X-Content-Type-Options` +- `X-Frame-Options` +- `Referrer-Policy` +- `Cross-Origin-*` hardening headers + +Configure CSP via `SECURITY_HEADERS_CSP`. + +### CORS + +CORS is applied to all endpoints with these defaults: + +- Allowed origin from `CORS_ALLOW_ORIGIN` (defaults to `*`) +- Allowed headers: `Content-Type`, `Authorization` +- Allowed methods: `GET,POST,DELETE,OPTIONS` -### Issue #28: Secure credential storage and verification +### Rate limiting -- Passwords are stored as salted `scrypt` hashes (not plaintext). -- Legacy plaintext user passwords are auto-migrated to hashed values on successful login. +Two limits are active: -### Issue #29: Protect login endpoint from brute-force attempts +1. **Global API limiter** per IP (`GLOBAL_RATE_LIMIT_MAX_REQUESTS` in `GLOBAL_RATE_LIMIT_WINDOW_MS`) +2. **Login brute-force limiter** per `IP + username` + (`LOGIN_RATE_LIMIT_MAX_ATTEMPTS` in `LOGIN_RATE_LIMIT_WINDOW_MS`, temporary block for `LOGIN_RATE_LIMIT_BLOCK_MS`) -- Login is now rate-limited per `IP + username` key. -- Defaults: 5 failed attempts within 15 minutes triggers a 15 minute temporary block (`429`). -- Configure via env vars: - - `LOGIN_RATE_LIMIT_MAX_ATTEMPTS` - - `LOGIN_RATE_LIMIT_WINDOW_MS` - - `LOGIN_RATE_LIMIT_BLOCK_MS` +Both return HTTP `429` and `Retry-After` headers. + +### Password hashing + +User credentials are stored as salted hashes (using Node crypto `scrypt`) and never as plaintext. +Legacy plaintext records auto-migrate to hashed values at successful login. + +## Database + +- Uses a local JSON database file at `backend/data/brocode.json`. +- You can override the location with `BROCODE_DB_PATH=/custom/path.json npm run backend`. +- On first start, seed data is inserted for users, spots, catalog items, and a sample order. +- New orders are validated against DB data (known `spotId`, `userId`, `productId`) and item pricing is always derived from catalog prices in the database. +## Deployment (Render / Railway / AWS EC2) ### Issue #31: Redis-backed caching + session/performance primitives - Added optional Redis integration (`REDIS_URL`) with automatic in-memory fallback when Redis is unavailable. @@ -56,18 +79,12 @@ Server starts at `http://localhost:4000` by default. ### Issue #30: Secure backend data access with signed auth tokens + authorization -- Login now returns an HMAC-signed bearer token (replacing predictable demo tokens). -- Tokens include user id, role, and expiry, and are validated with constant-time signature checks. -- Data endpoints now require `Authorization: Bearer ` and enforce role access: - - `GET /api/orders` → users can only read their own orders; admins can read all. - - `POST /api/orders` → users can create only for themselves; admins can create for any user. - - `GET /api/orders/:id` → users can read only their own order; admins can read any order. - - `GET /api/bills/:spotId` and `DELETE /api/users/:userId` → admin only. -- Configure via env vars: - - `AUTH_TOKEN_SECRET` - - `AUTH_TOKEN_TTL_SECONDS` - - `CORS_ALLOW_ORIGIN` +See [`backend/deployment.md`](./deployment.md) for step-by-step deployment options and env setup for: +- Backend hosting: **Render**, **Railway**, **AWS EC2** +- PostgreSQL: **Supabase** or **Neon** +- Redis: **Upstash** +- File storage: **AWS S3** or **Cloudinary** ### Background jobs (BullMQ + Redis) - The backend initializes BullMQ queues for: diff --git a/backend/deployment.md b/backend/deployment.md new file mode 100644 index 0000000..f659d23 --- /dev/null +++ b/backend/deployment.md @@ -0,0 +1,108 @@ +# Deployment guide + +This project can be deployed with the following stack: + +- **Backend**: Render, Railway, or AWS EC2 +- **Database (PostgreSQL)**: Supabase or Neon +- **Redis**: Upstash +- **File storage**: AWS S3 or Cloudinary + +> Current repo runtime still uses a local JSON DB for persistence. `DATABASE_URL`, `REDIS_URL`, and storage variables are wired into env validation so you can safely provide production credentials while evolving integrations. + +## 1) Required environment variables + +Use these common variables in all platforms: + +```bash +PORT=4000 +AUTH_TOKEN_SECRET=replace-with-long-secret +AUTH_TOKEN_TTL_SECONDS=43200 +CORS_ALLOW_ORIGIN=https://your-frontend-domain.com + +# Security/rate-limit tuning +SECURITY_HEADERS_CSP=default-src 'self' +GLOBAL_RATE_LIMIT_MAX_REQUESTS=300 +GLOBAL_RATE_LIMIT_WINDOW_MS=900000 +LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5 +LOGIN_RATE_LIMIT_WINDOW_MS=900000 +LOGIN_RATE_LIMIT_BLOCK_MS=900000 + +# PostgreSQL (Supabase/Neon) +DATABASE_URL=postgresql://... + +# Redis (Upstash) +REDIS_URL=redis://... +UPSTASH_REDIS_REST_URL=https://... +UPSTASH_REDIS_REST_TOKEN=... + +# Storage (choose one) +STORAGE_DRIVER=s3 +AWS_REGION=ap-south-1 +AWS_S3_BUCKET=... +AWS_ACCESS_KEY_ID=... +AWS_SECRET_ACCESS_KEY=... + +# OR +STORAGE_DRIVER=cloudinary +CLOUDINARY_CLOUD_NAME=... +CLOUDINARY_API_KEY=... +CLOUDINARY_API_SECRET=... +``` + +## 2) Render + +1. Create a **Web Service** from this repo. +2. Build command: `npm install && npm run build` +3. Start command: `npm run backend` +4. Add the env vars above in Render dashboard. +5. Add PostgreSQL (Supabase/Neon external) and Upstash connection URLs. + +## 3) Railway + +1. Create a new Railway project linked to this repo. +2. Set start command to `npm run backend`. +3. Add all env vars in the Variables tab. +4. Set custom domain and update `CORS_ALLOW_ORIGIN`. + +## 4) AWS EC2 + +1. Provision Ubuntu instance and install Node.js LTS. +2. Clone repo and run `npm install`. +3. Configure env vars in systemd service file. +4. Run service with `npm run backend` via systemd. +5. Put Nginx in front with HTTPS (Let's Encrypt). + +Example service snippet: + +```ini +[Service] +WorkingDirectory=/srv/Brocode-Party-Update-App +ExecStart=/usr/bin/npm run backend +Environment=PORT=4000 +Environment=AUTH_TOKEN_SECRET=replace-me +Restart=always +``` + +## 5) PostgreSQL provider choice + +- **Supabase**: Copy pooled connection string from Project Settings → Database. +- **Neon**: Copy connection string from Neon project dashboard. +- Set as `DATABASE_URL`. + +## 6) Redis (Upstash) + +- Create Redis database in Upstash. +- Use TCP URL as `REDIS_URL` (if your runtime supports it). +- For REST-based access, set both `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`. + +## 7) File storage + +- **AWS S3**: set `STORAGE_DRIVER=s3` + AWS credentials and bucket vars. +- **Cloudinary**: set `STORAGE_DRIVER=cloudinary` + Cloudinary keys. + +## 8) Post-deploy checks + +- `GET /api/health` returns status 200. +- Login endpoint returns token and includes security headers. +- CORS allows only your front-end domain. +- Rate limiting returns 429 after repeated requests. diff --git a/backend/env.js b/backend/env.js index 32e9b15..85af5e4 100644 --- a/backend/env.js +++ b/backend/env.js @@ -55,6 +55,21 @@ const envSchema = z.object({ LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.string().regex(/^\d+$/).optional(), LOGIN_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(), LOGIN_RATE_LIMIT_BLOCK_MS: z.string().regex(/^\d+$/).optional(), + GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.string().regex(/^\d+$/).optional(), + GLOBAL_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(), + SECURITY_HEADERS_CSP: z.string().optional(), + DATABASE_URL: z.string().optional(), + REDIS_URL: z.string().optional(), + UPSTASH_REDIS_REST_URL: z.string().optional(), + UPSTASH_REDIS_REST_TOKEN: z.string().optional(), + STORAGE_DRIVER: z.enum(['s3', 'cloudinary', 'local']).optional(), + AWS_REGION: z.string().optional(), + AWS_S3_BUCKET: z.string().optional(), + AWS_ACCESS_KEY_ID: z.string().optional(), + AWS_SECRET_ACCESS_KEY: z.string().optional(), + CLOUDINARY_CLOUD_NAME: z.string().optional(), + CLOUDINARY_API_KEY: z.string().optional(), + CLOUDINARY_API_SECRET: z.string().optional(), REDIS_URL: z.string().url().optional(), REDIS_KEY_PREFIX: z.string().optional(), CACHE_DEFAULT_TTL_SECONDS: z.string().regex(/^\d+$/).optional(), diff --git a/backend/server.js b/backend/server.js index 9e1db7d..a7dbef9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -16,19 +16,67 @@ const AUTH_TOKEN_SECRET = process.env.AUTH_TOKEN_SECRET || 'brocode-dev-secret-c const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS || 60 * 60 * 12); const EVENT_STATE_DEFAULT_TTL_SECONDS = Number(process.env.EVENT_STATE_DEFAULT_TTL_SECONDS || 120); const CORS_ALLOW_ORIGIN = process.env.CORS_ALLOW_ORIGIN || '*'; +const GLOBAL_RATE_LIMIT_MAX_REQUESTS = Number(process.env.GLOBAL_RATE_LIMIT_MAX_REQUESTS || 300); +const GLOBAL_RATE_LIMIT_WINDOW_MS = Number(process.env.GLOBAL_RATE_LIMIT_WINDOW_MS || 15 * 60 * 1000); +const SECURITY_HEADERS_CSP = process.env.SECURITY_HEADERS_CSP || "default-src 'self'"; const loginAttempts = new Map(); +const globalRequests = new Map(); const SWAGGER_HTML = buildSwaggerHtml(); const jobSystem = await createJobSystem(); -const getLoginKey = (req, username) => { +const getLoginKey = (req, username) => `${getRequestIp(req)}:${username}`; + +const getRateLimitState = (key) => { + const now = Date.now(); + const existing = loginAttempts.get(key); + + if (!existing) { + const state = { count: 0, windowStart: now, blockedUntil: 0 }; + loginAttempts.set(key, state); + return state; + } + + if (existing.blockedUntil > 0 && existing.blockedUntil <= now) { + existing.count = 0; + existing.windowStart = now; + existing.blockedUntil = 0; + } + + if (now - existing.windowStart > LOGIN_RATE_LIMIT_WINDOW_MS) { + existing.count = 0; + existing.windowStart = now; + } + + return existing; +}; + + +const getGlobalRateLimitState = (key) => { + const now = Date.now(); + const existing = globalRequests.get(key); + + if (!existing || now - existing.windowStart > GLOBAL_RATE_LIMIT_WINDOW_MS) { + const state = { count: 0, windowStart: now }; + globalRequests.set(key, state); + return state; + } + + return existing; +}; + +const getRequestIp = (req) => { const forwardedFor = req.headers['x-forwarded-for']; const firstForwardedIp = Array.isArray(forwardedFor) ? forwardedFor[0] : typeof forwardedFor === 'string' ? forwardedFor.split(',')[0] : ''; - const remoteIp = firstForwardedIp?.trim() || req.socket?.remoteAddress || 'unknown-ip'; - return `${remoteIp}:${username}`; + + return firstForwardedIp?.trim() || req.socket?.remoteAddress || 'unknown-ip'; +}; + +const clearRateLimitState = (key) => { + loginAttempts.delete(key); }; const parseBearerToken = (authHeader) => { @@ -109,13 +157,42 @@ const getUserFromAuthHeader = async (authHeader) => { return database.getUserById(verifiedPayload.sub); }; +const recordFailedLoginAttempt = (key) => { + const now = Date.now(); + const state = getRateLimitState(key); + state.count += 1; + + if (state.count >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS) { + state.blockedUntil = now + LOGIN_RATE_LIMIT_BLOCK_MS; + } +}; + +const sendJson = (res, statusCode, body, extraHeaders = {}) => { const sendJson = (res, statusCode, body) => { res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': CORS_ALLOW_ORIGIN, 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Origin-Agent-Cluster': '?1', + 'Referrer-Policy': 'no-referrer', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'X-Content-Type-Options': 'nosniff', + 'X-DNS-Prefetch-Control': 'off', + 'X-Download-Options': 'noopen', + 'X-Frame-Options': 'SAMEORIGIN', + 'X-Permitted-Cross-Domain-Policies': 'none', + 'X-XSS-Protection': '0', + 'Content-Security-Policy': SECURITY_HEADERS_CSP, + ...extraHeaders, }); + if (statusCode === 204) { + res.end(); + return; + } + res.end(JSON.stringify(body)); }; @@ -156,6 +233,23 @@ const server = createServer(async (req, res) => { const parsedUrl = new URL(req.url || '/', `http://localhost:${port}`); const path = parsedUrl.pathname; + const globalRateLimitKey = getRequestIp(req); + const globalRateLimitState = getGlobalRateLimitState(globalRateLimitKey); + globalRateLimitState.count += 1; + + if (globalRateLimitState.count > GLOBAL_RATE_LIMIT_MAX_REQUESTS) { + const retryAfterSeconds = Math.ceil( + (GLOBAL_RATE_LIMIT_WINDOW_MS - (Date.now() - globalRateLimitState.windowStart)) / 1000 + ); + sendJson( + res, + 429, + { error: 'Too many requests. Please try again later.', retryAfterSeconds }, + { 'Retry-After': String(Math.max(retryAfterSeconds, 1)) } + ); + return; + } + if (method === 'OPTIONS') { sendJson(res, 204, {}); return; @@ -193,6 +287,19 @@ const server = createServer(async (req, res) => { } const loginKey = getLoginKey(req, username); + const rateLimitState = getRateLimitState(loginKey); + const now = Date.now(); + if (rateLimitState.blockedUntil > now) { + const retryAfterSeconds = Math.ceil((rateLimitState.blockedUntil - now) / 1000); + sendJson( + res, + 429, + { + error: 'Too many failed login attempts. Please try again later.', + retryAfterSeconds, + }, + { 'Retry-After': String(Math.max(retryAfterSeconds, 1)) } + ); const retryAfterSeconds = await rateLimiter.getBlockedSeconds(loginKey); if (retryAfterSeconds > 0) { sendJson(res, 429, {