Skip to content

Commit 382fed6

Browse files
committed
Add backend security hardening and deployment guide
1 parent e5fa696 commit 382fed6

4 files changed

Lines changed: 242 additions & 43 deletions

File tree

backend/README.md

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,43 +10,59 @@ 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

36-
### Issue #30: Secure backend data access with signed auth tokens + authorization
58+
## Deployment (Render / Railway / AWS EC2)
3759

38-
- Login now returns an HMAC-signed bearer token (replacing predictable demo tokens).
39-
- Tokens include user id, role, and expiry, and are validated with constant-time signature checks.
40-
- Data endpoints now require `Authorization: Bearer <token>` and enforce role access:
41-
- `GET /api/orders` → users can only read their own orders; admins can read all.
42-
- `POST /api/orders` → users can create only for themselves; admins can create for any user.
43-
- `GET /api/orders/:id` → users can read only their own order; admins can read any order.
44-
- `GET /api/bills/:spotId` and `DELETE /api/users/:userId` → admin only.
45-
- Configure via env vars:
46-
- `AUTH_TOKEN_SECRET`
47-
- `AUTH_TOKEN_TTL_SECONDS`
48-
- `CORS_ALLOW_ORIGIN`
60+
See [`backend/deployment.md`](./deployment.md) for step-by-step deployment options and env setup for:
4961

62+
- Backend hosting: **Render**, **Railway**, **AWS EC2**
63+
- PostgreSQL: **Supabase** or **Neon**
64+
- Redis: **Upstash**
65+
- File storage: **AWS S3** or **Cloudinary**
5066

5167
## Available endpoints
5268

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
@@ -13,6 +13,21 @@ const envSchema = z.object({
1313
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.string().regex(/^\d+$/).optional(),
1414
LOGIN_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(),
1515
LOGIN_RATE_LIMIT_BLOCK_MS: z.string().regex(/^\d+$/).optional(),
16+
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.string().regex(/^\d+$/).optional(),
17+
GLOBAL_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(),
18+
SECURITY_HEADERS_CSP: z.string().optional(),
19+
DATABASE_URL: z.string().optional(),
20+
REDIS_URL: z.string().optional(),
21+
UPSTASH_REDIS_REST_URL: z.string().optional(),
22+
UPSTASH_REDIS_REST_TOKEN: z.string().optional(),
23+
STORAGE_DRIVER: z.enum(['s3', 'cloudinary', 'local']).optional(),
24+
AWS_REGION: z.string().optional(),
25+
AWS_S3_BUCKET: z.string().optional(),
26+
AWS_ACCESS_KEY_ID: z.string().optional(),
27+
AWS_SECRET_ACCESS_KEY: z.string().optional(),
28+
CLOUDINARY_CLOUD_NAME: z.string().optional(),
29+
CLOUDINARY_API_KEY: z.string().optional(),
30+
CLOUDINARY_API_SECRET: z.string().optional(),
1631
});
1732

1833
const result = envSchema.safeParse(process.env);

backend/server.js

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,13 @@ const LOGIN_RATE_LIMIT_BLOCK_MS = Number(process.env.LOGIN_RATE_LIMIT_BLOCK_MS |
1212
const AUTH_TOKEN_SECRET = process.env.AUTH_TOKEN_SECRET || 'brocode-dev-secret-change-me';
1313
const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS || 60 * 60 * 12);
1414
const CORS_ALLOW_ORIGIN = process.env.CORS_ALLOW_ORIGIN || '*';
15+
const GLOBAL_RATE_LIMIT_MAX_REQUESTS = Number(process.env.GLOBAL_RATE_LIMIT_MAX_REQUESTS || 300);
16+
const GLOBAL_RATE_LIMIT_WINDOW_MS = Number(process.env.GLOBAL_RATE_LIMIT_WINDOW_MS || 15 * 60 * 1000);
17+
const SECURITY_HEADERS_CSP = process.env.SECURITY_HEADERS_CSP || "default-src 'self'";
1518
const loginAttempts = new Map();
19+
const globalRequests = new Map();
1620

17-
const getLoginKey = (req, username) => {
18-
const forwardedFor = req.headers['x-forwarded-for'];
19-
const firstForwardedIp = Array.isArray(forwardedFor)
20-
? forwardedFor[0]
21-
: typeof forwardedFor === 'string'
22-
? forwardedFor.split(',')[0]
23-
: '';
24-
const remoteIp = firstForwardedIp?.trim() || req.socket?.remoteAddress || 'unknown-ip';
25-
return `${remoteIp}:${username}`;
26-
};
21+
const getLoginKey = (req, username) => `${getRequestIp(req)}:${username}`;
2722

