@pixelbyte-software/pixcode 1.54.0 → 1.54.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/assets/{index-CEw93JQ7.js → index-CLO8YURv.js} +205 -205
  2. package/dist/assets/index-DvudRU5X.css +32 -0
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/claude-sdk.js +6 -0
  5. package/dist-server/server/claude-sdk.js.map +1 -1
  6. package/dist-server/server/index.js +240 -46
  7. package/dist-server/server/index.js.map +1 -1
  8. package/dist-server/server/middleware/account-lockout.js +101 -0
  9. package/dist-server/server/middleware/account-lockout.js.map +1 -0
  10. package/dist-server/server/middleware/auth.js +71 -9
  11. package/dist-server/server/middleware/auth.js.map +1 -1
  12. package/dist-server/server/middleware/rate-limiter.js +62 -0
  13. package/dist-server/server/middleware/rate-limiter.js.map +1 -0
  14. package/dist-server/server/routes/agent.js +3 -4
  15. package/dist-server/server/routes/agent.js.map +1 -1
  16. package/dist-server/server/routes/auth.js +75 -13
  17. package/dist-server/server/routes/auth.js.map +1 -1
  18. package/dist-server/server/routes/codex.js +1 -1
  19. package/dist-server/server/routes/codex.js.map +1 -1
  20. package/dist-server/server/routes/diagnostics.js +6 -3
  21. package/dist-server/server/routes/diagnostics.js.map +1 -1
  22. package/dist-server/server/routes/gemini.js +1 -1
  23. package/dist-server/server/routes/gemini.js.map +1 -1
  24. package/dist-server/server/routes/git.js +9 -9
  25. package/dist-server/server/routes/git.js.map +1 -1
  26. package/dist-server/server/routes/live-view.js +2 -2
  27. package/dist-server/server/routes/live-view.js.map +1 -1
  28. package/dist-server/server/routes/plugins.js +12 -12
  29. package/dist-server/server/routes/plugins.js.map +1 -1
  30. package/dist-server/server/routes/projects.js +2 -2
  31. package/dist-server/server/routes/projects.js.map +1 -1
  32. package/dist-server/server/routes/qwen.js +1 -1
  33. package/dist-server/server/routes/qwen.js.map +1 -1
  34. package/dist-server/server/routes/remote.js +1 -1
  35. package/dist-server/server/routes/remote.js.map +1 -1
  36. package/dist-server/server/routes/webhooks.js +1 -1
  37. package/dist-server/server/routes/webhooks.js.map +1 -1
  38. package/dist-server/server/services/startup-update.js +31 -6
  39. package/dist-server/server/services/startup-update.js.map +1 -1
  40. package/dist-server/server/services/telegram/bot.js +6 -4
  41. package/dist-server/server/services/telegram/bot.js.map +1 -1
  42. package/dist-server/server/setup-wizard.js +12 -6
  43. package/dist-server/server/setup-wizard.js.map +1 -1
  44. package/dist-server/server/utils/plugin-loader.js +3 -0
  45. package/dist-server/server/utils/plugin-loader.js.map +1 -1
  46. package/dist-server/server/utils/port-access.js +25 -8
  47. package/dist-server/server/utils/port-access.js.map +1 -1
  48. package/dist-server/server/utils/security-log.js +89 -0
  49. package/dist-server/server/utils/security-log.js.map +1 -0
  50. package/package.json +1 -1
  51. package/server/claude-sdk.js +7 -0
  52. package/server/index.js +249 -46
  53. package/server/middleware/account-lockout.js +112 -0
  54. package/server/middleware/auth.js +72 -9
  55. package/server/middleware/rate-limiter.js +82 -0
  56. package/server/routes/agent.js +3 -4
  57. package/server/routes/auth.js +86 -13
  58. package/server/routes/codex.js +1 -1
  59. package/server/routes/diagnostics.js +6 -3
  60. package/server/routes/gemini.js +1 -1
  61. package/server/routes/git.js +9 -9
  62. package/server/routes/live-view.js +2 -2
  63. package/server/routes/plugins.js +12 -12
  64. package/server/routes/projects.js +2 -2
  65. package/server/routes/qwen.js +1 -1
  66. package/server/routes/remote.js +1 -1
  67. package/server/routes/webhooks.js +1 -1
  68. package/server/services/startup-update.js +31 -6
  69. package/server/services/telegram/bot.js +6 -4
  70. package/server/setup-wizard.js +12 -6
  71. package/server/utils/plugin-loader.js +4 -0
  72. package/server/utils/port-access.js +26 -8
  73. package/server/utils/security-log.js +84 -0
  74. package/dist/assets/index-B6Ssy_IG.css +0 -32
