Skip to content

Commit cfb3d8e

Browse files
authored
Merge pull request #37 from fuzziecoder/codex/fix-common-backend-issues-and-create-pr
backend: add login rate limiting to mitigate brute-force attempts
2 parents 90f4351 + ed59db6 commit cfb3d8e

2 files changed

Lines changed: 79 additions & 0 deletions

File tree

backend/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ Server starts at `http://localhost:4000` by default.
2424
- Passwords are stored as salted `scrypt` hashes (not plaintext).
2525
- Legacy plaintext user passwords are auto-migrated to hashed values on successful login.
2626

27+
### Issue #29: Protect login endpoint from brute-force attempts
28+
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`
35+
36+
2737
## Available endpoints
2838

2939
- `GET /api/health`

backend/server.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,60 @@ import { database, dbPath } from './db.js';
44

55
const port = Number(process.env.PORT || 4000);
66

7+
const LOGIN_RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.LOGIN_RATE_LIMIT_MAX_ATTEMPTS || 5);
8+
const LOGIN_RATE_LIMIT_WINDOW_MS = Number(process.env.LOGIN_RATE_LIMIT_WINDOW_MS || 15 * 60 * 1000);
9+
const LOGIN_RATE_LIMIT_BLOCK_MS = Number(process.env.LOGIN_RATE_LIMIT_BLOCK_MS || 15 * 60 * 1000);
10+
const loginAttempts = new Map();
11+
12+
const getLoginKey = (req, username) => {
13+
const forwardedFor = req.headers['x-forwarded-for'];
14+
const firstForwardedIp = Array.isArray(forwardedFor)
15+
? forwardedFor[0]
16+
: typeof forwardedFor === 'string'
17+
? forwardedFor.split(',')[0]
18+
: '';
19+
const remoteIp = firstForwardedIp?.trim() || req.socket?.remoteAddress || 'unknown-ip';
20+
return `${remoteIp}:${username}`;
21+
};
22+
23+
const getRateLimitState = (key) => {
24+
const now = Date.now();
25+
const existing = loginAttempts.get(key);
26+
27+
if (!existing) {
28+
const state = { count: 0, windowStart: now, blockedUntil: 0 };
29+
loginAttempts.set(key, state);
30+
return state;
31+
}
32+
33+
if (existing.blockedUntil > 0 && existing.blockedUntil <= now) {
34+
existing.count = 0;
35+
existing.windowStart = now;
36+
existing.blockedUntil = 0;
37+
}
38+
39+
if (now - existing.windowStart > LOGIN_RATE_LIMIT_WINDOW_MS) {
40+
existing.count = 0;
41+
existing.windowStart = now;
42+
}
43+
44+
return existing;
45+
};
46+
47+
const clearRateLimitState = (key) => {
48+
loginAttempts.delete(key);
49+
};
50+
51+
const recordFailedLoginAttempt = (key) => {
52+
const now = Date.now();
53+
const state = getRateLimitState(key);
54+
state.count += 1;
55+
56+
if (state.count >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS) {
57+
state.blockedUntil = now + LOGIN_RATE_LIMIT_BLOCK_MS;
58+
}
59+
};
60+
761
const sendJson = (res, statusCode, body) => {
862
res.writeHead(statusCode, {
963
'Content-Type': 'application/json',
@@ -62,13 +116,28 @@ const server = createServer(async (req, res) => {
62116
return;
63117
}
64118

119+
const loginKey = getLoginKey(req, username);
120+
const rateLimitState = getRateLimitState(loginKey);
121+
const now = Date.now();
122+
if (rateLimitState.blockedUntil > now) {
123+
const retryAfterSeconds = Math.ceil((rateLimitState.blockedUntil - now) / 1000);
124+
sendJson(res, 429, {
125+
error: 'Too many failed login attempts. Please try again later.',
126+
retryAfterSeconds,
127+
});
128+
return;
129+
}
130+
65131
const user = database.getUserByCredentials(username, password);
66132

67133
if (!user) {
134+
recordFailedLoginAttempt(loginKey);
68135
sendJson(res, 401, { error: 'invalid credentials' });
69136
return;
70137
}
71138

139+
clearRateLimitState(loginKey);
140+
72141
sendJson(res, 200, { token: `demo-token-${user.id}`, user });
73142
return;
74143
} catch (error) {

0 commit comments

Comments
 (0)