@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.
- package/dist/assets/{index-CEw93JQ7.js → index-CLO8YURv.js} +205 -205
- package/dist/assets/index-DvudRU5X.css +32 -0
- package/dist/index.html +2 -2
- package/dist-server/server/claude-sdk.js +6 -0
- package/dist-server/server/claude-sdk.js.map +1 -1
- package/dist-server/server/index.js +240 -46
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/middleware/account-lockout.js +101 -0
- package/dist-server/server/middleware/account-lockout.js.map +1 -0
- package/dist-server/server/middleware/auth.js +71 -9
- package/dist-server/server/middleware/auth.js.map +1 -1
- package/dist-server/server/middleware/rate-limiter.js +62 -0
- package/dist-server/server/middleware/rate-limiter.js.map +1 -0
- package/dist-server/server/routes/agent.js +3 -4
- package/dist-server/server/routes/agent.js.map +1 -1
- package/dist-server/server/routes/auth.js +75 -13
- package/dist-server/server/routes/auth.js.map +1 -1
- package/dist-server/server/routes/codex.js +1 -1
- package/dist-server/server/routes/codex.js.map +1 -1
- package/dist-server/server/routes/diagnostics.js +6 -3
- package/dist-server/server/routes/diagnostics.js.map +1 -1
- package/dist-server/server/routes/gemini.js +1 -1
- package/dist-server/server/routes/gemini.js.map +1 -1
- package/dist-server/server/routes/git.js +9 -9
- package/dist-server/server/routes/git.js.map +1 -1
- package/dist-server/server/routes/live-view.js +2 -2
- package/dist-server/server/routes/live-view.js.map +1 -1
- package/dist-server/server/routes/plugins.js +12 -12
- package/dist-server/server/routes/plugins.js.map +1 -1
- package/dist-server/server/routes/projects.js +2 -2
- package/dist-server/server/routes/projects.js.map +1 -1
- package/dist-server/server/routes/qwen.js +1 -1
- package/dist-server/server/routes/qwen.js.map +1 -1
- package/dist-server/server/routes/remote.js +1 -1
- package/dist-server/server/routes/remote.js.map +1 -1
- package/dist-server/server/routes/webhooks.js +1 -1
- package/dist-server/server/routes/webhooks.js.map +1 -1
- package/dist-server/server/services/startup-update.js +31 -6
- package/dist-server/server/services/startup-update.js.map +1 -1
- package/dist-server/server/services/telegram/bot.js +6 -4
- package/dist-server/server/services/telegram/bot.js.map +1 -1
- package/dist-server/server/setup-wizard.js +12 -6
- package/dist-server/server/setup-wizard.js.map +1 -1
- package/dist-server/server/utils/plugin-loader.js +3 -0
- package/dist-server/server/utils/plugin-loader.js.map +1 -1
- package/dist-server/server/utils/port-access.js +25 -8
- package/dist-server/server/utils/port-access.js.map +1 -1
- package/dist-server/server/utils/security-log.js +89 -0
- package/dist-server/server/utils/security-log.js.map +1 -0
- package/package.json +1 -1
- package/server/claude-sdk.js +7 -0
- package/server/index.js +249 -46
- package/server/middleware/account-lockout.js +112 -0
- package/server/middleware/auth.js +72 -9
- package/server/middleware/rate-limiter.js +82 -0
- package/server/routes/agent.js +3 -4
- package/server/routes/auth.js +86 -13
- package/server/routes/codex.js +1 -1
- package/server/routes/diagnostics.js +6 -3
- package/server/routes/gemini.js +1 -1
- package/server/routes/git.js +9 -9
- package/server/routes/live-view.js +2 -2
- package/server/routes/plugins.js +12 -12
- package/server/routes/projects.js +2 -2
- package/server/routes/qwen.js +1 -1
- package/server/routes/remote.js +1 -1
- package/server/routes/webhooks.js +1 -1
- package/server/services/startup-update.js +31 -6
- package/server/services/telegram/bot.js +6 -4
- package/server/setup-wizard.js +12 -6
- package/server/utils/plugin-loader.js +4 -0
- package/server/utils/port-access.js +26 -8
- package/server/utils/security-log.js +84 -0
- 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
|
-
|
|
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:
|
|
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 };
|
package/server/routes/agent.js
CHANGED
|
@@ -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:
|
|
1416
|
-
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 ||
|
|
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
|
}
|
package/server/routes/auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
package/server/routes/codex.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
package/server/routes/gemini.js
CHANGED
|
@@ -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
|
|
21
|
+
res.status(500).json({ success: false, error: "Internal server error" });
|
|
22
22
|
}
|
|
23
23
|
});
|
|
24
24
|
|