Skip to content

Commit 35b6556

Browse files
authored
Merge pull request #55 from fuzziecoder/codex/implement-security-and-deployment-features
Harden backend security, add global rate limiting and deployment runbook
2 parents f5a9e41 + 5445543 commit 35b6556

4 files changed

Lines changed: 277 additions & 30 deletions

File tree

backend/README.md

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,52 @@ npm run backend
1010

1111
Server starts at `http://localhost:4000` by default.
1212

13-
## Database
13+
## Security Layer
1414

15-
### Issue #26: Move from in-memory store to persistent DB
15+
### Helmet-style security headers
1616

17-
- Uses a local JSON database file at `backend/data/brocode.json`.
18-
- You can override the location with `BROCODE_DB_PATH=/custom/path.json npm run backend`.
19-
- On first start, seed data is inserted for users, spots, catalog items, and a sample order.
20-
- New orders are validated against DB data (known `spotId`, `userId`, `productId`) and item pricing is always derived from catalog prices in the database.
17+
The server now sends strict security headers on every API response, including:
18+
19+
- `Content-Security-Policy`
20+
- `Strict-Transport-Security`
21+
- `X-Content-Type-Options`
22+
- `X-Frame-Options`
23+
- `Referrer-Policy`
24+
- `Cross-Origin-*` hardening headers
25+
26+
Configure CSP via `SECURITY_HEADERS_CSP`.
27+
28+
### CORS
29+
30+
CORS is applied to all endpoints with these defaults:
31+
32+
- Allowed origin from `CORS_ALLOW_ORIGIN` (defaults to `*`)
33+
- Allowed headers: `Content-Type`, `Authorization`
34+
- Allowed methods: `GET,POST,DELETE,OPTIONS`
2135

22-
### Issue #28: Secure credential storage and verification
36+
### Rate limiting
2337

24-
- Passwords are stored as salted `scrypt` hashes (not plaintext).
25-
- Legacy plaintext user passwords are auto-migrated to hashed values on successful login.
38+
Two limits are active:
2639

27-
### Issue #29: Protect login endpoint from brute-force attempts
40+
1. **Global API limiter** per IP (`GLOBAL_RATE_LIMIT_MAX_REQUESTS` in `GLOBAL_RATE_LIMIT_WINDOW_MS`)
41+
2. **Login brute-force limiter** per `IP + username`
42+
(`LOGIN_RATE_LIMIT_MAX_ATTEMPTS` in `LOGIN_RATE_LIMIT_WINDOW_MS`, temporary block for `LOGIN_RATE_LIMIT_BLOCK_MS`)
2843

29-
- Login is now rate-limited per `IP + username` key.
30-
- Defaults: 5 failed attempts within 15 minutes triggers a 15 minute temporary block (`429`).
31-
- Configure via env vars:
32-
- `LOGIN_RATE_LIMIT_MAX_ATTEMPTS`
33-
- `LOGIN_RATE_LIMIT_WINDOW_MS`
34-
- `LOGIN_RATE_LIMIT_BLOCK_MS`
44+
Both return HTTP `429` and `Retry-After` headers.
45+
46+
### Password hashing
47+
48+
User credentials are stored as salted hashes (using Node crypto `scrypt`) and never as plaintext.
49+
Legacy plaintext records auto-migrate to hashed values at successful login.
50+
51+
## Database
52+
53+
- Uses a local JSON database file at `backend/data/brocode.json`.
54+
- You can override the location with `BROCODE_DB_PATH=/custom/path.json npm run backend`.
55+
- On first start, seed data is inserted for users, spots, catalog items, and a sample order.
56+
- New orders are validated against DB data (known `spotId`, `userId`, `productId`) and item pricing is always derived from catalog prices in the database.
3557

58+
## Deployment (Render / Railway / AWS EC2)
3659
### Issue #31: Redis-backed caching + session/performance primitives
3760

3861
- 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.
5679

5780
### Issue #30: Secure backend data access with signed auth tokens + authorization
5881

59-
- Login now returns an HMAC-signed bearer token (replacing predictable demo tokens).
60-
- Tokens include user id, role, and expiry, and are validated with constant-time signature checks.
61-
- Data endpoints now require `Authorization: Bearer <token>` and enforce role access:
62-
- `GET /api/orders` → users can only read their own orders; admins can read all.
63-
- `POST /api/orders` → users can create only for themselves; admins can create for any user.
64-
- `GET /api/orders/:id` → users can read only their own order; admins can read any order.
65-
- `GET /api/bills/:spotId` and `DELETE /api/users/:userId` → admin only.
66-
- Configure via env vars:
67-
- `AUTH_TOKEN_SECRET`
68-
- `AUTH_TOKEN_TTL_SECONDS`
69-
- `CORS_ALLOW_ORIGIN`
82+
See [`backend/deployment.md`](./deployment.md) for step-by-step deployment options and env setup for:
7083

