cli-tunnel 1.1.0 → 1.2.0-beta.1

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.js CHANGED
@@ -29,40 +29,45 @@ const GREEN = '\x1b[32m';
29
29
  const YELLOW = '\x1b[33m';
30
30
  // ─── Parse args ─────────────────────────────────────────────
31
31
  const args = process.argv.slice(2);
32
- if (args.includes('--help') || args.includes('-h') || args.length === 0) {
32
+ if (args.includes('--help') || args.includes('-h')) {
33
33
  console.log(`
34
34
  ${BOLD}cli-tunnel${RESET} — Tunnel any CLI app to your phone
35
35
 
36
36
  ${BOLD}Usage:${RESET}
37
37
  cli-tunnel [options] <command> [args...]
38
+ cli-tunnel # hub mode — sessions dashboard only
38
39
 
39
40
  ${BOLD}Options:${RESET}
40
- --tunnel Enable remote access via devtunnel
41
+ --local Disable devtunnel (localhost only)
41
42
  --port <n> Bridge port (default: random)
42
43
  --name <name> Session name (shown in dashboard)
44
+ --replay Enable replay buffer (off by default)
43
45
  --help, -h Show this help
44
46
 
45
47
  ${BOLD}Examples:${RESET}
46
- cli-tunnel copilot
47
- cli-tunnel --tunnel copilot --yolo
48
- cli-tunnel --tunnel copilot --model claude-sonnet-4 --agent squad
49
- cli-tunnel --tunnel --name wizard copilot --allow-all
50
- cli-tunnel --tunnel python -i
51
- cli-tunnel --tunnel htop
48
+ cli-tunnel copilot --yolo # tunnel + run copilot
49
+ cli-tunnel copilot --model claude-sonnet-4 --agent squad
50
+ cli-tunnel k9s # tunnel + run k9s
51
+ cli-tunnel python -i # tunnel + run python
52
+ cli-tunnel --name wizard copilot # named session
53
+ cli-tunnel --local copilot --yolo # localhost only, no devtunnel
54
+ cli-tunnel # hub: see all active sessions
52
55
 
53
- Any flags after the command name pass through to the underlying
54
- app. cli-tunnel's own flags (--tunnel, --port, --name) must come
55
- before the command.
56
+ Devtunnel is enabled by default. All flags after the command name
57
+ pass through to the underlying app. cli-tunnel's own flags
58
+ (--local, --port, --name) must come before the command.
56
59
  `);
57
60
  process.exit(0);
58
61
  }
59
- const hasTunnel = args.includes('--tunnel');
62
+ const hasLocal = args.includes('--local');
63
+ const hasTunnel = !hasLocal;
64
+ const hasReplay = args.includes('--replay');
60
65
  const portIdx = args.indexOf('--port');
61
66
  const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1], 10) : 0;
62
67
  const nameIdx = args.indexOf('--name');
63
68
  const sessionName = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : '';
64
69
  // Everything that's not our flags is the command
65
- const ourFlags = new Set(['--tunnel', '--port', '--name']);
70
+ const ourFlags = new Set(['--local', '--tunnel', '--port', '--name', '--replay']);
66
71
  const cmdArgs = [];
67
72
  let skip = false;
