lobstakit-cloud 1.0.3 → 1.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobstakit-cloud",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "LobstaKit Cloud — Setup wizard and management for LobstaCloud gateways",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -600,9 +600,9 @@ async function launchBot() {
600
600
  return;
601
601
  }
602
602
 
603
- if (!password || password.length < 6) {
603
+ if (!password || password.length < 8) {
604
604
  if (passwordErrorEl) {
605
- passwordErrorEl.textContent = 'Password must be at least 6 characters';
605
+ passwordErrorEl.textContent = 'Password must be at least 8 characters';
606
606
  passwordErrorEl.classList.remove('hidden');
607
607
  }
608
608
  return;
@@ -645,10 +645,14 @@ async function launchBot() {
645
645
  if (passwordEl && passwordEl.value) {
646
646
  try {
647
647
  const email = emailEl ? emailEl.value.trim() : '';
648
+ const setupPayload = { email, password: passwordEl.value };
649
+ if (provisionData && provisionData.setupToken) {
650
+ setupPayload.setupToken = provisionData.setupToken;
651
+ }
648
652
  const authRes = await fetch('/api/auth/setup', {
649
653
  method: 'POST',
650
654
  headers: { 'Content-Type': 'application/json' },
651
- body: JSON.stringify({ email, password: passwordEl.value })
655
+ body: JSON.stringify(setupPayload)
652
656
  });
653
657
  const authData = await authRes.json();
654
658
  if (authData.status === 'ok') {
package/server.js CHANGED
@@ -50,6 +50,44 @@ function saveLobstaKitConfig(cfg) {
50
50
  // Active sessions (in-memory, cleared on restart)
51
51
  const activeSessions = new Map();
52
52
 
53
+ // ─── Email Masking ───────────────────────────────────────────────────────────
54
+ function maskEmail(email) {
55
+ if (!email || !email.includes('@')) return email;
56
+ const [local, domain] = email.split('@');
57
+ const maskedLocal = local[0] + '***';
58
+ const domainParts = domain.split('.');
59
+ const maskedDomain = domainParts[0][0] + '***.' + domainParts.slice(1).join('.');
60
+ return maskedLocal + '@' + maskedDomain;
61
+ }
62
+
63
+ // ─── Rate Limiting (Login Brute Force Protection) ────────────────────────────
64
+ const loginAttempts = new Map(); // ip -> { count, firstAttempt }
65
+ const RATE_LIMIT_MAX = 5;
66
+ const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
67
+
68
+ // Clean up stale rate limit entries every 5 minutes
69
+ setInterval(() => {
70
+ const now = Date.now();
71
+ for (const [ip, data] of loginAttempts.entries()) {
72
+ if (now - data.firstAttempt > RATE_LIMIT_WINDOW_MS) {
73
+ loginAttempts.delete(ip);
74
+ }
75
+ }
76
+ }, 5 * 60 * 1000);
77
+
78
+ // ─── Session Expiration ──────────────────────────────────────────────────────
79
+ const SESSION_MAX_LIFETIME_MS = 24 * 60 * 60 * 1000; // 24 hours
80
+
81
+ // Clean up expired sessions every 30 minutes
82
+ setInterval(() => {
83
+ const now = Date.now();
84
+ for (const [token, session] of activeSessions.entries()) {
85
+ if (now - session.created > SESSION_MAX_LIFETIME_MS) {
86
+ activeSessions.delete(token);
87
+ }
88
+ }
89
+ }, 30 * 60 * 1000);
90
+
53
91
  // Auth middleware — protect API routes (except auth endpoints, health, and static files)
54
92
  function requireAuth(req, res, next) {
55
93
  const publicPaths = ['/api/auth/login', '/api/auth/setup', '/api/auth/status', '/api/health', '/api/provision'];
@@ -66,6 +104,12 @@ function requireAuth(req, res, next) {
66
104
  if (!token || !activeSessions.has(token)) {
67
105
  return res.status(401).json({ error: 'Authentication required' });
68
106
  }
107
+ // Check session expiration (24h max lifetime)
108
+ const session = activeSessions.get(token);
109
+ if (Date.now() - session.created > SESSION_MAX_LIFETIME_MS) {
110
+ activeSessions.delete(token);
111
+ return res.status(401).json({ error: 'Session expired. Please log in again.' });
112
+ }
69
113
  next();
70
114
  }
71
115
 
@@ -84,11 +128,18 @@ app.post('/api/auth/setup', (req, res) => {
84
128
  if (lobstaConfig.passwordHash) {
85
129
  return res.status(403).json({ error: 'Account already set up. Use /api/auth/change to update.' });
86
130
  }
87
- let { email, password } = req.body;
131
+ let { email, password, setupToken } = req.body;
132
+
133
+ // Validate setup token if provisioned (prevents setup race condition — MEDIUM-3)
134
+ const provision = getProvisionData();
135
+ if (provision && provision.setupToken) {
136
+ if (!setupToken || setupToken !== provision.setupToken) {
137
+ return res.status(403).json({ error: 'Invalid setup token. Please use the setup link provided in your welcome email.' });
138
+ }
139
+ }
88
140
 
89
141
  // Fallback to provision email if not provided
90
142
  if (!email) {
91
- const provision = getProvisionData();
92
143
  if (provision && provision.email) {
93
144
  email = provision.email;
94
145
  }
@@ -97,8 +148,8 @@ app.post('/api/auth/setup', (req, res) => {
97
148
  if (!email || !email.includes('@')) {
98
149
  return res.status(400).json({ error: 'A valid email address is required' });
99
150
  }
100
- if (!password || password.length < 6) {
101
- return res.status(400).json({ error: 'Password must be at least 6 characters' });
151
+ if (!password || password.length < 8) {
152
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
102
153
  }
103
154
  const salt = crypto.randomBytes(16).toString('hex');
104
155
  const hash = crypto.scryptSync(password, salt, 64).toString('hex');
@@ -122,21 +173,47 @@ app.post('/api/auth/login', (req, res) => {
122
173
  if (!lobstaConfig.passwordHash) {
123
174
  return res.status(400).json({ error: 'No account set up. Complete setup first.' });
124
175
  }
176
+
177
+ // Rate limiting — check failed attempts for this IP (HIGH-5)
178
+ const clientIp = req.ip || req.connection.remoteAddress;
179
+ const attempts = loginAttempts.get(clientIp);
180
+ if (attempts && attempts.count >= RATE_LIMIT_MAX) {
181
+ const elapsed = Date.now() - attempts.firstAttempt;
182
+ if (elapsed < RATE_LIMIT_WINDOW_MS) {
183
+ const retryAfter = Math.ceil((RATE_LIMIT_WINDOW_MS - elapsed) / 1000);
184
+ res.set('Retry-After', String(retryAfter));
185
+ return res.status(429).json({ error: 'Too many login attempts. Please try again later.', retryAfter });
186
+ }
187
+ // Window expired, reset
188
+ loginAttempts.delete(clientIp);
189
+ }
190
+
125
191
  const { email, password } = req.body;
126
192
 
127
193
  // Verify email matches (case-insensitive)
128
194
  const storedEmail = (lobstaConfig.email || '').toLowerCase().trim();
129
195
  const providedEmail = (email || '').toLowerCase().trim();
130
196
  if (!providedEmail || providedEmail !== storedEmail) {
197
+ // Track failed attempt
198
+ const current = loginAttempts.get(clientIp) || { count: 0, firstAttempt: Date.now() };
199
+ current.count++;
200
+ loginAttempts.set(clientIp, current);
131
201
  return res.status(401).json({ error: 'Incorrect email or password' });
132
202
  }
133
203
 
134
204
  // Verify password
135
205
  const hash = crypto.scryptSync(password || '', lobstaConfig.passwordSalt, 64).toString('hex');
136
206
  if (hash !== lobstaConfig.passwordHash) {
207
+ // Track failed attempt
208
+ const current = loginAttempts.get(clientIp) || { count: 0, firstAttempt: Date.now() };
209
+ current.count++;
210
+ loginAttempts.set(clientIp, current);
137
211
  return res.status(401).json({ error: 'Incorrect email or password' });
138
212
  }
139
213
 
214
+ // Successful login — clear rate limit for this IP
215
+ loginAttempts.delete(clientIp);
216
+
140
217
  const sessionToken = crypto.randomBytes(32).toString('hex');
141
218
  activeSessions.set(sessionToken, { created: Date.now(), ip: req.ip });
142
219
 
@@ -160,15 +237,25 @@ app.post('/api/auth/logout', (req, res) => {
160
237
  app.get('/api/auth/status', (req, res) => {
161
238
  const lobstaConfig = getLobstaKitConfig();
162
239
  const token = req.headers.authorization?.replace('Bearer ', '');
163
- const isAuthenticated = token && activeSessions.has(token);
240
+ let isAuthenticated = !!(token && activeSessions.has(token));
241
+
242
+ // Check session expiration for auth status
243
+ if (isAuthenticated) {
244
+ const session = activeSessions.get(token);
245
+ if (session && Date.now() - session.created > SESSION_MAX_LIFETIME_MS) {
246
+ activeSessions.delete(token);
247
+ isAuthenticated = false;
248
+ }
249
+ }
250
+
164
251
  const response = {
165
252
  setupComplete: !!lobstaConfig.setupComplete,
166
253
  passwordSet: !!lobstaConfig.passwordHash,
167
254
  authenticated: isAuthenticated
168
255
  };
169
- // Include email for login pre-fill (always it's just an email, not a secret)
256
+ // Return full email only when authenticated; mask otherwise (HIGH-4)
170
257
  if (lobstaConfig.email) {
171
- response.email = lobstaConfig.email;
258
+ response.email = isAuthenticated ? lobstaConfig.email : maskEmail(lobstaConfig.email);
172
259
  }
173
260
  res.json(response);
174
261
  });
@@ -197,7 +284,7 @@ app.post('/api/auth/change', (req, res) => {
197
284
 
198
285
  // Update password if provided
199
286
  if (newPassword) {
200
- if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' });
287
+ if (newPassword.length < 8) return res.status(400).json({ error: 'New password must be at least 8 characters' });
201
288
  const salt = crypto.randomBytes(16).toString('hex');
202
289
  lobstaConfig.passwordHash = crypto.scryptSync(newPassword, salt, 64).toString('hex');
203
290
  lobstaConfig.passwordSalt = salt;
@@ -217,7 +304,15 @@ app.post('/api/auth/change', (req, res) => {
217
304
  app.get('/api/provision', (req, res) => {
218
305
  const provision = getProvisionData();
219
306
  if (provision) {
220
- res.json({ provisioned: true, email: provision.email || null, subdomain: provision.subdomain || null, plan: provision.plan || null });
307
+ const token = req.headers.authorization?.replace('Bearer ', '');
308
+ const isAuthenticated = token && activeSessions.has(token);
309
+ res.json({
310
+ provisioned: true,
311
+ email: isAuthenticated ? (provision.email || null) : maskEmail(provision.email || ''),
312
+ subdomain: provision.subdomain || null,
313
+ plan: provision.plan || null,
314
+ setupToken: provision.setupToken || null
315
+ });
221
316
  } else {
222
317
  res.json({ provisioned: false });
223
318
  }
@@ -977,10 +1072,10 @@ app.post('/api/tailscale/connect', (req, res) => {
977
1072
  return res.status(400).json({ error: 'Auth key is required' });
978
1073
  }
979
1074
 
980
- // Validate auth key format (tskey-auth-...)
981
- if (!authKey.startsWith('tskey-auth-') && !authKey.startsWith('tskey-')) {
1075
+ // Strict validation: Tailscale auth keys are alphanumeric + hyphens only (CRITICAL-1: prevent command injection)
1076
+ if (!/^tskey-auth-[a-zA-Z0-9-]+$/.test(authKey)) {
982
1077
  return res.status(400).json({
983
- error: 'Invalid auth key format. Tailscale auth keys start with tskey-auth-'
1078
+ error: 'Invalid auth key format. Tailscale auth keys start with tskey-auth- and contain only alphanumeric characters and hyphens.'
984
1079
  });
985
1080
  }
986
1081
 
@@ -1007,6 +1102,7 @@ app.post('/api/tailscale/connect', (req, res) => {
1007
1102
 
1008
1103
  // Run tailscale up with the auth key
1009
1104
  try {
1105
+ // SAFE: authKey validated above with /^tskey-auth-[a-zA-Z0-9-]+$/ — no injection possible
1010
1106
  execSync(`tailscale up --authkey=${authKey} --accept-routes --accept-dns`, {
1011
1107
  stdio: 'pipe',
1012
1108
  timeout: 30000