bloby-bot 0.53.10 → 0.54.10

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.
@@ -11,7 +11,7 @@ import { log } from '../shared/logger.js';
11
11
  import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel, startNamedTunnel, restartNamedTunnel } from './tunnel.js';
12
12
  import { createWorkerApp } from '../worker/index.js';
13
13
  import { closeDb, getSession, getSetting } from '../worker/db.js';
14
- import { spawnBackend, stopBackend, restartBackend, getBackendPort, isBackendAlive, isBackendStopping, isBackendDead, readBackendLogTail, setBackendEnv } from './backend.js';
14
+ import { spawnBackend, stopBackend, restartBackend, getBackendPort, isBackendAlive, isBackendStopping, isBackendDead, readBackendLogTail, setBackendEnv, setBackendGiveUpHandler } from './backend.js';
15
15
  import { handleAgentQuery, type AgentQueryRequest } from './agent-api.js';
16
16
  import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
17
17
  import {
@@ -662,6 +662,32 @@ export async function startSupervisor() {
662
662
  res.setHeader('Content-Type', 'application/json');
663
663
  res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
664
664
 
665
+ // ── Loopback-only guard for channel MUTATION endpoints ──
666
+ // These are only ever called by the local agent over loopback (curl localhost:7400),
667
+ // never legitimately from the public Cloudflare tunnel. Without this, an unauthenticated
668
+ // remote request could set mode/admins or flip allowOthersToTrigger and seize the agent
669
+ // (it can run Bash/edit files). Same guard the Agent API uses: cloudflared forwards over
670
+ // loopback so the IP check alone is a no-op behind the relay — we also reject any request
671
+ // carrying cloudflared's cf-connecting-ip/cf-ray (tunnel-origin) headers. Reads (status,
672
+ // qr, qr-page) and alexa/handle (relay-origin, secret-gated) deliberately stay public.
673
+ const WA_MUTATION_ROUTES = new Set([
674
+ 'POST /api/channels/whatsapp/configure',
675
+ 'POST /api/channels/whatsapp/connect',
676
+ 'POST /api/channels/whatsapp/disconnect',
677
+ 'POST /api/channels/whatsapp/logout',
678
+ 'POST /api/channels/whatsapp/pairing-code',
679
+ 'POST /api/channels/send',
680
+ ]);
681
+ if (WA_MUTATION_ROUTES.has(`${req.method} ${channelPath}`)) {
682
+ const remoteIp = req.socket.remoteAddress || '';
683
+ const isLoopback = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
684
+ if (!isLoopback || req.headers['cf-connecting-ip'] || req.headers['cf-ray']) {
685
+ res.writeHead(403);
686
+ res.end(JSON.stringify({ ok: false, error: 'This channel endpoint is localhost-only.' }));
687
+ return;
688
+ }
689
+ }
690
+
665
691
  // GET /api/channels/status — all channel statuses
666
692
  if (req.method === 'GET' && channelPath === '/api/channels/status') {
667
693
  res.writeHead(200);
@@ -952,6 +978,7 @@ ${!connected ? `<script>
952
978
  if (data.admins !== undefined) cfg.channels.whatsapp.admins = data.admins;
953
979
  if (data.skill !== undefined) cfg.channels.whatsapp.skill = data.skill;
954
980
  if (data.allowGroups !== undefined) cfg.channels.whatsapp.allowGroups = !!data.allowGroups;
981
+ if (data.allowOthersToTrigger !== undefined) cfg.channels.whatsapp.allowOthersToTrigger = !!data.allowOthersToTrigger;
955
982
  saveConfig(cfg);
956
983
  res.writeHead(200);
957
984
  res.end(JSON.stringify({ ok: true, config: cfg.channels.whatsapp }));
@@ -1327,11 +1354,31 @@ mint();
1327
1354
  return;
1328
1355
  }
1329
1356
 
1330
- // POST /api/env — write env vars to workspace .env (used by chat EnvForm)
1357
+ // POST /api/env — write env vars to workspace .env (used by chat EnvForm).
1331
1358
  if (req.method === 'POST' && req.url === '/api/env') {
1359
+ // This route is intercepted before the /api worker gate, so gate it here: writing the
1360
+ // workspace .env (which the backend loads) is a privileged action — require the portal token
1361
+ // when a password is set. Internal supervisor calls (x-internal) bypass. The chat EnvForm
1362
+ // sends the token via authFetch.
1363
+ if (req.headers['x-internal'] !== internalSecret && await isAuthRequired()) {
1364
+ const authHeader = req.headers['authorization'];
1365
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
1366
+ if (!token || !(await validateToken(token))) {
1367
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1368
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1369
+ return;
1370
+ }
1371
+ }
1332
1372
  let body = '';
1333
- req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
1373
+ let bodyBytes = 0;
1374
+ let tooLarge = false;
1375
+ req.on('data', (chunk: Buffer) => {
1376
+ bodyBytes += chunk.length;
1377
+ if (bodyBytes > 1_000_000) { tooLarge = true; req.destroy(); return; } // .env is tiny; 1MB is generous
1378
+ body += chunk.toString();
1379
+ });
1334
1380
  req.on('end', () => {
1381
+ if (tooLarge) return;
1335
1382
  try {
1336
1383
  const { vars } = JSON.parse(body) as { vars: Record<string, string> };
1337
1384
  if (!vars || typeof vars !== 'object') {
@@ -1348,7 +1395,20 @@ mint();
1348
1395
 
1349
1396
  for (const [rawKey, rawValue] of Object.entries(vars)) {
1350
1397
  const key = rawKey.trim();
1398
+ // Validate the key as a real env var name; reject anything that could inject extra
1399
+ // lines or break .env parsing.
1400
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
1401
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1402
+ res.end(JSON.stringify({ error: `Invalid env var name: ${rawKey}` }));
1403
+ return;
1404
+ }
1351
1405
  const value = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
1406
+ // Reject embedded newlines/CR in the value — they'd inject arbitrary extra .env lines.
1407
+ if (typeof value === 'string' && /[\r\n]/.test(value)) {
1408
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1409
+ res.end(JSON.stringify({ error: `Invalid value for ${key}: must not contain newlines` }));
1410
+ return;
1411
+ }
1352
1412
  // Find existing line for this key (supports KEY=val, KEY="val", KEY='val')
1353
1413
  const idx = lines.findIndex((l) => {
1354
1414
  const trimmed = l.trim();
@@ -1380,20 +1440,29 @@ mint();
1380
1440
  if (req.url?.startsWith('/api/agent/')) {
1381
1441
  const agentPath = req.url.split('?')[0];
1382
1442
 
1383
- // Localhost-only guard
1443
+ // Localhost-only guard. NOTE: cloudflared connects over loopback, so a tunneled request also
1444
+ // arrives as 127.0.0.1 — this IP check alone is a no-op behind the relay. We additionally
1445
+ // reject any request carrying cloudflared's cf-connecting-ip header (tunnel-origin traffic);
1446
+ // the real defense is the 256-bit agent secret below. The Agent API is only ever called by
1447
+ // the local workspace backend (loopback), never legitimately from the public tunnel.
1384
1448
  const remoteIp = req.socket.remoteAddress || '';
1385
- if (remoteIp !== '127.0.0.1' && remoteIp !== '::1' && remoteIp !== '::ffff:127.0.0.1') {
1449
+ const isLoopback = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
1450
+ if (!isLoopback || req.headers['cf-connecting-ip'] || req.headers['cf-ray']) {
1386
1451
  res.setHeader('Content-Type', 'application/json');
1387
1452
  res.writeHead(403);
1388
1453
  res.end(JSON.stringify({ ok: false, error: 'Agent API is localhost-only.' }));
1389
1454
  return;
1390
1455
  }
1391
1456
 
1392
- // Auth: x-agent-secret header (query param fallback for EventSource which cannot set headers)
1457
+ // Auth: x-agent-secret header (query param fallback for EventSource which cannot set headers).
1458
+ // Constant-time compare (length-guarded) so the secret can't be recovered via timing.
1393
1459
  const urlObj = new URL(req.url, `http://${req.headers.host}`);
1394
1460
  const headerSecret = req.headers['x-agent-secret'];
1395
1461
  const querySecret = urlObj.searchParams.get('secret');
1396
- if (headerSecret !== agentSecret && querySecret !== agentSecret) {
1462
+ const presented = typeof headerSecret === 'string' ? headerSecret : (querySecret || '');
1463
+ const secretOk = presented.length === agentSecret.length &&
1464
+ crypto.timingSafeEqual(Buffer.from(presented), Buffer.from(agentSecret));
1465
+ if (!secretOk) {
1397
1466
  res.setHeader('Content-Type', 'application/json');
1398
1467
  res.writeHead(401);
1399
1468
  res.end(JSON.stringify({ ok: false, error: 'Invalid or missing x-agent-secret.' }));
@@ -1929,6 +1998,11 @@ mint();
1929
1998
  // An 'error' event with no listener is rethrown by Node as an uncaught exception,
1930
1999
  // which would crash the whole supervisor. ws still tears down + fires 'close'.
1931
2000
  ws.on('error', (err: any) => console.warn(`[app-ws] socket error: ${err?.message || err}`));
2001
+ // Liveness: a half-open socket (mobile/Wi-Fi drop behind the tunnel) never fires 'close', so
2002
+ // its chat subscription + maps would leak and broadcastBloby would keep writing to it. The
2003
+ // heartbeat below pings; a peer that misses a pong is terminated (which fires 'close' → cleanup).
2004
+ (ws as any).isAlive = true;
2005
+ ws.on('pong', () => { (ws as any).isAlive = true; });
1932
2006
 
1933
2007
  // Per-WS chat subscription: when the client opts in, this WS joins chatSubscribers
1934
2008
  // and receives every bot:* / chat:* event the dashboard widget does. SSE through the
@@ -2203,6 +2277,8 @@ mint();
2203
2277
  // See appWss above: a listener-less 'error' event would crash the supervisor and kill
2204
2278
  // chat for everyone (G1). ws still fires 'close' afterward, so map cleanup still runs.
2205
2279
  ws.on('error', (err: any) => log.warn(`[bloby-ws] socket error: ${err?.message || err}`));
2280
+ (ws as any).isAlive = true;
2281
+ ws.on('pong', () => { (ws as any).isAlive = true; });
2206
2282
  let convId = Math.random().toString(36).slice(2) + Date.now().toString(36);
2207
2283
  conversations.set(ws, []);
2208
2284
 
@@ -2730,6 +2806,12 @@ mint();
2730
2806
  }
2731
2807
  }
2732
2808
 
2809
+ // Tell the live chat when the backend gives up — the dashboard interstitial covers page loads,
2810
+ // but an already-open chat client gets an explicit event it can surface ("ask me to fix the backend").
2811
+ setBackendGiveUpHandler(() => {
2812
+ broadcastBloby('backend:failed', { message: 'The workspace backend crashed and could not restart. Ask Bloby to fix it.' });
2813
+ });
2814
+
2733
2815
  // Spawn backend (worker runs in-process)
2734
2816
  spawnBackend(backendPort);
2735
2817
 
@@ -2902,6 +2984,20 @@ mint();
2902
2984
  armBackendWatcher();
2903
2985
  armWorkspaceWatcher();
2904
2986
 
2987
+ // WebSocket liveness heartbeat — ping the app + chat WS clients every 30s and terminate any
2988
+ // that missed the previous pong (half-open sockets that never fired 'close'). Terminating fires
2989
+ // 'close', which runs the existing map/subscription cleanup. Scoped to our two WSS only (Vite's
2990
+ // HMR socket is separate and managed by Vite). Cleared in shutdown().
2991
+ const wsHeartbeat = setInterval(() => {
2992
+ for (const wss of [blobyWss, appWss]) {
2993
+ for (const ws of wss.clients) {
2994
+ if ((ws as any).isAlive === false) { try { ws.terminate(); } catch {} continue; }
2995
+ (ws as any).isAlive = false;
2996
+ try { ws.ping(); } catch {}
2997
+ }
2998
+ }
2999
+ }, 30_000);
3000
+
2905
3001
  // Tunnel
2906
3002
  let tunnelUrl: string | null = null;
2907
3003
 
@@ -3064,6 +3160,7 @@ mint();
3064
3160
  stopScheduler();
3065
3161
  backendWatcher?.close();
3066
3162
  workspaceWatcher?.close();
3163
+ clearInterval(wsHeartbeat);
3067
3164
  if (backendRestartTimer) clearTimeout(backendRestartTimer);
3068
3165
  if (watchdogInterval) clearInterval(watchdogInterval);
3069
3166
  stopHeartbeat();