From 382fed6dc579b874b8ae07651836dd37d78a2349 Mon Sep 17 00:00:00 2001 From: Ramkumar <153575188+fuzziecoder@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:07:36 -0500 Subject: [PATCH] Add backend security hardening and deployment guide --- backend/README.md | 72 +++++++++++++++++----------- backend/deployment.md | 108 ++++++++++++++++++++++++++++++++++++++++++ backend/env.js | 15 ++++++ backend/server.js | 90 +++++++++++++++++++++++++++++------ 4 files changed, 242 insertions(+), 43 deletions(-) create mode 100644 backend/deployment.md diff --git a/backend/README.md b/backend/README.md index 430e5cb..8c2743e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -10,43 +10,59 @@ 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. -### Issue #30: Secure backend data access with signed auth tokens + authorization +## Deployment (Render / Railway / AWS EC2) -- 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** ## Available endpoints 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 1a2890d..f556851 100644 --- a/backend/env.js +++ b/backend/env.js @@ -13,6 +13,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(), }); const result = envSchema.safeParse(process.env); diff --git a/backend/server.js b/backend/server.js index 5d5532a..9f479e2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -12,18 +12,13 @@ const LOGIN_RATE_LIMIT_BLOCK_MS = Number(process.env.LOGIN_RATE_LIMIT_BLOCK_MS | const AUTH_TOKEN_SECRET = process.env.AUTH_TOKEN_SECRET || 'brocode-dev-secret-change-me'; const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS || 60 * 60 * 12); 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 getLoginKey = (req, username) => { - 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}`; -}; +const getLoginKey = (req, username) => `${getRequestIp(req)}:${username}`; const getRateLimitState = (key) => { const now = Date.now(); @@ -49,6 +44,31 @@ const getRateLimitState = (key) => { 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] + : ''; + + return firstForwardedIp?.trim() || req.socket?.remoteAddress || 'unknown-ip'; +}; + const clearRateLimitState = (key) => { loginAttempts.delete(key); }; @@ -136,13 +156,31 @@ const recordFailedLoginAttempt = (key) => { } }; -const sendJson = (res, statusCode, body) => { +const sendJson = (res, statusCode, body, extraHeaders = {}) => { 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)); }; @@ -175,6 +213,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; @@ -199,10 +254,15 @@ const server = createServer(async (req, res) => { 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, - }); + sendJson( + res, + 429, + { + error: 'Too many failed login attempts. Please try again later.', + retryAfterSeconds, + }, + { 'Retry-After': String(Math.max(retryAfterSeconds, 1)) } + ); return; }