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 +1 -1
- package/public/js/setup.js +7 -3
- package/server.js +108 -12
package/package.json
CHANGED
package/public/js/setup.js
CHANGED
|
@@ -600,9 +600,9 @@ async function launchBot() {
|
|
|
600
600
|
return;
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
-
if (!password || password.length <
|
|
603
|
+
if (!password || password.length < 8) {
|
|
604
604
|
if (passwordErrorEl) {
|
|
605
|
-
passwordErrorEl.textContent = 'Password must be at least
|
|
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(
|
|
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 <
|
|
101
|
-
return res.status(400).json({ error: 'Password must be at least
|
|
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
|
-
|
|
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
|
-
//
|
|
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 <
|
|
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
|
-
|
|
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
|
-
//
|
|
981
|
-
if (
|
|
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
|