84+
- Backend hosting: **Render**, **Railway**, **AWS EC2**
85+
- PostgreSQL: **Supabase** or **Neon**
86+
- Redis: **Upstash**
87+
- File storage: **AWS S3** or **Cloudinary**
7188
### Background jobs (BullMQ + Redis)
7289

7390
- The backend initializes BullMQ queues for:

backend/deployment.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Deployment guide
2+
3+
This project can be deployed with the following stack:
4+
5+
- **Backend**: Render, Railway, or AWS EC2
6+
- **Database (PostgreSQL)**: Supabase or Neon
7+
- **Redis**: Upstash
8+
- **File storage**: AWS S3 or Cloudinary
9+
10+
> 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.
11+
12+
## 1) Required environment variables
13+
14+
Use these common variables in all platforms:
15+
16+
```bash
17+
PORT=4000
18+
AUTH_TOKEN_SECRET=replace-with-long-secret
19+
AUTH_TOKEN_TTL_SECONDS=43200
20+
CORS_ALLOW_ORIGIN=https://your-frontend-domain.com
21+
22+
# Security/rate-limit tuning
23+
SECURITY_HEADERS_CSP=default-src 'self'
24+
GLOBAL_RATE_LIMIT_MAX_REQUESTS=300
25+
GLOBAL_RATE_LIMIT_WINDOW_MS=900000
26+
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
27+
LOGIN_RATE_LIMIT_WINDOW_MS=900000
28+
LOGIN_RATE_LIMIT_BLOCK_MS=900000
29+
30+
# PostgreSQL (Supabase/Neon)
31+
DATABASE_URL=postgresql://...
32+
33+
# Redis (Upstash)
34+
REDIS_URL=redis://...
35+
UPSTASH_REDIS_REST_URL=https://...
36+
UPSTASH_REDIS_REST_TOKEN=...
37+
38+
# Storage (choose one)
39+
STORAGE_DRIVER=s3
40+
AWS_REGION=ap-south-1
41+
AWS_S3_BUCKET=...
42+
AWS_ACCESS_KEY_ID=...
43+
AWS_SECRET_ACCESS_KEY=...
44+
45+
# OR
46+
STORAGE_DRIVER=cloudinary
47+
CLOUDINARY_CLOUD_NAME=...
48+
CLOUDINARY_API_KEY=...
49+
CLOUDINARY_API_SECRET=...
50+
```
51+
52+
## 2) Render
53+
54+
1. Create a **Web Service** from this repo.
55+
2. Build command: `npm install && npm run build`
56+
3. Start command: `npm run backend`
57+
4. Add the env vars above in Render dashboard.
58+
5. Add PostgreSQL (Supabase/Neon external) and Upstash connection URLs.
59+
60+
## 3) Railway
61+
62+
1. Create a new Railway project linked to this repo.
63+
2. Set start command to `npm run backend`.
64+
3. Add all env vars in the Variables tab.
65+
4. Set custom domain and update `CORS_ALLOW_ORIGIN`.
66+
67+
## 4) AWS EC2
68+
69+
1. Provision Ubuntu instance and install Node.js LTS.
70+
2. Clone repo and run `npm install`.
71+
3. Configure env vars in systemd service file.
72+
4. Run service with `npm run backend` via systemd.
73+
5. Put Nginx in front with HTTPS (Let's Encrypt).
74+
75+
Example service snippet:
76+
77+
```ini
78+
[Service]
79+
WorkingDirectory=/srv/Brocode-Party-Update-App
80+
ExecStart=/usr/bin/npm run backend
81+
Environment=PORT=4000
82+
Environment=AUTH_TOKEN_SECRET=replace-me
83+
Restart=always
84+
```
85+
86+
## 5) PostgreSQL provider choice
87+
88+
- **Supabase**: Copy pooled connection string from Project Settings → Database.
89+
- **Neon**: Copy connection string from Neon project dashboard.
90+
- Set as `DATABASE_URL`.
91+
92+
## 6) Redis (Upstash)
93+
94+
- Create Redis database in Upstash.
95+
- Use TCP URL as `REDIS_URL` (if your runtime supports it).
96+
- For REST-based access, set both `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`.
97+
98+
## 7) File storage
99+
100+
- **AWS S3**: set `STORAGE_DRIVER=s3` + AWS credentials and bucket vars.
101+
- **Cloudinary**: set `STORAGE_DRIVER=cloudinary` + Cloudinary keys.
102+
103+
## 8) Post-deploy checks
104+
105+
- `GET /api/health` returns status 200.
106+
- Login endpoint returns token and includes security headers.
107+
- CORS allows only your front-end domain.
108+
- Rate limiting returns 429 after repeated requests.

backend/env.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ const envSchema = z.object({
5555
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.string().regex(/^\d+$/).optional(),
5656
LOGIN_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(),
5757
LOGIN_RATE_LIMIT_BLOCK_MS: z.string().regex(/^\d+$/).optional(),
58+
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.string().regex(/^\d+$/).optional(),
59+
GLOBAL_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(),
60+
SECURITY_HEADERS_CSP: z.string().optional(),
61+
DATABASE_URL: z.string().optional(),
62+
REDIS_URL: z.string().optional(),
63+
UPSTASH_REDIS_REST_URL: z.string().optional(),
64+
UPSTASH_REDIS_REST_TOKEN: z.string().optional(),
65+
STORAGE_DRIVER: z.enum(['s3', 'cloudinary', 'local']).optional(),
66+
AWS_REGION: z.string().optional(),
67+
AWS_S3_BUCKET: z.string().optional(),
68+
AWS_ACCESS_KEY_ID: z.string().optional(),
69+
AWS_SECRET_ACCESS_KEY: z.string().optional(),
70+
CLOUDINARY_CLOUD_NAME: z.string().optional(),
71+
CLOUDINARY_API_KEY: z.string().optional(),
72+
CLOUDINARY_API_SECRET: z.string().optional(),
5873
REDIS_URL: z.string().url().optional(),
5974
REDIS_KEY_PREFIX: z.string().optional(),
6075
CACHE_DEFAULT_TTL_SECONDS: z.string().regex(/^\d+$/).optional(),

backend/server.js

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,67 @@ const AUTH_TOKEN_SECRET = process.env.AUTH_TOKEN_SECRET || 'brocode-dev-secret-c
1616
const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS || 60 * 60 * 12);
1717
const EVENT_STATE_DEFAULT_TTL_SECONDS = Number(process.env.EVENT_STATE_DEFAULT_TTL_SECONDS || 120);
1818
const CORS_ALLOW_ORIGIN = process.env.CORS_ALLOW_ORIGIN || '*';
19+
const GLOBAL_RATE_LIMIT_MAX_REQUESTS = Number(process.env.GLOBAL_RATE_LIMIT_MAX_REQUESTS || 300);
20+
const GLOBAL_RATE_LIMIT_WINDOW_MS = Number(process.env.GLOBAL_RATE_LIMIT_WINDOW_MS || 15 * 60 * 1000);
21+
const SECURITY_HEADERS_CSP = process.env.SECURITY_HEADERS_CSP || "default-src 'self'";
1922
const loginAttempts = new Map();
23+
const globalRequests = new Map();
2024
const SWAGGER_HTML = buildSwaggerHtml();
2125
const jobSystem = await createJobSystem();
2226

23-
const getLoginKey = (req, username) => {
27+
const getLoginKey = (req, username) => `${getRequestIp(req)}:${username}`;
28+
29+
const getRateLimitState = (key) => {
30+
const now = Date.now();
31+
const existing = loginAttempts.get(key);
32+
33+
if (!existing) {
34+
const state = { count: 0, windowStart: now, blockedUntil: 0 };
35+
loginAttempts.set(key, state);
36+
return state;
37+
}
38+
39+
if (existing.blockedUntil > 0 && existing.blockedUntil <= now) {
40+
existing.count = 0;
41+
existing.windowStart = now;
42+
existing.blockedUntil = 0;
43+
}
44+
45+
if (now - existing.windowStart > LOGIN_RATE_LIMIT_WINDOW_MS) {
46+
existing.count = 0;
47+
existing.windowStart = now;
48+
}
49+
50+
return existing;
51+
};
52+
53+
54+
const getGlobalRateLimitState = (key) => {
55+
const now = Date.now();
56+
const existing = globalRequests.get(key);
57+
58+
if (!existing || now - existing.windowStart > GLOBAL_RATE_LIMIT_WINDOW_MS) {
59+
const state = { count: 0, windowStart: now };
60+
globalRequests.set(key, state);
61+
return state;
62+
}
63+
64+
return existing;
65+
};
66+
67+
const getRequestIp = (req) => {
2468
const forwardedFor = req.headers['x-forwarded-for'];
2569
const firstForwardedIp = Array.isArray(forwardedFor)
2670
? forwardedFor[0]
2771
: typeof forwardedFor === 'string'
2872
? forwardedFor.split(',')[0]
2973
: '';
30-
const remoteIp = firstForwardedIp?.trim() || req.socket?.remoteAddress || 'unknown-ip';
31-
return `${remoteIp}:${username}`;
74+
75+
return firstForwardedIp?.trim() || req.socket?.remoteAddress || 'unknown-ip';
76+
};
77+
78+
const clearRateLimitState = (key) => {
79+
loginAttempts.delete(key);
3280
};
3381

3482
const parseBearerToken = (authHeader) => {
@@ -109,13 +157,42 @@ const getUserFromAuthHeader = async (authHeader) => {
109157
return database.getUserById(verifiedPayload.sub);
110158
};
111159

160+
const recordFailedLoginAttempt = (key) => {
161+
const now = Date.now();
162+
const state = getRateLimitState(key);
163+
state.count += 1;
164+
165+
if (state.count >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS) {
166+
state.blockedUntil = now + LOGIN_RATE_LIMIT_BLOCK_MS;
167+
}
168+
};
169+
170+
const sendJson = (res, statusCode, body, extraHeaders = {}) => {
112171
const sendJson = (res, statusCode, body) => {
113172
res.writeHead(statusCode, {
114173
'Content-Type': 'application/json',
115174
'Access-Control-Allow-Origin': CORS_ALLOW_ORIGIN,
116175
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
117176
'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS',
177+
'Cross-Origin-Opener-Policy': 'same-origin',
178+
'Cross-Origin-Resource-Policy': 'same-origin',
179+
'Origin-Agent-Cluster': '?1',
180+
'Referrer-Policy': 'no-referrer',
181+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
182+
'X-Content-Type-Options': 'nosniff',
183+
'X-DNS-Prefetch-Control': 'off',
184+
'X-Download-Options': 'noopen',
185+
'X-Frame-Options': 'SAMEORIGIN',
186+
'X-Permitted-Cross-Domain-Policies': 'none',
187+
'X-XSS-Protection': '0',
188+
'Content-Security-Policy': SECURITY_HEADERS_CSP,
189+
...extraHeaders,
118190
});
191+
if (statusCode === 204) {
192+
res.end();
193+
return;
194+
}
195+
119196
res.end(JSON.stringify(body));
120197
};
121198

@@ -156,6 +233,23 @@ const server = createServer(async (req, res) => {
156233
const parsedUrl = new URL(req.url || '/', `http://localhost:${port}`);
157234
const path = parsedUrl.pathname;
158235

236+
const globalRateLimitKey = getRequestIp(req);
237+
const globalRateLimitState = getGlobalRateLimitState(globalRateLimitKey);
238+
globalRateLimitState.count += 1;
239+
240+
if (globalRateLimitState.count > GLOBAL_RATE_LIMIT_MAX_REQUESTS) {
241+
const retryAfterSeconds = Math.ceil(
242+
(GLOBAL_RATE_LIMIT_WINDOW_MS - (Date.now() - globalRateLimitState.windowStart)) / 1000
243+
);
244+
sendJson(
245+
res,
246+
429,
247+
{ error: 'Too many requests. Please try again later.', retryAfterSeconds },
248+
{ 'Retry-After': String(Math.max(retryAfterSeconds, 1)) }
249+
);
250+
return;
251+
}
252+
159253
if (method === 'OPTIONS') {
160254
sendJson(res, 204, {});
161255
return;
@@ -193,6 +287,19 @@ const server = createServer(async (req, res) => {
193287
}
194288

195289
const loginKey = getLoginKey(req, username);
290+
const rateLimitState = getRateLimitState(loginKey);
291+
const now = Date.now();
292+
if (rateLimitState.blockedUntil > now) {
293+
const retryAfterSeconds = Math.ceil((rateLimitState.blockedUntil - now) / 1000);
294+
sendJson(
295+
res,
296+
429,
297+
{
298+
error: 'Too many failed login attempts. Please try again later.',
299+
retryAfterSeconds,
300+
},
301+
{ 'Retry-After': String(Math.max(retryAfterSeconds, 1)) }
302+
);
196303
const retryAfterSeconds = await rateLimiter.getBlockedSeconds(loginKey);
197304
if (retryAfterSeconds > 0) {
198305
sendJson(res, 429, {

0 commit comments

Comments
 (0)