2823
const getRateLimitState = (key) => {
2924
const now = Date.now();
@@ -49,6 +44,31 @@ const getRateLimitState = (key) => {
4944
return existing;
5045
};
5146

47+
48+
const getGlobalRateLimitState = (key) => {
49+
const now = Date.now();
50+
const existing = globalRequests.get(key);
51+
52+
if (!existing || now - existing.windowStart > GLOBAL_RATE_LIMIT_WINDOW_MS) {
53+
const state = { count: 0, windowStart: now };
54+
globalRequests.set(key, state);
55+
return state;
56+
}
57+
58+
return existing;
59+
};
60+
61+
const getRequestIp = (req) => {
62+
const forwardedFor = req.headers['x-forwarded-for'];
63+
const firstForwardedIp = Array.isArray(forwardedFor)
64+
? forwardedFor[0]
65+
: typeof forwardedFor === 'string'
66+
? forwardedFor.split(',')[0]
67+
: '';
68+
69+
return firstForwardedIp?.trim() || req.socket?.remoteAddress || 'unknown-ip';
70+
};
71+
5272
const clearRateLimitState = (key) => {
5373
loginAttempts.delete(key);
5474
};
@@ -136,13 +156,31 @@ const recordFailedLoginAttempt = (key) => {
136156
}
137157
};
138158

139-
const sendJson = (res, statusCode, body) => {
159+
const sendJson = (res, statusCode, body, extraHeaders = {}) => {
140160
res.writeHead(statusCode, {
141161
'Content-Type': 'application/json',
142162
'Access-Control-Allow-Origin': CORS_ALLOW_ORIGIN,
143163
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
144164
'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS',
165+
'Cross-Origin-Opener-Policy': 'same-origin',
166+
'Cross-Origin-Resource-Policy': 'same-origin',
167+
'Origin-Agent-Cluster': '?1',
168+
'Referrer-Policy': 'no-referrer',
169+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
170+
'X-Content-Type-Options': 'nosniff',
171+
'X-DNS-Prefetch-Control': 'off',
172+
'X-Download-Options': 'noopen',
173+
'X-Frame-Options': 'SAMEORIGIN',
174+
'X-Permitted-Cross-Domain-Policies': 'none',
175+
'X-XSS-Protection': '0',
176+
'Content-Security-Policy': SECURITY_HEADERS_CSP,
177+
...extraHeaders,
145178
});
179+
if (statusCode === 204) {
180+
res.end();
181+
return;
182+
}
183+
146184
res.end(JSON.stringify(body));
147185
};
148186

@@ -175,6 +213,23 @@ const server = createServer(async (req, res) => {
175213
const parsedUrl = new URL(req.url || '/', `http://localhost:${port}`);
176214
const path = parsedUrl.pathname;
177215

216+
const globalRateLimitKey = getRequestIp(req);
217+
const globalRateLimitState = getGlobalRateLimitState(globalRateLimitKey);
218+
globalRateLimitState.count += 1;
219+
220+
if (globalRateLimitState.count > GLOBAL_RATE_LIMIT_MAX_REQUESTS) {
221+
const retryAfterSeconds = Math.ceil(
222+
(GLOBAL_RATE_LIMIT_WINDOW_MS - (Date.now() - globalRateLimitState.windowStart)) / 1000
223+
);
224+
sendJson(
225+
res,
226+
429,
227+
{ error: 'Too many requests. Please try again later.', retryAfterSeconds },
228+
{ 'Retry-After': String(Math.max(retryAfterSeconds, 1)) }
229+
);
230+
return;
231+
}
232+
178233
if (method === 'OPTIONS') {
179234
sendJson(res, 204, {});
180235
return;
@@ -199,10 +254,15 @@ const server = createServer(async (req, res) => {
199254
const now = Date.now();
200255
if (rateLimitState.blockedUntil > now) {
201256
const retryAfterSeconds = Math.ceil((rateLimitState.blockedUntil - now) / 1000);
202-
sendJson(res, 429, {
203-
error: 'Too many failed login attempts. Please try again later.',
204-
retryAfterSeconds,
205-
});
257+
sendJson(
258+
res,
259+
429,
260+
{
261+
error: 'Too many failed login attempts. Please try again later.',
262+
retryAfterSeconds,
263+
},
264+
{ 'Retry-After': String(Math.max(retryAfterSeconds, 1)) }
265+
);
206266
return;
207267
}
208268

0 commit comments

Comments
 (0)