@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/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-C2ViUvl2.js"></script>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchamber/web",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
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(/\/+$/, '')}/config`, {
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
- if (res.ok) return true;
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
  }
@@ -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 password', locked: true });
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
- sessions.clear();
501
+ if (rateLimitCleanupTimer) {
502
+ clearInterval(rateLimitCleanupTimer);
503
+ rateLimitCleanupTimer = null;
504
+ }
280
505
  };
281
506
 
282
507
  return {