aigo 1.1.1 → 1.2.0

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/README.md CHANGED
@@ -228,17 +228,46 @@ aigo uses multiple layers of security:
228
228
 
229
229
  1. **URL Token** - A random 4-character token in the URL path (`/t/<token>`)
230
230
  2. **Password** - A 6-character alphanumeric password (or custom password via `-P`)
231
- 3. **Session Lock** - Screen locks after inactivity (disabled by default, enable with `-T <mins>`)
232
- 4. **Auto Exit** - Session terminates after inactivity (disabled by default, enable with `-E <mins>`)
231
+ 3. **Brute-Force Protection** - Progressive delays and lockout after failed attempts
232
+ 4. **Session Lock** - Screen locks after inactivity (disabled by default, enable with `-T <mins>`)
233
+ 5. **Auto Exit** - Session terminates after inactivity (disabled by default, enable with `-E <mins>`)
233
234
 
234
235
  **Security features:**
235
236
  - Token required in URL (403 Forbidden without it)
236
237
  - Password required before terminal access
237
238
  - Password uses unambiguous characters (no 0/O, 1/l/I)
239
+ - Brute-force protection with progressive delays (see below)
238
240
  - Lock screen requires re-entering password after inactivity (when enabled)
239
241
  - Session auto-terminates and kills tmux after extended inactivity (when enabled)
240
242
  - HTTPS via tunnel (encrypted in transit)
241
243
 
244
+ ### Brute-Force Protection
245
+
246
+ aigo automatically protects against password brute-force attacks with:
247
+ - **Progressive delays** after 5 failed attempts
248
+ - **IP-based tracking** that persists across page refreshes
249
+ - **15-minute lockout** after 10 failed attempts
250
+
251
+ | Attempt | Behavior |
252
+ |---------|----------|
253
+ | 1-4 | Instant response |
254
+ | 5 | 1 second wait (input disabled, countdown shown) |
255
+ | 6 | 2 second wait |
256
+ | 7 | 4 second wait |
257
+ | 8 | 8 second wait |
258
+ | 9 | 16 second wait |
259
+ | 10+ | **IP locked out for 15 minutes** |
260
+
261
+ After 5 failed attempts, the password input is disabled and a countdown timer shows the remaining wait time. After 10 failed attempts, the IP address is locked out for 15 minutes - refreshing the page won't reset the counter.
262
+
263
+ Configure in `lib/config.js`:
264
+ ```javascript
265
+ maxAuthAttempts: 10, // Lock out IP after this many failures
266
+ authDelayThreshold: 5, // Start delays after this many failures
267
+ authBaseDelayMs: 1000, // Base delay (doubles each attempt)
268
+ authLockoutMins: 15, // Lockout duration after max attempts
269
+ ```
270
+
242
271
  **Recommendations:**
243
272
  - Use HTTPS (both ngrok and cloudflared provide this automatically)
244
273
  - Don't share your access URL or password publicly
@@ -278,7 +307,6 @@ When accessing aigo from a mobile device (phone or tablet), control buttons appe
278
307
  | **Mode** | `Shift+Tab` | Toggle between chat/edit modes in Claude Code or switch modes in Cursor Agent |
279
308
  | **Enter** | `Enter` | Send Enter key to confirm prompts or submit input |
280
309
  | **Stop** | `Ctrl+C` | Interrupt the current operation (stop running commands or cancel AI responses) |
281
- | **Exit** | Cleanup | Shows confirmation dialog, then kills tmux session and stops the server |
282
310
 
283
311
  These buttons are useful because mobile keyboards don't easily support modifier key combinations like Ctrl+C or Shift+Tab.
284
312
 
package/lib/config.js CHANGED
@@ -74,7 +74,23 @@ export const config = {
74
74
  maxReconnectAttempts: 10,
75
75
 
76
76
  /** Base delay between reconnection attempts (ms) */
77
- reconnectDelay: 2000
77
+ reconnectDelay: 2000,
78
+
79
+ // ============================================
80
+ // Brute-Force Protection
81
+ // ============================================
82
+
83
+ /** Maximum auth attempts before locking out the IP */
84
+ maxAuthAttempts: 10,
85
+
86
+ /** Start progressive delays after this many failures */
87
+ authDelayThreshold: 5,
88
+
89
+ /** Base delay in ms (doubles each attempt after threshold) */
90
+ authBaseDelayMs: 1000,
91
+
92
+ /** Lockout duration in minutes after max attempts reached */
93
+ authLockoutMins: 15
78
94
  };