@@ -0,0 +1,112 @@
1
+ import { securityLog } from '../utils/security-log.js';
2
+
3
+ const MAX_FAILED_ATTEMPTS = 5;
4
+ const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
5
+ const failedAttempts = new Map();
6
+
7
+ function cleanupExpired() {
8
+ const now = Date.now();
9
+ for (const [key, entry] of failedAttempts.entries()) {
10
+ if (entry.lockedUntil && entry.lockedUntil <= now) {
11
+ failedAttempts.delete(key);
12
+ } else if (!entry.lockedUntil && entry.lastAttempt && now - entry.lastAttempt > LOCKOUT_DURATION_MS) {
13
+ failedAttempts.delete(key);
14
+ }
15
+ }
16
+ }
17
+
18
+ const cleanupInterval = setInterval(cleanupExpired, 5 * 60 * 1000);
19
+ cleanupInterval.unref();
20
+
21
+ export function checkAccountLockout(username, ip) {
22
+ const key = `${username}:${ip}`;
23
+ const entry = failedAttempts.get(key);
24
+ if (!entry) return { locked: false };
25
+
26
+ if (entry.lockedUntil && entry.lockedUntil > Date.now()) {
27
+ const remainingMs = entry.lockedUntil - Date.now();
28
+ const remainingMin = Math.ceil(remainingMs / 60000);
29
+ return {
30
+ locked: true,
31
+ remainingMs,
32
+ message: `Account locked due to too many failed attempts. Try again in ${remainingMin} minute(s).`,
33
+ };
34
+ }
35
+
36
+ if (entry.lockedUntil && entry.lockedUntil <= Date.now()) {
37
+ failedAttempts.delete(key);
38
+ return { locked: false };
39
+ }
40
+
41
+ return { locked: false };
42
+ }
43
+
44
+ export function recordFailedLogin(username, ip) {
45
+ const key = `${username}:${ip}`;
46
+ let entry = failedAttempts.get(key);
47
+ if (!entry) {
48
+ entry = { count: 0, lockedUntil: null, lastAttempt: Date.now() };
49
+ failedAttempts.set(key, entry);
50
+ }
51
+
52
+ entry.count += 1;
53
+ entry.lastAttempt = Date.now();
54
+
55
+ if (entry.count >= MAX_FAILED_ATTEMPTS) {
56
+ entry.lockedUntil = Date.now() + LOCKOUT_DURATION_MS;
57
+ securityLog('account_locked', {
58
+ username,
59
+ ip,
60
+ reason: `Exceeded ${MAX_FAILED_ATTEMPTS} failed login attempts`,
61
+ });
62
+ return {
63
+ locked: true,
64
+ message: `Account locked due to too many failed attempts. Try again in 15 minutes.`,
65
+ };
66
+ }
67
+
68
+ return {
69
+ locked: false,
70
+ remaining: MAX_FAILED_ATTEMPTS - entry.count,
71
+ };
72
+ }
73
+
74
+ export function recordSuccessfulLogin(username, ip) {
75
+ const key = `${username}:${ip}`;
76
+ failedAttempts.delete(key);
77
+ }
78
+
79
+ export function validatePasswordPolicy(password) {
80
+ if (typeof password !== 'string' || password.length < 8) {
81
+ return { valid: false, error: 'Password must be at least 8 characters long' };
82
+ }
83
+ if (password.length > 128) {
84
+ return { valid: false, error: 'Password must not exceed 128 characters' };
85
+ }
86
+ if (!/[a-z]/.test(password)) {
87
+ return { valid: false, error: 'Password must contain at least one lowercase letter' };
88
+ }
89
+ if (!/[A-Z]/.test(password)) {
90
+ return { valid: false, error: 'Password must contain at least one uppercase letter' };
91
+ }
92
+ if (!/[0-9]/.test(password)) {
93
+ return { valid: false, error: 'Password must contain at least one number' };
94
+ }
95
+ if (!/[^a-zA-Z0-9]/.test(password)) {
96
+ return { valid: false, error: 'Password must contain at least one special character' };
97
+ }
98
+ return { valid: true };
99
+ }
100
+
101
+ export function validateUsername(username) {
102
+ if (typeof username !== 'string' || username.length < 3) {
103
+ return { valid: false, error: 'Username must be at least 3 characters' };
104
+ }
105
+ if (username.length > 64) {
106
+ return { valid: false, error: 'Username must not exceed 64 characters' };
107
+ }
108
+ if (!/^[a-zA-Z0-9_.-]+$/.test(username)) {
109
+ return { valid: false, error: 'Username may only contain letters, numbers, dots, hyphens, and underscores' };
110
+ }
111
+ return { valid: true };
112
+ }
@@ -2,9 +2,12 @@ import jwt from 'jsonwebtoken';
2
2
 
