@openchamber/web 1.6.2 → 1.6.3
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/{ToolOutputDialog-C_ErvF_z.js → ToolOutputDialog-C-GmPj0l.js} +1 -1
- package/dist/assets/{index-C2ViUvl2.js → index-76oYSL3c.js} +2 -2
- package/dist/assets/{main-BivlNOB5.js → main-CY2cv9YC.js} +51 -51
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/index.js +8 -2
- package/server/lib/ui-auth.js +228 -3
package/dist/index.html
CHANGED
|
@@ -223,7 +223,7 @@
|
|
|
223
223
|
pointer-events: none;
|
|
224
224
|
}
|
|
225
225
|
</style>
|
|
226
|
-
<script type="module" crossorigin src="/assets/index-
|
|
226
|
+
<script type="module" crossorigin src="/assets/index-76oYSL3c.js"></script>
|
|
227
227
|
<link rel="modulepreload" crossorigin href="/assets/vendor-.bun-BQ7mgOcE.js">
|
|
228
228
|
<link rel="stylesheet" crossorigin href="/assets/vendor--Jn2c0Clh.css">
|
|
229
229
|
<link rel="stylesheet" crossorigin href="/assets/index-ObfBKDTp.css">
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -1959,13 +1959,19 @@ async function waitForReady(url, timeoutMs = 10000) {
|
|
|
1959
1959
|
try {
|
|
1960
1960
|
const controller = new AbortController();
|
|
1961
1961
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
1962
|
-
const res = await fetch(`${url.replace(/\/+$/, '')}/
|
|
1962
|
+
const res = await fetch(`${url.replace(/\/+$/, '')}/global/health`, {
|
|
1963
1963
|
method: 'GET',
|
|
1964
1964
|
headers: { Accept: 'application/json' },
|
|
1965
1965
|
signal: controller.signal
|
|
1966
1966
|
});
|
|
1967
1967
|
clearTimeout(timeout);
|
|
1968
|
-
|
|
1968
|
+
|
|
1969
|
+
if (res.ok) {
|
|
1970
|
+
const body = await res.json().catch(() => null);
|
|
1971
|
+
if (body?.healthy === true) {
|
|
1972
|
+
return true;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1969
1975
|
} catch {
|
|
1970
1976
|
// ignore
|
|
1971
1977
|
}
|
package/server/lib/ui-auth.js
CHANGED
|
@@ -4,6 +4,207 @@ const SESSION_COOKIE_NAME = 'oc_ui_session';
|
|
|
4
4
|
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
5
5
|
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
|
6
6
|
|
|
7
|
+
// Login rate limit configuration
|
|
8
|
+
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
|
9
|
+
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
|
|
10
|
+
const RATE_LIMIT_LOCKOUT_MS = 15 * 60 * 1000;
|
|
11
|
+
const RATE_LIMIT_CLEANUP_MS = 60 * 60 * 1000;
|
|
12
|
+
const RATE_LIMIT_NO_IP_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS) || 3;
|
|
13
|
+
|
|
14
|
+
// Rate limit tracker: IP -> { count, lastAttempt, lockedUntil }
|
|
15
|
+
const loginRateLimiter = new Map();
|
|
16
|
+
let rateLimitCleanupTimer = null;
|
|
17
|
+
|
|
18
|
+
// Concurrency control: key -> Promise<void>
|
|
19
|
+
const rateLimitLocks = new Map();
|
|
20
|
+
|
|
21
|
+
const getClientIp = (req) => {
|
|
22
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
23
|
+
if (typeof forwarded === 'string') {
|
|
24
|
+
const ip = forwarded.split(',')[0].trim();
|
|
25
|
+
if (ip.startsWith('::ffff:')) {
|
|
26
|
+
return ip.substring(7);
|
|
27
|
+
}
|
|
28
|
+
return ip;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ip = req.ip || req.connection?.remoteAddress;
|
|
32
|
+
if (ip) {
|
|
33
|
+
if (ip.startsWith('::ffff:')) {
|
|
34
|
+
return ip.substring(7);
|
|
35
|
+
}
|
|
36
|
+
return ip;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getRateLimitKey = (req) => {
|
|
42
|
+
const ip = getClientIp(req);
|
|
43
|
+
if (ip) return ip;
|
|
44
|
+
return 'rate-limit:no-ip';
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const getRateLimitConfig = (key) => {
|
|
48
|
+
if (key === 'rate-limit:no-ip') {
|
|
49
|
+
return {
|
|
50
|
+
maxAttempts: RATE_LIMIT_NO_IP_MAX_ATTEMPTS,
|
|
51
|
+
windowMs: RATE_LIMIT_WINDOW_MS
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
maxAttempts: RATE_LIMIT_MAX_ATTEMPTS,
|
|
56
|
+
windowMs: RATE_LIMIT_WINDOW_MS
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const acquireRateLimitLock = async (key) => {
|
|
61
|
+
const prev = rateLimitLocks.get(key) || Promise.resolve();
|
|
62
|
+
const curr = prev.then(() => rateLimitLocks.delete(key));
|
|
63
|
+
rateLimitLocks.set(key, curr);
|
|
64
|
+
await curr;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const checkRateLimit = async (req) => {
|
|
68
|
+
const key = getRateLimitKey(req);
|
|
69
|
+
await acquireRateLimitLock(key);
|
|
70
|
+
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const { maxAttempts } = getRateLimitConfig(key);
|
|
73
|
+
|
|
74
|
+
let record;
|
|
75
|
+
try {
|
|
76
|
+
record = loginRateLimiter.get(key);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error('[RateLimit] Failed to get record', { key, error: err.message });
|
|
79
|
+
return {
|
|
80
|
+
allowed: true,
|
|
81
|
+
limit: maxAttempts,
|
|
82
|
+
remaining: maxAttempts,
|
|
83
|
+
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (record?.lockedUntil && now < record.lockedUntil) {
|
|
88
|
+
return {
|
|
89
|
+
allowed: false,
|
|
90
|
+
retryAfter: Math.ceil((record.lockedUntil - now) / 1000),
|
|
91
|
+
locked: true,
|
|
92
|
+
limit: maxAttempts,
|
|
93
|
+
remaining: 0,
|
|
94
|
+
reset: Math.ceil(record.lockedUntil / 1000)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (record?.lockedUntil && now >= record.lockedUntil) {
|
|
99
|
+
try {
|
|
100
|
+
loginRateLimiter.delete(key);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error('[RateLimit] Failed to delete expired record', { key, error: err.message });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
107
|
+
return {
|
|
108
|
+
allowed: true,
|
|
109
|
+
limit: maxAttempts,
|
|
110
|
+
remaining: maxAttempts,
|
|
111
|
+
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (record.count >= maxAttempts) {
|
|
116
|
+
const lockedUntil = now + RATE_LIMIT_LOCKOUT_MS;
|
|
117
|
+
try {
|
|
118
|
+
loginRateLimiter.set(key, { count: record.count + 1, lastAttempt: now, lockedUntil });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error('[RateLimit] Failed to set lockout', { key, error: err.message });
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
allowed: false,
|
|
124
|
+
retryAfter: Math.ceil(RATE_LIMIT_LOCKOUT_MS / 1000),
|
|
125
|
+
locked: true,
|
|
126
|
+
limit: maxAttempts,
|
|
127
|
+
remaining: 0,
|
|
128
|
+
reset: Math.ceil(lockedUntil / 1000)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const remaining = maxAttempts - record.count;
|
|
133
|
+
const reset = Math.ceil((record.lastAttempt + RATE_LIMIT_WINDOW_MS) / 1000);
|
|
134
|
+
return {
|
|
135
|
+
allowed: true,
|
|
136
|
+
limit: maxAttempts,
|
|
137
|
+
remaining,
|
|
138
|
+
reset
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const recordFailedAttempt = async (req) => {
|
|
143
|
+
const key = getRateLimitKey(req);
|
|
144
|
+
await acquireRateLimitLock(key);
|
|
145
|
+
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
const { maxAttempts } = getRateLimitConfig(key);
|
|
148
|
+
const record = loginRateLimiter.get(key);
|
|
149
|
+
|
|
150
|
+
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
151
|
+
try {
|
|
152
|
+
loginRateLimiter.set(key, { count: 1, lastAttempt: now });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
const newCount = record.count + 1;
|
|
158
|
+
try {
|
|
159
|
+
loginRateLimiter.set(key, { count: newCount, lastAttempt: now });
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const clearRateLimit = async (req) => {
|
|
167
|
+
const key = getRateLimitKey(req);
|
|
168
|
+
await acquireRateLimitLock(key);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
loginRateLimiter.delete(key);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error('[RateLimit] Failed to clear', { key, error: err.message });
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const cleanupRateLimitRecords = () => {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
for (const [key, record] of loginRateLimiter.entries()) {
|
|
180
|
+
const isExpired = record.lockedUntil && now >= record.lockedUntil;
|
|
181
|
+
const isStale = now - record.lastAttempt > RATE_LIMIT_CLEANUP_MS;
|
|
182
|
+
if (isExpired || isStale) {
|
|
183
|
+
try {
|
|
184
|
+
loginRateLimiter.delete(key);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error('[RateLimit] Cleanup failed', { key, error: err.message });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const startRateLimitCleanup = () => {
|
|
193
|
+
if (!rateLimitCleanupTimer) {
|
|
194
|
+
rateLimitCleanupTimer = setInterval(cleanupRateLimitRecords, RATE_LIMIT_CLEANUP_MS);
|
|
195
|
+
if (rateLimitCleanupTimer && typeof rateLimitCleanupTimer.unref === 'function') {
|
|
196
|
+
rateLimitCleanupTimer.unref();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const stopRateLimitCleanup = () => {
|
|
202
|
+
if (rateLimitCleanupTimer) {
|
|
203
|
+
clearInterval(rateLimitCleanupTimer);
|
|
204
|
+
rateLimitCleanupTimer = null;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
7
208
|
const isSecureRequest = (req) => {
|
|
8
209
|
if (req.secure) {
|
|
9
210
|
return true;
|
|
@@ -221,6 +422,7 @@ export const createUiAuth = ({
|
|
|
221
422
|
};
|
|
222
423
|
|
|
223
424
|
startCleanup();
|
|
425
|
+
startRateLimitCleanup();
|
|
224
426
|
|
|
225
427
|
const respondUnauthorized = (req, res) => {
|
|
226
428
|
res.status(401);
|
|
@@ -254,14 +456,32 @@ export const createUiAuth = ({
|
|
|
254
456
|
res.status(401).json({ authenticated: false, locked: true });
|
|
255
457
|
};
|
|
256
458
|
|
|
257
|
-
const handleSessionCreate = (req, res) => {
|
|
459
|
+
const handleSessionCreate = async (req, res) => {
|
|
460
|
+
const rateLimitResult = await checkRateLimit(req);
|
|
461
|
+
|
|
462
|
+
res.setHeader('X-RateLimit-Limit', rateLimitResult.limit);
|
|
463
|
+
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remaining);
|
|
464
|
+
res.setHeader('X-RateLimit-Reset', rateLimitResult.reset);
|
|
465
|
+
|
|
466
|
+
if (!rateLimitResult.allowed) {
|
|
467
|
+
res.setHeader('Retry-After', rateLimitResult.retryAfter);
|
|
468
|
+
res.status(429).json({
|
|
469
|
+
error: 'Too many login attempts, please try again later',
|
|
470
|
+
retryAfter: rateLimitResult.retryAfter
|
|
471
|
+
});
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
258
475
|
const candidate = typeof req.body?.password === 'string' ? req.body.password : '';
|
|
259
476
|
if (!verifyPassword(candidate)) {
|
|
477
|
+
await recordFailedAttempt(req);
|
|
260
478
|
clearSessionCookie(req, res);
|
|
261
|
-
res.status(401).json({ error: 'Invalid
|
|
479
|
+
res.status(401).json({ error: 'Invalid credentials' });
|
|
262
480
|
return;
|
|
263
481
|
}
|
|
264
482
|
|
|
483
|
+
await clearRateLimit(req);
|
|
484
|
+
|
|
265
485
|
const previousToken = getTokenFromRequest(req);
|
|
266
486
|
if (previousToken) {
|
|
267
487
|
dropSession(previousToken);
|
|
@@ -272,11 +492,16 @@ export const createUiAuth = ({
|
|
|
272
492
|
};
|
|
273
493
|
|
|
274
494
|
const dispose = () => {
|
|
495
|
+
sessions.clear();
|
|
496
|
+
loginRateLimiter.clear();
|
|
275
497
|
if (cleanupTimer) {
|
|
276
498
|
clearInterval(cleanupTimer);
|
|
277
499
|
cleanupTimer = null;
|
|
278
500
|
}
|
|
279
|
-
|
|
501
|
+
if (rateLimitCleanupTimer) {
|
|
502
|
+
clearInterval(rateLimitCleanupTimer);
|
|
503
|
+
rateLimitCleanupTimer = null;
|
|
504
|
+
}
|
|
280
505
|
};
|
|
281
506
|
|
|
282
507
|
return {
|