79
95
 
80
96
  export default config;
package/lib/server.js CHANGED
@@ -6,10 +6,82 @@ import { fileURLToPath } from 'url';
6
6
  import { execSync } from 'child_process';
7
7
  import pty from 'node-pty';
8
8
  import { getTmuxPath, killSession } from './tmux.js';
9
+ import { config } from './config.js';
9
10
 
10
11
  const __filename = fileURLToPath(import.meta.url);
11
12
  const __dirname = path.dirname(__filename);
12
13
 
14
+ // IP-based brute-force tracking (persists across connections)
15
+ // Map<ip, { attempts: number, lockedUntil: number | null }>
16
+ const ipAttempts = new Map();
17
+
18
+ /**
19
+ * Get client IP from request, checking forwarded headers for tunnels
20
+ */
21
+ function getClientIp(req) {
22
+ // Check X-Forwarded-For header (used by ngrok, cloudflared, proxies)
23
+ const forwarded = req.headers['x-forwarded-for'];
24
+ if (forwarded) {
25
+ // Take the first IP in the chain (original client)
26
+ return forwarded.split(',')[0].trim();
27
+ }
28
+ // Fall back to direct connection IP
29
+ return req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown';
30
+ }
31
+
32
+ /**
33
+ * Check if IP is locked out
34
+ */
35
+ function isIpLockedOut(ip) {
36
+ const record = ipAttempts.get(ip);
37
+ if (!record) return false;
38
+ if (record.lockedUntil && Date.now() < record.lockedUntil) {
39
+ return true;
40
+ }
41
+ // Lockout expired, reset
42
+ if (record.lockedUntil && Date.now() >= record.lockedUntil) {
43
+ ipAttempts.delete(ip);
44
+ return false;
45
+ }
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Get remaining lockout time in ms
51
+ */
52
+ function getLockoutRemaining(ip) {
53
+ const record = ipAttempts.get(ip);
54
+ if (!record || !record.lockedUntil) return 0;
55
+ return Math.max(0, record.lockedUntil - Date.now());
56
+ }
57
+
58
+ /**
59
+ * Record failed attempt for IP
60
+ */
61
+ function recordFailedAttempt(ip) {
62
+ let record = ipAttempts.get(ip);
63
+ if (!record) {
64
+ record = { attempts: 0, lockedUntil: null };
65
+ ipAttempts.set(ip, record);
66
+ }
67
+ record.attempts++;
68
+
69
+ // Check if should lock out
70
+ if (record.attempts >= config.maxAuthAttempts) {
71
+ record.lockedUntil = Date.now() + (config.authLockoutMins * 60 * 1000);
72
+ console.log(`IP ${ip} locked out for ${config.authLockoutMins} minutes`);
73
+ }
74
+
75
+ return record;
76
+ }
77
+
78
+ /**
79
+ * Reset attempts for IP (on successful auth)
80
+ */
81
+ function resetIpAttempts(ip) {
82
+ ipAttempts.delete(ip);
83
+ }
84
+
13
85
  /**
14
86
  * Start the web server with WebSocket support
15
87
  */
@@ -84,8 +156,9 @@ export function startServer({ port, token, password, session, lockTimeout, exitT
84
156
  // WebSocket server
85
157
  const wss = new WebSocketServer({ server, path: `/ws/${token}` });
86
158
 
87
- wss.on('connection', (ws) => {
88
- console.log('WebSocket client connected');
159
+ wss.on('connection', (ws, req) => {
160
+ const clientIp = getClientIp(req);
161
+ console.log(`WebSocket client connected from ${clientIp}`);
89
162
  hasActiveConnection = true;
90
163
  updateActivity();
91
164
 
@@ -95,6 +168,24 @@ export function startServer({ port, token, password, session, lockTimeout, exitT
95
168
  let ptyProcess = null;
96
169
  let authenticated = false;
97
170
 
171
+ // Check if IP is already locked out
172
+ if (isIpLockedOut(clientIp)) {
173
+ const remainingMs = getLockoutRemaining(clientIp);
174
+ const remainingMins = Math.ceil(remainingMs / 60000);
175
+ console.log(`IP ${clientIp} is locked out for ${remainingMins} more minutes`);
176
+ ws.send(JSON.stringify({
177
+ type: 'auth_locked',
178
+ message: `Too many failed attempts. Try again in ${remainingMins} minute${remainingMins !== 1 ? 's' : ''}.`,
179
+ lockoutRemainingMs: remainingMs
180
+ }));
181
+ ws.close(1008, 'IP locked out');
182
+ return;
183
+ }
184
+
185
+ // Get current attempt count for this IP
186
+ const ipRecord = ipAttempts.get(clientIp);
187
+ const currentAttempts = ipRecord ? ipRecord.attempts : 0;
188
+
98
189
  // Send auth request
99
190
  ws.send(JSON.stringify({ type: 'auth_required' }));
100
191
 
@@ -107,10 +198,25 @@ export function startServer({ port, token, password, session, lockTimeout, exitT
107
198
  try {
108
199
  const parsed = JSON.parse(message);
109
200
  if (parsed.type === 'auth' && parsed.password) {
201
+ // Check if IP is locked out (could have been locked by another connection)
202
+ if (isIpLockedOut(clientIp)) {
203
+ const remainingMs = getLockoutRemaining(clientIp);
204
+ const remainingMins = Math.ceil(remainingMs / 60000);
205
+ console.log(`IP ${clientIp} is locked out`);
206
+ ws.send(JSON.stringify({
207
+ type: 'auth_locked',
208
+ message: `Too many failed attempts. Try again in ${remainingMins} minute${remainingMins !== 1 ? 's' : ''}.`,
209
+ lockoutRemainingMs: remainingMs
210
+ }));
211
+ ws.close(1008, 'IP locked out');
212
+ return;
213
+ }
214
+
110
215
  if (parsed.password === password) {
111
216
  authenticated = true;
217
+ resetIpAttempts(clientIp); // Clear failed attempts on success
112
218
  updateActivity();
113
- console.log('Client authenticated successfully');
219
+ console.log(`Client ${clientIp} authenticated successfully`);
114
220
 
115
221
  // Send auth success with config
116
222
  ws.send(JSON.stringify({
@@ -160,8 +266,33 @@ export function startServer({ port, token, password, session, lockTimeout, exitT
160
266
  ws.close(1011, 'PTY spawn failed');
161
267
  }
162
268
  } else {
163
- console.log('Client authentication failed');
164
- ws.send(JSON.stringify({ type: 'auth_failed' }));
269
+ // Authentication failed - use IP-based tracking
270
+ const record = recordFailedAttempt(clientIp);
271
+ const attemptsRemaining = config.maxAuthAttempts - record.attempts;
272
+ console.log(`Client ${clientIp} authentication failed (attempt ${record.attempts}/${config.maxAuthAttempts})`);
273
+
274
+ // Calculate delay for client-side countdown (after threshold)
275
+ let delayMs = 0;
276
+ if (record.attempts >= config.authDelayThreshold) {
277
+ delayMs = config.authBaseDelayMs * Math.pow(2, record.attempts - config.authDelayThreshold);
278
+ }
279
+
280
+ // Check if should lock out
281
+ if (record.attempts >= config.maxAuthAttempts) {
282
+ const lockoutMs = config.authLockoutMins * 60 * 1000;
283
+ ws.send(JSON.stringify({
284
+ type: 'auth_locked',
285
+ message: `Too many failed attempts. Try again in ${config.authLockoutMins} minutes.`,
286
+ lockoutRemainingMs: lockoutMs
287
+ }));
288
+ ws.close(1008, 'Too many failed attempts');
289
+ } else {
290
+ ws.send(JSON.stringify({
291
+ type: 'auth_failed',
292
+ attemptsRemaining: attemptsRemaining,
293
+ delayMs: delayMs // Client will show countdown and disable input
294
+ }));
295
+ }
165
296
  }
166
297
  return;
167
298
  }
@@ -192,11 +323,48 @@ export function startServer({ port, token, password, session, lockTimeout, exitT
192
323
 
193
324
  // Handle re-auth (after lock screen)
194
325
  if (parsed.type === 're-auth' && parsed.password) {
326
+ // Check if IP is locked out
327
+ if (isIpLockedOut(clientIp)) {
328
+ const remainingMs = getLockoutRemaining(clientIp);
329
+ const remainingMins = Math.ceil(remainingMs / 60000);
330
+ ws.send(JSON.stringify({
331
+ type: 'auth_locked',
332
+ message: `Too many failed attempts. Try again in ${remainingMins} minute${remainingMins !== 1 ? 's' : ''}.`,
333
+ lockoutRemainingMs: remainingMs
334
+ }));
335
+ ws.close(1008, 'IP locked out');
336
+ return;
337
+ }
338
+
195
339
  if (parsed.password === password) {
340
+ resetIpAttempts(clientIp);
196
341
  updateActivity();
197
342
  ws.send(JSON.stringify({ type: 'auth_success' }));
198
343
  } else {
199
- ws.send(JSON.stringify({ type: 'auth_failed' }));
344
+ const record = recordFailedAttempt(clientIp);
345
+ const attemptsRemaining = config.maxAuthAttempts - record.attempts;
346
+ console.log(`Re-auth failed for ${clientIp} (attempt ${record.attempts}/${config.maxAuthAttempts})`);
347
+
348
+ let delayMs = 0;
349
+ if (record.attempts >= config.authDelayThreshold) {
350
+ delayMs = config.authBaseDelayMs * Math.pow(2, record.attempts - config.authDelayThreshold);
351
+ }
352
+
353
+ if (record.attempts >= config.maxAuthAttempts) {
354
+ const lockoutMs = config.authLockoutMins * 60 * 1000;
355
+ ws.send(JSON.stringify({
356
+ type: 'auth_locked',
357
+ message: `Too many failed attempts. Try again in ${config.authLockoutMins} minutes.`,
358
+ lockoutRemainingMs: lockoutMs
359
+ }));
360
+ ws.close(1008, 'Too many failed attempts');
361
+ } else {
362
+ ws.send(JSON.stringify({
363
+ type: 'auth_failed',
364
+ attemptsRemaining: attemptsRemaining,
365
+ delayMs: delayMs
366
+ }));
367
+ }
200
368
  }
201
369
  return;
202
370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aigo",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Stream Claude Code to the web - run Claude remotely from any device",
5
5
  "type": "module",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -467,18 +467,6 @@
467
467
  color: var(--bg-primary);
468
468
  }
469
469
 
470
- /* Exit button */
471
- #mobile-exit-btn {
472
- color: #ff5555;
473
- border-color: #ff5555;
474
- }
475
-
476
- #mobile-exit-btn:hover,
477
- #mobile-exit-btn:active {
478
- background: #ff5555;
479
- color: white;
480
- }
481
-
482
470
  /* History button */
483
471
  #mobile-history-btn {
484
472
  color: #00ffff;
@@ -646,7 +634,6 @@
646
634
  <button id="mobile-mode-toggle" class="mobile-btn" title="Switch mode (Shift+Tab)">Mode</button>
647
635
  <button id="mobile-enter-btn" class="mobile-btn" title="Send Enter key">Enter</button>
648
636
  <button id="mobile-stop-btn" class="mobile-btn" title="Stop (Ctrl+C)">Stop</button>
649
- <button id="mobile-exit-btn" class="mobile-btn" title="Exit session">Exit</button>
650
637
  </div>
651
638
 
652
639
  <!-- History modal overlay -->
@@ -51,7 +51,6 @@
51
51
  const mobileToggleBtn = document.getElementById('mobile-mode-toggle');
52
52
  const mobileEnterBtn = document.getElementById('mobile-enter-btn');
53
53
  const mobileStopBtn = document.getElementById('mobile-stop-btn');
54
- const mobileExitBtn = document.getElementById('mobile-exit-btn');
55
54
  const mobileHistoryBtn = document.getElementById('mobile-history-btn');
56
55
  const historyOverlay = document.getElementById('history-overlay');
57
56
  const historyCloseBtn = document.getElementById('history-close-btn');
@@ -451,14 +450,6 @@
451
450
  }
452
451
  });
453
452
 
454
- // Mobile exit button handler (shows confirmation dialog)
455
- mobileExitBtn.addEventListener('click', () => {
456
- if (authenticated && !isLocked) {
457
- mobileButtonFeedback(mobileExitBtn);
458
- showExitConfirm();
459
- }
460
- });
461
-
462
453
  // Strip ANSI escape codes from text for clean display
463
454
  function stripAnsiCodes(text) {
464
455
  // Remove ANSI escape sequences (colors, cursor movement, etc.)
@@ -654,12 +645,93 @@
654
645
  }
655
646
 
656
647
  if (msg.type === 'auth_failed') {
657
- authError.textContent = 'Invalid password';
658
648
  if (!isLocked) {
659
649
  savedPassword = null;
660
650
  }
661
651
  passwordInput.value = '';
662
- passwordInput.focus();
652
+
653
+ // Check if there's a delay (brute-force protection kicked in)
654
+ if (msg.delayMs && msg.delayMs > 0) {
655
+ // Disable input and show countdown
656
+ passwordInput.disabled = true;
657
+ authBtn.disabled = true;
658
+
659
+ let remainingMs = msg.delayMs;
660
+ const updateCountdown = () => {
661
+ const seconds = Math.ceil(remainingMs / 1000);
662
+ authError.textContent = `Too many attempts. Wait ${seconds}s (${msg.attemptsRemaining} attempts remaining)`;
663
+ authError.style.color = '#ffaa00';
664
+ };
665
+ updateCountdown();
666
+
667
+ const countdownInterval = setInterval(() => {
668
+ remainingMs -= 100;
669
+ if (remainingMs <= 0) {
670
+ clearInterval(countdownInterval);
671
+ passwordInput.disabled = false;
672
+ authBtn.disabled = false;
673
+ authError.textContent = `Invalid password (${msg.attemptsRemaining} attempts remaining)`;
674
+ authError.style.color = '#ff5555';
675
+ passwordInput.focus();
676
+ } else {
677
+ updateCountdown();
678
+ }
679
+ }, 100);
680
+ } else {
681
+ // No delay, just show error with attempts remaining
682
+ let errorMsg = 'Invalid password';
683
+ if (typeof msg.attemptsRemaining === 'number') {
684
+ errorMsg += ` (${msg.attemptsRemaining} attempts remaining)`;
685
+ }
686
+ authError.textContent = errorMsg;
687
+ authError.style.color = '#ff5555';
688
+ passwordInput.focus();
689
+ }
690
+ return;
691
+ }
692
+
693
+ if (msg.type === 'auth_locked') {
694
+ // Locked out - too many attempts
695
+ passwordInput.disabled = true;
696
+ authBtn.disabled = true;
697
+ authBtn.textContent = 'Locked';
698
+ authError.style.color = '#ff5555';
699
+ if (!isLocked) {
700
+ savedPassword = null;
701
+ }
702
+
703
+ // Show countdown if lockout time is provided
704
+ if (msg.lockoutRemainingMs && msg.lockoutRemainingMs > 0) {
705
+ let remainingMs = msg.lockoutRemainingMs;
706
+ const updateLockoutCountdown = () => {
707
+ const mins = Math.floor(remainingMs / 60000);
708
+ const secs = Math.ceil((remainingMs % 60000) / 1000);
709
+ if (mins > 0) {
710
+ authError.textContent = `Locked out. Try again in ${mins}m ${secs}s`;
711
+ } else {
712
+ authError.textContent = `Locked out. Try again in ${secs}s`;
713
+ }
714
+ };
715
+ updateLockoutCountdown();
716
+
717
+ const lockoutInterval = setInterval(() => {
718
+ remainingMs -= 1000;
719
+ if (remainingMs <= 0) {
720
+ clearInterval(lockoutInterval);
721
+ // Re-enable and allow retry
722
+ passwordInput.disabled = false;
723
+ authBtn.disabled = false;
724
+ authBtn.textContent = 'Connect';
725
+ authError.textContent = 'You can try again now';
726
+ authError.style.color = '#00ff88';
727
+ passwordInput.focus();
728
+ } else {
729
+ updateLockoutCountdown();
730
+ }
731
+ }, 1000);
732
+ } else {
733
+ authError.textContent = msg.message || 'Too many failed attempts. Please reconnect.';
734
+ }
663
735
  return;
664
736
  }
665
737