3
3
  import { userDb, appConfigDb, apiKeysDb } from '../database/db.js';
4
4
  import { IS_PLATFORM } from '../constants/config.js';
5
+ import { securityLog, getClientIp } from '../utils/security-log.js';
5
6
 
6
7
  // Use env var if set, otherwise auto-generate a unique secret per installation
7
8
  const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
9
+ const JWT_ALGORITHM = 'HS256';
10
+ const JWT_EXPIRY = process.env.JWT_EXPIRY || '24h';
8
11
  const isPixcodeApiKey = (token) => typeof token === 'string' && (token.startsWith('px_') || token.startsWith('ck_'));
9
12
  const ADMIN_ROLES = new Set(['owner', 'admin']);
10
13
  const PLATFORM_AUTH_BYPASS_ENABLED = IS_PLATFORM && process.env.PIXCODE_ALLOW_PLATFORM_AUTH_BYPASS === '1';
@@ -18,6 +21,11 @@ const validateApiKey = (req, res, next) => {
18
21
 
19
22
  const apiKey = req.headers['x-api-key'];
20
23
  if (apiKey !== process.env.API_KEY) {
24
+ securityLog('api_key_validation_failed', {
25
+ ip: getClientIp(req),
26
+ endpoint: req.path,
27
+ method: req.method,
28
+ });
21
29
  return res.status(401).json({ error: 'Invalid API key' });
22
30
  }
23
31
  next();
@@ -47,12 +55,23 @@ const authenticateToken = async (req, res, next) => {
47
55
  // - ?apiKey=<apikey> (EventSource workaround)
48
56
  // Auth-token mode is decided by the prefix: new keys generated by Pixcode
49
57
  // start with `px_`; older `ck_` keys remain valid for existing installs.
58
+ // SECURITY NOTE: Query-param credentials may appear in server logs, proxy
59
+ // logs, and browser history. Prefer Authorization headers for new clients.
50
60
  const authHeader = req.headers['authorization'];
51
61
  const bearerToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
52
62
  const apiKeyHeader = req.headers['x-api-key'];
53
63
  const queryToken = typeof req.query.token === 'string' ? req.query.token : null;
54
64
  const queryApiKey = typeof req.query.apiKey === 'string' ? req.query.apiKey : null;
55
65
 
66
+ if (queryToken || queryApiKey) {
67
+ securityLog('query_param_credential_used', {
68
+ ip: getClientIp(req),
69
+ endpoint: req.path,
70
+ method: req.method,
71
+ userAgent: req.headers['user-agent'],
72
+ });
73
+ }
74
+
56
75
  // Try API-key paths first when the credential is unambiguously an API key.
57
76
  const explicitApiKey = apiKeyHeader || queryApiKey
58
77
  || (isPixcodeApiKey(bearerToken) ? bearerToken : null)
@@ -62,8 +81,21 @@ const authenticateToken = async (req, res, next) => {
62
81
  try {
63
82
  const user = apiKeysDb.validateApiKey(explicitApiKey);
64
83
  if (!user) {
84
+ securityLog('api_key_auth_failed', {
85
+ ip: getClientIp(req),
86
+ endpoint: req.path,
87
+ method: req.method,
88
+ userId: user?.id,
89
+ });
65
90
  return res.status(401).json({ error: 'Invalid or inactive API key' });
66
91
  }
92
+ securityLog('api_key_auth_success', {
93
+ ip: getClientIp(req),
94
+ endpoint: req.path,
95
+ method: req.method,
96
+ userId: user.id,
97
+ username: user.username,
98
+ });
67
99
  req.user = user;
68
100
  return next();
69
101
  } catch (error) {
@@ -75,15 +107,26 @@ const authenticateToken = async (req, res, next) => {
75
107
  // Otherwise fall back to JWT.
76
108
  const jwtToken = bearerToken || queryToken;
77
109
  if (!jwtToken) {
110
+ securityLog('auth_no_token', {
111
+ ip: getClientIp(req),
112
+ endpoint: req.path,
113
+ method: req.method,
114
+ });
78
115
  return res.status(401).json({ error: 'Access denied. No token provided.' });
79
116
  }
80
117
 
81
118
  try {
82
- const decoded = jwt.verify(jwtToken, JWT_SECRET);
119
+ const decoded = jwt.verify(jwtToken, JWT_SECRET, { algorithms: [JWT_ALGORITHM] });
83
120
 
84
121
  // Verify user still exists and is active
85
122
  const user = userDb.getUserById(decoded.userId);
86
123
  if (!user) {
124
+ securityLog('jwt_user_not_found', {
125
+ ip: getClientIp(req),
126
+ endpoint: req.path,
127
+ method: req.method,
128
+ userId: decoded.userId,
129
+ });
87
130
  return res.status(401).json({ error: 'Invalid token. User not found.' });
88
131
  }
89
132
 
@@ -100,7 +143,12 @@ const authenticateToken = async (req, res, next) => {
100
143
  req.user = user;
101
144
  next();
102
145
  } catch (error) {
103
- console.error('Token verification error:', error);
146
+ securityLog('jwt_verification_failed', {
147
+ ip: getClientIp(req),
148
+ endpoint: req.path,
149
+ method: req.method,
150
+ reason: error.name || 'unknown',
151
+ });
104
152
  return res.status(403).json({ error: 'Invalid token' });
105
153
  }
106
154
  };
@@ -111,6 +159,13 @@ const requireAdmin = (req, res, next) => {
111
159
  }
112
160
 
113
161
  if (!ADMIN_ROLES.has(req.user.role)) {
162
+ securityLog('admin_access_denied', {
163
+ ip: getClientIp(req),
164
+ endpoint: req.path,
165
+ method: req.method,
166
+ userId: req.user.id,
167
+ username: req.user.username,
168
+ });
114
169
  return res.status(403).json({ error: 'Admin access required.' });
115
170
  }
116
171
 
@@ -120,6 +175,12 @@ const requireAdmin = (req, res, next) => {
120
175
  !req.user.api_key_scopes?.includes('system') &&
121
176
  !req.user.api_key_scopes?.includes('*')
122
177
  ) {
178
+ securityLog('admin_scope_denied', {
179
+ ip: getClientIp(req),
180
+ endpoint: req.path,
181
+ method: req.method,
182
+ userId: req.user.id,
183
+ });
123
184
  return res.status(403).json({ error: 'API key lacks admin scope.' });
124
185
  }
125
186
 
@@ -136,6 +197,13 @@ const requireApiScope = (scope) => (req, res, next) => {
136
197
  return next();
137
198
  }
138
199
 
200
+ securityLog('api_scope_denied', {
201
+ ip: getClientIp(req),
202
+ endpoint: req.path,
203
+ method: req.method,
204
+ userId: req.user.id,
205
+ reason: `Missing scope: ${scope}`,
206
+ });
139
207
  return res.status(403).json({ error: `API key lacks required scope: ${scope}` });
140
208
  };
141
209
 
@@ -148,7 +216,7 @@ const generateToken = (user) => {
148
216
  role: user.role || null,
149
217
  },
150
218
  JWT_SECRET,
151
- { expiresIn: '7d' }
219
+ { expiresIn: JWT_EXPIRY, algorithm: JWT_ALGORITHM }
152
220
  );
153
221
  };
154
222
 
@@ -170,10 +238,6 @@ const authenticateWebSocket = (token) => {
170
238
 
171
239
  // Normal OSS validation — accept either an API key (`px_…` or legacy
172
240
  // `ck_…`) or a JWT.
173
- // Mirrors the REST `authenticateToken` middleware so any tool that has
174
- // a Pixcode API key (CI scripts, the api-tester subagent, the user's own
175
- // automation, ...) can also open a WebSocket without first exchanging
176
- // the key for a JWT.
177
241
  if (!token) {
178
242
  return null;
179
243
  }
@@ -190,8 +254,7 @@ const authenticateWebSocket = (token) => {
190
254
  }
191
255
 
192
256
  try {
193
- const decoded = jwt.verify(token, JWT_SECRET);
194
- // Verify user actually exists in database (matches REST authenticateToken behavior)
257
+ const decoded = jwt.verify(token, JWT_SECRET, { algorithms: [JWT_ALGORITHM] });
195
258
  const user = userDb.getUserById(decoded.userId);
196
259
  if (!user) {
197
260
  return null;
@@ -0,0 +1,82 @@
1
+ import { securityLog, getClientIp } from '../utils/security-log.js';
2
+
3
+ function createRateLimiter(options = {}) {
4
+ const {
5
+ windowMs = 15 * 60 * 1000,
6
+ max = 100,
7
+ keyGenerator = (req) => getClientIp(req),
8
+ message = 'Too many requests from this IP, please try again later.',
9
+ statusCode = 429,
10
+ skip = (_req) => false,
11
+ } = options;
12
+
13
+ const hits = new Map();
14
+
15
+ function cleanupExpired() {
16
+ const now = Date.now();
17
+ for (const [key, entry] of hits.entries()) {
18
+ if (entry.resetTime <= now) {
19
+ hits.delete(key);
20
+ }
21
+ }
22
+ }
23
+
24
+ const cleanupInterval = setInterval(cleanupExpired, windowMs);
25
+ cleanupInterval.unref();
26
+
27
+ return (req, res, next) => {
28
+ if (skip(req)) {
29
+ return next();
30
+ }
31
+
32
+ const key = keyGenerator(req);
33
+ const now = Date.now();
34
+
35
+ let entry = hits.get(key);
36
+ if (!entry || entry.resetTime <= now) {
37
+ entry = {
38
+ count: 0,
39
+ resetTime: now + windowMs,
40
+ };
41
+ hits.set(key, entry);
42
+ }
43
+
44
+ entry.count += 1;
45
+
46
+ res.setHeader('X-RateLimit-Limit', String(max));
47
+ res.setHeader('X-RateLimit-Remaining', String(Math.max(0, max - entry.count)));
48
+ res.setHeader('X-RateLimit-Reset', String(Math.ceil(entry.resetTime / 1000)));
49
+
50
+ if (entry.count > max) {
51
+ securityLog('rate_limit_exceeded', {
52
+ ip: getClientIp(req),
53
+ endpoint: req.path,
54
+ method: req.method,
55
+ userAgent: req.headers['user-agent'],
56
+ });
57
+ return res.status(statusCode).json({
58
+ success: false,
59
+ error: {
60
+ code: 'RATE_LIMIT_EXCEEDED',
61
+ message,
62
+ },
63
+ });
64
+ }
65
+
66
+ next();
67
+ };
68
+ }
69
+
70
+ export const authRateLimiter = createRateLimiter({
71
+ windowMs: 15 * 60 * 1000,
72
+ max: 10,
73
+ message: 'Too many authentication attempts. Please try again in 15 minutes.',
74
+ });
75
+
76
+ export const apiRateLimiter = createRateLimiter({
77
+ windowMs: 60 * 1000,
78
+ max: 120,
79
+ message: 'Too many API requests. Please slow down.',
80
+ });
81
+
82
+ export { createRateLimiter };
@@ -1412,8 +1412,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
1412
1412
  if (!res.writableEnded) {
1413
1413
  writer.send({
1414
1414
  type: 'error',
1415
- error: error.message,
1416
- message: `Failed: ${error.message}`
1415
+ error: 'Failed to process agent request.',
1416
+ message: 'Failed to process agent request.'
1417
1417
  });
1418
1418
  writer.end();
1419
1419
  }
@@ -1435,14 +1435,13 @@ router.post('/', validateExternalApiKey, async (req, res) => {
1435
1435
  if (errorText) collectedError = errorText;
1436
1436
  } catch { /* ignore — fall back to error.message */ }
1437
1437
  }
1438
- const failureDetails = describeProviderFailure(collectedError || error.message, provider);
1438
+ const failureDetails = describeProviderFailure(collectedError || 'Provider returned no assistant text.', provider);
1439
1439
  res.status(502).json({
1440
1440
  success: false,
1441
1441
  sessionId: writer && typeof writer.getSessionId === 'function' ? writer.getSessionId() : null,
1442
1442
  error: failureDetails.message,
1443
1443
  rawError: failureDetails.rawMessage,
1444
1444
  errorDetails: failureDetails,
1445
- wrapperError: collectedError ? error.message : undefined,
1446
1445
  messages: collectedMessages,
1447
1446
  });
1448
1447
  }
@@ -1,12 +1,17 @@
1
1
  import express from 'express';
2
- // bcryptjs is a pure-JS drop-in (same hash/compare API, same output format)
3
- // — switching from native `bcrypt` here eliminated one C++ compile from
4
- // the install path. Existing $2a$/$2b$ hashes in the DB remain valid;
5
- // bcryptjs recognizes both prefixes so logins work across the swap.
6
2
  import bcrypt from 'bcryptjs';
7
3
 
8
4
  import { userDb, db } from '../database/db.js';
9
5
  import { generateToken, authenticateToken, requireAdmin } from '../middleware/auth.js';
6
+ import { authRateLimiter } from '../middleware/rate-limiter.js';
7
+ import {
8
+ checkAccountLockout,
9
+ recordFailedLogin,
10
+ recordSuccessfulLogin,
11
+ validatePasswordPolicy,
12
+ validateUsername,
13
+ } from '../middleware/account-lockout.js';
14
+ import { securityLog, getClientIp } from '../utils/security-log.js';
10
15
  import {
11
16
  consumeQrLoginToken,
12
17
  createQrLoginToken,
@@ -49,7 +54,10 @@ router.get('/connection-mode', (req, res) => {
49
54
  res.json({ success: true, connection: getPublicRemoteConnectionConfig() });
50
55
  });
51
56
 
52
- router.put('/connection-mode', (req, res) => {
57
+ // Changing the connection mode is a state-changing operation that must
58
+ // require authentication. Without auth, any unauthenticated caller could
59
+ // redirect the app to a malicious remote server.
60
+ router.put('/connection-mode', authenticateToken, requireAdmin, (req, res) => {
53
61
  try {
54
62
  res.json({ success: true, connection: saveRemoteConnectionConfig(req.body || {}) });
55
63
  } catch (error) {
@@ -69,7 +77,7 @@ router.put('/qr-login/settings', authenticateToken, requireAdmin, (req, res) =>
69
77
  }
70
78
  });
71
79
 
72
- router.post('/qr-login/token', authenticateToken, requireAdmin, (req, res) => {
80
+ router.post('/qr-login/token', authenticateToken, requireAdmin, authRateLimiter, (req, res) => {
73
81
  try {
74
82
  const baseUrl = typeof req.body?.baseUrl === 'string' && req.body.baseUrl.trim()
75
83
  ? req.body.baseUrl.trim()
@@ -81,11 +89,16 @@ router.post('/qr-login/token', authenticateToken, requireAdmin, (req, res) => {
81
89
  }
82
90
  });
83
91
 
84
- router.post('/qr-login', (req, res) => {
92
+ router.post('/qr-login', authRateLimiter, (req, res) => {
85
93
  try {
86
94
  const user = consumeQrLoginToken(req.body?.token);
87
95
  const token = generateToken(user);
88
96
  userDb.updateLastLogin(user.id);
97
+ securityLog('qr_login_success', {
98
+ ip: getClientIp(req),
99
+ userId: user.id,
100
+ username: user.username,
101
+ });
89
102
  res.json({
90
103
  success: true,
91
104
  user: publicUser(user),
@@ -93,22 +106,33 @@ router.post('/qr-login', (req, res) => {
93
106
  });
94
107
  } catch (error) {
95
108
  const status = error?.code === 'QR_LOGIN_DISABLED' ? 409 : 401;
109
+ securityLog('qr_login_failed', {
110
+ ip: getClientIp(req),
111
+ reason: error?.code || 'unknown',
112
+ });
96
113
  res.status(status).json({ success: false, error: error.message });
97
114
  }
98
115
  });
99
116
 
100
117
  // User registration (setup) - only allowed if no users exist
101
- router.post('/register', async (req, res) => {
118
+ router.post('/register', authRateLimiter, async (req, res) => {
102
119
  try {
103
120
  const { username, password } = req.body;
121
+ const clientIp = getClientIp(req);
104
122
 
105
123
  // Validate input
106
124
  if (!username || !password) {
107
125
  return res.status(400).json({ error: 'Username and password are required' });
108
126
  }
109
127
 
110
- if (username.length < 3 || password.length < 6) {
111
- return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
128
+ const usernameValidation = validateUsername(username);
129
+ if (!usernameValidation.valid) {
130
+ return res.status(400).json({ error: usernameValidation.error });
131
+ }
132
+
133
+ const passwordValidation = validatePasswordPolicy(password);
134
+ if (!passwordValidation.valid) {
135
+ return res.status(400).json({ error: passwordValidation.error });
112
136
  }
113
137
 
114
138
  // Use a transaction to prevent race conditions
@@ -136,6 +160,12 @@ router.post('/register', async (req, res) => {
136
160
  // Update last login (non-fatal, outside transaction)
137
161
  userDb.updateLastLogin(user.id);
138
162
 
163
+ securityLog('user_registered', {
164
+ ip: clientIp,
165
+ userId: user.id,
166
+ username: user.username,
167
+ });
168
+
139
169
  res.json({
140
170
  success: true,
141
171
  user: publicUser(user),
@@ -157,32 +187,72 @@ router.post('/register', async (req, res) => {
157
187
  });
158
188
 
159
189
  // User login
160
- router.post('/login', async (req, res) => {
190
+ router.post('/login', authRateLimiter, async (req, res) => {
161
191
  try {
162
192
  const { username, password } = req.body;
193
+ const clientIp = getClientIp(req);
163
194
 
164
195
  // Validate input
165
196
  if (!username || !password) {
166
197
  return res.status(400).json({ error: 'Username and password are required' });
167
198
  }
199
+
200
+ // Check account lockout before attempting login
201
+ const lockoutStatus = checkAccountLockout(String(username), clientIp);
202
+ if (lockoutStatus.locked) {
203
+ securityLog('login_blocked_locked', {
204
+ ip: clientIp,
205
+ username,
206
+ reason: 'Account locked due to failed attempts',
207
+ });
208
+ return res.status(423).json({ error: lockoutStatus.message });
209
+ }
168
210
 
169
211
  // Get user from database
170
212
  const user = userDb.getUserByUsername(username);
171
213
  if (!user) {
214
+ const failResult = recordFailedLogin(String(username), clientIp);
215
+ securityLog('login_failed', {
216
+ ip: clientIp,
217
+ username,
218
+ reason: 'User not found',
219
+ });
220
+ if (failResult.locked) {
221
+ return res.status(423).json({ error: failResult.message });
222
+ }
172
223
  return res.status(401).json({ error: 'Invalid username or password' });
173
224
  }
174
225
 
175
226
  // Verify password
176
227
  const isValidPassword = await bcrypt.compare(password, user.password_hash);
177
228
  if (!isValidPassword) {
229
+ const failResult = recordFailedLogin(String(username), clientIp);
230
+ securityLog('login_failed', {
231
+ ip: clientIp,
232
+ username,
233
+ userId: user.id,
234
+ reason: 'Invalid password',
235
+ });
236
+ if (failResult.locked) {
237
+ return res.status(423).json({ error: failResult.message });
238
+ }
178
239
  return res.status(401).json({ error: 'Invalid username or password' });
179
240
  }
241
+
242
+ // Clear failed attempts on successful login
243
+ recordSuccessfulLogin(String(username), clientIp);
180
244
 
181
245
  // Generate token
182
246
  const token = generateToken(user);
183
247
 
184
248
  // Update last login
185
249
  userDb.updateLastLogin(user.id);
250
+
251
+ securityLog('login_success', {
252
+ ip: clientIp,
253
+ userId: user.id,
254
+ username: user.username,
255
+ });
186
256
 
187
257
  res.json({
188
258
  success: true,
@@ -205,8 +275,11 @@ router.get('/user', authenticateToken, (req, res) => {
205
275
 
206
276
  // Logout (client-side token removal, but this endpoint can be used for logging)
207
277
  router.post('/logout', authenticateToken, (req, res) => {
208
- // In a simple JWT system, logout is mainly client-side
209
- // This endpoint exists for consistency and potential future logging
278
+ securityLog('user_logout', {
279
+ ip: getClientIp(req),
280
+ userId: req.user?.id,
281
+ username: req.user?.username,
282
+ });
210
283
  res.json({ success: true, message: 'Logged out successfully' });
211
284
  });
212
285
 
@@ -13,7 +13,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
13
13
  res.json({ success: true });
14
14
  } catch (error) {
15
15
  console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);
16
- res.status(500).json({ success: false, error: error.message });
16
+ res.status(500).json({ success: false, error: "Internal server error" });
17
17
  }
18
18
  });
19
19
 
@@ -1,6 +1,7 @@
1
1
  import express from 'express';
2
2
 
3
3
  import { collectDiagnostics } from '../services/diagnostics.js';
4
+ import { requireAdmin } from '../middleware/auth.js';
4
5
 
5
6
  const router = express.Router();
6
7
 
@@ -16,11 +17,13 @@ function buildDiagnostics(req) {
16
17
  });
17
18
  }
18
19
 
19
- router.get('/', (req, res) => {
20
+ // Diagnostics expose internal system state (active sessions, errors,
21
+ // provider health) — restrict to admins to prevent information leakage.
22
+ router.get('/', requireAdmin, (req, res) => {
20
23
  res.json(buildDiagnostics(req));
21
24
  });
22
25
 
23
- router.post('/refresh', (req, res) => {
26
+ router.post('/refresh', requireAdmin, (req, res) => {
24
27
  req.app.locals.diagnosticsCache = {
25
28
  ...(req.app.locals.diagnosticsCache || {}),
26
29
  diagnosticsUpdatedAt: new Date().toISOString(),
@@ -29,7 +32,7 @@ router.post('/refresh', (req, res) => {
29
32
  res.json(buildDiagnostics(req));
30
33
  });
31
34
 
32
- router.get('/bundle', (req, res) => {
35
+ router.get('/bundle', requireAdmin, (req, res) => {
33
36
  const diagnostics = buildDiagnostics(req);
34
37
  res.json({
35
38
  generatedAt: diagnostics.timestamp,
@@ -18,7 +18,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
18
18
  res.json({ success: true });
19
19
  } catch (error) {
20
20
  console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);
21
- res.status(500).json({ success: false, error: error.message });
21
+ res.status(500).json({ success: false, error: "Internal server error" });
22
22
  }
23
23
  });
24
24