68
73
  for (let i = 0; i < args.length; i++) {
@@ -70,20 +75,18 @@ for (let i = 0; i < args.length; i++) {
70
75
  skip = false;
71
76
  continue;
72
77
  }
73
- if (ourFlags.has(args[i]) && args[i] !== '--tunnel') {
78
+ if (args[i] === '--port' || args[i] === '--name') {
74
79
  skip = true;
75
80
  continue;
76
81
  }
77
- if (args[i] === '--tunnel')
82
+ if (args[i] === '--local' || args[i] === '--tunnel' || args[i] === '--replay')
78
83
  continue;
79
84
  cmdArgs.push(args[i]);
80
85
  }
81
- if (cmdArgs.length === 0) {
82
- console.error('Error: no command specified. Run cli-tunnel --help for usage.');
83
- process.exit(1);
84
- }
85
- const command = cmdArgs[0];
86
- const commandArgs = cmdArgs.slice(1);
86
+ // Hub mode no command, just show sessions dashboard
87
+ const hubMode = cmdArgs.length === 0;
88
+ const command = hubMode ? '' : cmdArgs[0];
89
+ const commandArgs = hubMode ? [] : cmdArgs.slice(1);
87
90
  const cwd = process.cwd();
88
91
  // ─── Tunnel helpers ─────────────────────────────────────────
89
92
  function sanitizeLabel(l) {
@@ -103,18 +106,85 @@ function getGitInfo() {
103
106
  }
104
107
  // ─── Security: Session token for WebSocket auth ────────────
105
108
  const sessionToken = crypto.randomUUID();
109
+ // ─── F-18: Session TTL (24 hours) ──────────────────────────
110
+ const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
111
+ const sessionCreatedAt = Date.now();
112
+ // ─── F-02: One-time ticket store for WebSocket auth ────────
113
+ const tickets = new Map();
114
+ // #30: Ticket GC — clean expired tickets every 30s
115
+ setInterval(() => {
116
+ const now = Date.now();
117
+ for (const [id, t] of tickets) {
118
+ if (t.expires < now)
119
+ tickets.delete(id);
120
+ }
121
+ }, 30000);
106
122
  // ─── Security: Redact secrets from replay events ────────────
107
123
  function redactSecrets(text) {
108
- return text.replace(/(?:token|secret|key|password|credential|authorization)[\s:="']+[^\s"']{8,}/gi, '$& [REDACTED]');
124
+ return text
125
+ // Generic patterns: key=value, key: value, key="value"
126
+ .replace(/(?:token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing)[\s:="']+\S{8,}/gi, '[REDACTED]')
127
+ // OpenAI keys
128
+ .replace(/sk-[a-zA-Z0-9]{20,}/g, '[REDACTED]')
129
+ // GitHub tokens
130
+ .replace(/gh[ps]_[a-zA-Z0-9]{36,}/g, '[REDACTED]')
131
+ // AWS keys
132
+ .replace(/AKIA[A-Z0-9]{16}/g, '[REDACTED]')
133
+ // Azure connection strings
134
+ .replace(/DefaultEndpointsProtocol=[^;\s]{20,}/gi, '[REDACTED]')
135
+ .replace(/AccountKey=[^;\s]{20,}/gi, 'AccountKey=[REDACTED]')
136
+ // Database URLs
137
+ .replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
138
+ // Bearer tokens in headers
139
+ .replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]');
109
140
  }
110
141
  // ─── Bridge server ──────────────────────────────────────────
111
142
  const acpEventLog = [];
112
143
  const connections = new Map();
144
+ // #10: Session TTL enforcement — periodically close expired connections
145
+ setInterval(() => {
146
+ if (Date.now() - sessionCreatedAt > SESSION_TTL) {
147
+ for (const [id, ws] of connections) {
148
+ ws.close(1000, 'Session expired');
149
+ connections.delete(id);
150
+ }
151
+ }
152
+ }, 60000);
113
153
  const server = http.createServer((req, res) => {
154
+ // F-18: Session expiry check for API routes
155
+ if (!hubMode && req.url?.startsWith('/api/') && Date.now() - sessionCreatedAt > SESSION_TTL) {
156
+ res.writeHead(401, { 'Content-Type': 'application/json' });
157
+ res.end(JSON.stringify({ error: 'Session expired' }));
158
+ return;
159
+ }
160
+ // F-02: Ticket endpoint — exchange session token for one-time WS ticket
161
+ if (req.url === '/api/auth/ticket' && req.method === 'POST') {
162
+ const auth = req.headers.authorization?.replace('Bearer ', '');
163
+ if (auth !== sessionToken) {
164
+ res.writeHead(401);
165
+ res.end();
166
+ return;
167
+ }
168
+ const ticket = crypto.randomUUID();
169
+ tickets.set(ticket, { expires: Date.now() + 60000 });
170
+ res.writeHead(200, { 'Content-Type': 'application/json' });
171
+ res.end(JSON.stringify({ ticket, expires: Date.now() + 60000 }));
172
+ return;
173
+ }
174
+ // F-01: Session token check for all API routes (skip in hub mode)
175
+ if (!hubMode && req.url?.startsWith('/api/')) {
176
+ const reqUrl = new URL(req.url, `http://${req.headers.host}`);
177
+ const authToken = req.headers.authorization?.replace('Bearer ', '') || reqUrl.searchParams.get('token');
178
+ if (authToken !== sessionToken) {
179
+ res.writeHead(401, { 'Content-Type': 'application/json' });
180
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
181
+ return;
182
+ }
183
+ }
114
184
  // Sessions API
115
185
  if (req.url === '/api/sessions' && req.method === 'GET') {
116
186
  try {
117
- const output = execSync('devtunnel list --labels cli-tunnel --json', { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
187
+ const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
118
188
  const data = JSON.parse(output);
119
189
  const sessions = (data.tunnels || []).map((t) => {
120
190
  const labels = t.labels || [];
@@ -163,7 +233,16 @@ const server = http.createServer((req, res) => {
163
233
  }
164
234
  // Static files
165
235
  const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../remote-ui');
166
- const decodedUrl = decodeURIComponent(req.url || '/');
236
+ // #18: Guard against malformed URI encoding
237
+ let decodedUrl;
238
+ try {
239
+ decodedUrl = decodeURIComponent(req.url || '/');
240
+ }
241
+ catch {
242
+ res.writeHead(400);
243
+ res.end();
244
+ return;
245
+ }
167
246
  if (decodedUrl.includes('..')) {
168
247
  res.writeHead(400);
169
248
  res.end();
@@ -175,65 +254,132 @@ const server = http.createServer((req, res) => {
175
254
  res.end();
176
255
  return;
177
256
  }
178
- if (!fs.existsSync(filePath))
179
- filePath = path.resolve(uiDir, 'index.html');
257
+ // #2: EISDIR guard — check if path is a directory before createReadStream
258
+ try {
259
+ const stat = fs.statSync(filePath);
260
+ if (stat.isDirectory()) {
261
+ filePath = path.join(filePath, 'index.html');
262
+ if (!fs.existsSync(filePath)) {
263
+ res.writeHead(404);
264
+ res.end();
265
+ return;
266
+ }
267
+ }
268
+ }
269
+ catch {
270
+ res.writeHead(404);
271
+ res.end();
272
+ return;
273
+ }
180
274
  const ext = path.extname(filePath);
181
275
  const mimes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' };
182
276
  const securityHeaders = {
183
277
  'Content-Type': mimes[ext] || 'application/octet-stream',
184
278
  'X-Frame-Options': 'DENY',
185
279
  'X-Content-Type-Options': 'nosniff',
186
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws: wss:;",
280
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* wss://*.devtunnels.ms;",
281
+ 'Referrer-Policy': 'no-referrer',
282
+ 'Cache-Control': 'no-store',
187
283
  };
188
284
  res.writeHead(200, securityHeaders);
189
- fs.createReadStream(filePath).pipe(res);
285
+ // #8: Handle createReadStream errors
286
+ const stream = fs.createReadStream(filePath);
287
+ stream.on('error', () => { if (!res.headersSent) {
288
+ res.writeHead(500);
289
+ } res.end(); });
290
+ stream.pipe(res);
190
291
  });
191
292
  const wss = new WebSocketServer({
192
293
  server,
193
294
  maxPayload: 1048576,
194
295
  verifyClient: (info) => {
296
+ if (hubMode)
297
+ return true; // Hub mode doesn't need WS auth
298
+ // F-18: Session expiry
299
+ if (Date.now() - sessionCreatedAt > SESSION_TTL)
300
+ return false;
195
301
  const url = new URL(info.req.url, `http://${info.req.headers.host}`);
196
- return url.searchParams.get('token') === sessionToken;
302
+ // F-02: Accept one-time ticket
303
+ const ticket = url.searchParams.get('ticket');
304
+ if (ticket && tickets.has(ticket)) {
305
+ const t = tickets.get(ticket);
306
+ tickets.delete(ticket); // Single use
307
+ return t.expires > Date.now();
308
+ }
309
+ // Backward compat: accept token
310
+ if (url.searchParams.get('token') !== sessionToken)
311
+ return false;
312
+ // Validate origin if present
313
+ // #28: Proper origin validation using URL parsing
314
+ const origin = info.req.headers.origin;
315
+ if (origin) {
316
+ try {
317
+ const originUrl = new URL(origin);
318
+ const host = originUrl.hostname;
319
+ if (host !== 'localhost' && host !== '127.0.0.1' && !host.endsWith('.devtunnels.ms')) {
320
+ return false;
321
+ }
322
+ }
323
+ catch {
324
+ return false;
325
+ }
326
+ }
327
+ return true;
197
328
  },
198
329
  });
199
330
  // ─── Security: Audit log for remote PTY input ──────────────
200
- const auditLogPath = path.join(os.tmpdir(), `cli-tunnel-audit-${Date.now()}.log`);
331
+ const auditDir = path.join(os.homedir(), '.cli-tunnel', 'audit');
332
+ fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
333
+ const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice(0, 10)}.jsonl`);
201
334
  const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
335
+ auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
202
336
  wss.on('connection', (ws, req) => {
203
- const id = Math.random().toString(36).substring(2);
337
+ // F-10: Connection cap
338
+ if (connections.size >= 5) {
339
+ ws.close(1013, 'Max connections reached');
340
+ return;
341
+ }
342
+ const id = crypto.randomUUID();
204
343
  const remoteAddress = req.socket.remoteAddress || 'unknown';
205
344
  connections.set(id, ws);
206
- // Replay history with secrets redacted
207
- for (const event of acpEventLog) {
208
- ws.send(JSON.stringify({ type: '_replay', data: redactSecrets(event) }));
345
+ // Replay history with secrets redacted (only if replay is enabled)
346
+ if (hasReplay) {
347
+ for (const event of acpEventLog) {
348
+ ws.send(JSON.stringify({ type: '_replay', data: redactSecrets(event) }));
349
+ }
350
+ ws.send(JSON.stringify({ type: '_replay_done' }));
209
351
  }
210
- ws.send(JSON.stringify({ type: '_replay_done' }));
211
352
  ws.on('message', (data) => {
212
353
  const raw = data.toString();
213
354
  try {
214
355
  const msg = JSON.parse(raw);
215
356
  if (msg.type === 'pty_input' && ptyProcess) {
216
- auditLog.write(`${new Date().toISOString()} [${remoteAddress}] ${JSON.stringify(msg.data)}\n`);
357
+ auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(JSON.stringify(msg.data)) }) + '\n');
217
358
  ptyProcess.write(msg.data);
218
359
  }
219
- if (msg.type === 'pty_resize' && ptyProcess) {
220
- const cols = Math.max(1, Math.min(500, msg.cols));
221
- const rows = Math.max(1, Math.min(200, msg.rows));
222
- ptyProcess.resize(cols, rows);
360
+ // #7: NaN guard on pty_resize
361
+ if (msg.type === 'pty_resize') {
362
+ const cols = Number(msg.cols);
363
+ const rows = Number(msg.rows);
364
+ if (Number.isFinite(cols) && Number.isFinite(rows) && ptyProcess) {
365
+ ptyProcess.resize(Math.max(1, Math.min(500, cols)), Math.max(1, Math.min(200, rows)));
366
+ }
223
367
  }
224
368
  }
225
369
  catch {
226
- if (ptyProcess)
227
- ptyProcess.write(raw + '\r');
370
+ // #3: Log but do NOT write to PTY — only structured pty_input messages allowed
371
+ auditLog.write(JSON.stringify({ ts: new Date().toISOString(), type: 'rejected', reason: 'non-json', length: raw.length }) + '\n');
228
372
  }
229
373
  });
230
374
  ws.on('close', () => connections.delete(id));
231
375
  });
232
376
  function broadcast(data) {
233
377
  const msg = JSON.stringify({ type: 'pty', data });
234
- acpEventLog.push(msg);
235
- if (acpEventLog.length > 2000)
236
- acpEventLog.splice(0, acpEventLog.length - 2000);
378
+ if (hasReplay) {
379
+ acpEventLog.push(msg);
380
+ if (acpEventLog.length > 2000)
381
+ acpEventLog.splice(0, acpEventLog.length - 2000);
382
+ }
237
383
  for (const [, ws] of connections) {
238
384
  if (ws.readyState === WebSocket.OPEN)
239
385
  ws.send(msg);
@@ -243,7 +389,7 @@ function broadcast(data) {
243
389
  let ptyProcess = null;
244
390
  async function main() {
245
391
  const actualPort = await new Promise((resolve, reject) => {
246
- server.listen(port, () => {
392
+ server.listen(port, '127.0.0.1', () => {
247
393
  const addr = server.address();
248
394
  resolve(typeof addr === 'object' ? addr.port : port);
249
395
  });
@@ -252,17 +398,25 @@ async function main() {
252
398
  const { repo, branch } = getGitInfo();
253
399
  const machine = os.hostname();
254
400
  const displayName = sessionName || command;
255
- console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.0.0${RESET}\n`);
256
- console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
257
- console.log(` ${DIM}Name:${RESET} ${displayName}`);
258
- console.log(` ${DIM}Port:${RESET} ${actualPort}`);
259
- console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
401
+ console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
402
+ if (hubMode) {
403
+ console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
404
+ console.log(` ${DIM}Port:${RESET} ${actualPort}\n`);
405
+ }
406
+ else {
407
+ console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
408
+ console.log(` ${DIM}Name:${RESET} ${displayName}`);
409
+ console.log(` ${DIM}Port:${RESET} ${actualPort}`);
410
+ console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
411
+ console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}`);
412
+ console.log(` ${DIM}Session expires:${RESET} ${new Date(sessionCreatedAt + SESSION_TTL).toLocaleTimeString()}`);
413
+ }
260
414
  // Tunnel
261
415
  if (hasTunnel) {
262
416
  // Check if devtunnel is installed
263
417
  let devtunnelInstalled = false;
264
418
  try {
265
- execSync('devtunnel --version', { stdio: 'pipe' });
419
+ execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
266
420
  devtunnelInstalled = true;
267
421
  }
268
422
  catch {
@@ -285,7 +439,7 @@ async function main() {
285
439
  // Check if logged in
286
440
  if (devtunnelInstalled) {
287
441
  try {
288
- const userInfo = execSync('devtunnel user show', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
442
+ const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
289
443
  if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
290
444
  throw new Error('not logged in');
291
445
  }
@@ -300,12 +454,12 @@ async function main() {
300
454
  }
301
455
  if (devtunnelInstalled) {
302
456
  try {
303
- const labels = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`]
304
- .map(l => `--labels ${l}`).join(' ');
305
- const createOut = execSync(`devtunnel create ${labels} --expiration 1d --json`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
457
+ const labelValues = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`];
458
+ const labelArgs = labelValues.flatMap(l => ['--labels', l]);
459
+ const createOut = execFileSync('devtunnel', ['create', ...labelArgs, '--expiration', '1d', '--json'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
306
460
  const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
307
461
  const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
308
- execSync(`devtunnel port create ${tunnelId} -p ${actualPort} --protocol http`, { stdio: 'pipe' });
462
+ execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe' });
309
463
  const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
310
464
  const url = await new Promise((resolve, reject) => {
311
465
  const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
@@ -329,11 +483,11 @@ async function main() {
329
483
  }
330
484
  catch { }
331
485
  process.on('SIGINT', () => { hostProc.kill(); try {
332
- execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
486
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
333
487
  }
334
488
  catch { } });
335
489
  process.on('exit', () => { hostProc.kill(); try {
336
- execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
490
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
337
491
  }
338
492
  catch { } });
339
493
  }
@@ -342,6 +496,14 @@ async function main() {
342
496
  }
343
497
  } // end if (devtunnelInstalled)
344
498
  }
499
+ if (hubMode) {
500
+ // Hub mode — just serve the sessions dashboard, no PTY
501
+ console.log(` ${GREEN}✓${RESET} Hub running — open in browser to see all sessions\n`);
502
+ console.log(` ${DIM}Press Ctrl+C to stop.${RESET}\n`);
503
+ process.on('SIGINT', () => { server.close(); process.exit(0); });
504
+ // Keep process alive
505
+ await new Promise(() => { });
506
+ }
345
507
  console.log(` ${DIM}Starting ${command}...${RESET}\n`);
346
508
  // Spawn PTY
347
509
  const nodePty = await import('node-pty');
@@ -351,7 +513,7 @@ async function main() {
351
513
  let resolvedCmd = command;
352
514
  if (process.platform === 'win32') {
353
515
  try {
354
- const wherePaths = execSync(`where ${command}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
516
+ const wherePaths = execFileSync('where', [command], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
355
517
  // Prefer .exe or .cmd over .ps1 for node-pty compatibility
356
518
  const exePath = wherePaths.find(p => p.trim().endsWith('.exe')) || wherePaths.find(p => p.trim().endsWith('.cmd'));
357
519
  if (exePath) {
@@ -365,11 +527,27 @@ async function main() {
365
527
  }
366
528
  catch { /* use as-is */ }
367
529
  }
368
- // Security: filter sensitive environment variables
530
+ // F-07: Security allowlist safe environment variables for PTY
531
+ const SAFE_ENV_VARS = new Set([
532
+ 'PATH', 'HOME', 'USERPROFILE', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE',
533
+ 'USER', 'LOGNAME', 'EDITOR', 'VISUAL', 'COLORTERM', 'TERM_PROGRAM',
534
+ 'HOSTNAME', 'COMPUTERNAME', 'PWD', 'OLDPWD', 'SHLVL', 'TMPDIR', 'TMP', 'TEMP',
535
+ 'XDG_RUNTIME_DIR', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME',
536
+ 'DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS',
537
+ 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'SYSTEMROOT', 'WINDIR', 'COMSPEC',
538
+ 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
539
+ 'NODE_ENV',
540
+ 'GOPATH', 'GOROOT', 'CARGO_HOME', 'RUSTUP_HOME',
541
+ 'JAVA_HOME', 'MAVEN_HOME', 'GRADLE_HOME',
542
+ 'PYTHONPATH', 'VIRTUAL_ENV', 'CONDA_DEFAULT_ENV',
543
+ 'KUBECONFIG', 'DOCKER_HOST', 'DOCKER_CONFIG',
544
+ 'GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL',
545
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
546
+ 'SSH_AUTH_SOCK', 'GPG_TTY',
547
+ ]);
369
548
  const safeEnv = {};
370
- const sensitivePatterns = /token|secret|key|password|credential|api_key|private/i;
371
549
  for (const [k, v] of Object.entries(process.env)) {
372
- if (!sensitivePatterns.test(k) && v !== undefined) {
550
+ if (SAFE_ENV_VARS.has(k) && v !== undefined) {
373
551
  safeEnv[k] = v;
374
552
  }
375
553
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.1.0",
3
+ "version": "1.2.0-beta.1",
4
4
  "description": "Tunnel any CLI app to your phone - PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/remote-ui/app.js CHANGED
@@ -5,6 +5,27 @@
5
5
  (function () {
6
6
  'use strict';
7
7
 
8
+ // ─── Mobile keyboard viewport fix ────────────────────────
9
+ // Keep the key bar visible above the on-screen keyboard
10
+ if (window.visualViewport) {
11
+ window.visualViewport.addEventListener('resize', () => {
12
+ const vv = window.visualViewport;
13
+ const inputArea = document.getElementById('input-area');
14
+ if (inputArea && vv) {
15
+ const offset = window.innerHeight - vv.height - vv.offsetTop;
16
+ inputArea.style.transform = offset > 0 ? `translateY(-${offset}px)` : '';
17
+ }
18
+ });
19
+ window.visualViewport.addEventListener('scroll', () => {
20
+ const vv = window.visualViewport;
21
+ const inputArea = document.getElementById('input-area');
22
+ if (inputArea && vv) {
23
+ const offset = window.innerHeight - vv.height - vv.offsetTop;
24
+ inputArea.style.transform = offset > 0 ? `translateY(-${offset}px)` : '';
25
+ }
26
+ });
27
+ }
28
+
8
29
  let ws = null;
9
30
  let connected = false;
10
31
  let sessionId = null;
@@ -98,7 +119,7 @@
98
119
  const data = await resp.json();
99
120
  renderDashboard(data.sessions || []);
100
121
  } catch (err) {
101
- dashboard.innerHTML = '<div style="padding:12px;color:var(--red)">Failed to load sessions: ' + err.message + '</div>';
122
+ dashboard.innerHTML = '<div style="padding:12px;color:var(--red)">' + escapeHtml('Failed to load sessions: ' + err.message) + '</div>';
102
123
  }
103
124
  }
104
125
 
@@ -121,7 +142,7 @@
121
142
  '</div>';
122
143
  } else {
123
144
  html += filtered.map(s => `
124
- <div class="session-card" ${s.online ? 'onclick="openSession(\'' + escapeHtml(s.url) + '\')"' : ''}>
145
+ <div class="session-card" ${s.online ? 'data-session-url="' + escapeHtml(s.url) + '"' : ''}>
125
146
  <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
126
147
  <div class="info">
127
148
  <div class="repo">📦 ${escapeHtml(s.repo)}</div>
@@ -129,11 +150,18 @@
129
150
  <div class="machine">💻 ${escapeHtml(s.machine)}</div>
130
151
  </div>
131
152
  ${s.online ? '<span class="arrow">→</span>' :
132
- '<button onclick="event.stopPropagation();deleteSession(\'' + escapeHtml(s.id) + '\')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'}
153
+ '<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'}
133
154
  </div>
134
155
  `).join('');
135
156
  }
136
157
  dashboard.innerHTML = html;
158
+ // #16: XSS fix — use event delegation instead of inline onclick
159
+ dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
160
+ card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
161
+ });
162
+ dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
163
+ btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
164
+ });
137
165
  }
138
166
 
139
167
  window.openSession = (url) => {
@@ -338,14 +366,49 @@
338
366
  }
339
367
  }
340
368
 
369
+ // ─── Detect hub mode (no token in URL) ────────────────────
370
+ const isHubMode = !new URLSearchParams(window.location.search).get('token');
371
+
341
372
  // ─── WebSocket ───────────────────────────────────────────
342
373
  let reconnectAttempt = 0;
343
374
 
344
- function connect() {
345
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
375
+ async function connect() {
376
+ if (isHubMode) {
377
+ // Hub mode — hide terminal UI, show sessions only
378
+ setStatus('online', 'Hub');
379
+ terminal.classList.add('hidden');
380
+ termContainer.classList.add('hidden');
381
+ $('#input-area').classList.add('hidden');
382
+ $('#btn-sessions').classList.add('hidden');
383
+ dashboard.classList.remove('hidden');
384
+ loadSessions();
385
+ // Auto-refresh every 10s
386
+ setInterval(loadSessions, 10000);
387
+ return;
388
+ }
389
+
346
390
  const tokenParam = new URLSearchParams(window.location.search).get('token');
347
- const wsUrl = tokenParam ? `${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}` : `${proto}//${location.host}`;
348
- ws = new WebSocket(wsUrl);
391
+ if (!tokenParam) { setStatus('offline', 'No credentials'); return; }
392
+
393
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
394
+
395
+ // F-02: Try ticket-based auth first
396
+ try {
397
+ const resp = await fetch('/api/auth/ticket', {
398
+ method: 'POST',
399
+ headers: { 'Authorization': 'Bearer ' + tokenParam }
400
+ });
401
+ if (resp.ok) {
402
+ const { ticket } = await resp.json();
403
+ ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
404
+ } else {
405
+ // Fallback to token-in-URL (backward compat)
406
+ ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
407
+ }
408
+ } catch {
409
+ // Fallback to token-in-URL
410
+ ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
411
+ }
349
412
  setStatus('connecting', 'Connecting...');
350
413
 
351
414
  ws.onopen = () => {
@@ -484,10 +547,12 @@
484
547
  <h3>${icon} ${escapeHtml(title)}</h3>
485
548
  <p>${escapeHtml(shortCmd || JSON.stringify(p).substring(0, 200))}</p>
486
549
  <div class="perm-actions">
487
- <button class="btn-deny" onclick="handlePerm(${msg.id}, false)">Deny</button>
488
- <button class="btn-approve" onclick="handlePerm(${msg.id}, true)">Approve</button>
550
+ <button class="btn-deny">Deny</button>
551
+ <button class="btn-approve">Approve</button>
489
552
  </div>
490
553
  </div>`;
554
+ permOverlay.querySelector('.btn-deny').addEventListener('click', () => window.handlePerm(msg.id, false));
555
+ permOverlay.querySelector('.btn-approve').addEventListener('click', () => window.handlePerm(msg.id, true));
491
556
  }
492
557
  window.handlePerm = (id, approved) => {
493
558
  if (ws?.readyState === WebSocket.OPEN) {
@@ -172,6 +172,9 @@ header {
172
172
  background: var(--bg-tool);
173
173
  border-top: 1px solid var(--border);
174
174
  flex-shrink: 0;
175
+ position: sticky;
176
+ bottom: 0;
177
+ z-index: 10;
175
178
  }
176
179
  #key-bar {
177
180
  display: flex;