cli-tunnel 1.0.1 → 1.1.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/dist/index.js CHANGED
@@ -16,7 +16,8 @@
16
16
  */
17
17
  import path from 'node:path';
18
18
  import fs from 'node:fs';
19
- import { execSync, spawn } from 'node:child_process';
19
+ import crypto from 'node:crypto';
20
+ import { execSync, execFileSync, spawn } from 'node:child_process';
20
21
  import { fileURLToPath } from 'node:url';
21
22
  import http from 'node:http';
22
23
  import { WebSocketServer, WebSocket } from 'ws';
@@ -100,6 +101,12 @@ function getGitInfo() {
100
101
  return { repo: path.basename(cwd), branch: 'unknown' };
101
102
  }
102
103
  }
104
+ // ─── Security: Session token for WebSocket auth ────────────
105
+ const sessionToken = crypto.randomUUID();
106
+ // ─── Security: Redact secrets from replay events ────────────
107
+ function redactSecrets(text) {
108
+ return text.replace(/(?:token|secret|key|password|credential|authorization)[\s:="']+[^\s"']{8,}/gi, '$& [REDACTED]');
109
+ }
103
110
  // ─── Bridge server ──────────────────────────────────────────
104
111
  const acpEventLog = [];
105
112
  const connections = new Map();
@@ -126,11 +133,11 @@ const server = http.createServer((req, res) => {
126
133
  url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
127
134
  };
128
135
  });
129
- res.writeHead(200, { 'Content-Type': 'application/json' });
136
+ res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
130
137
  res.end(JSON.stringify({ sessions }));
131
138
  }
132
139
  catch {
133
- res.writeHead(200, { 'Content-Type': 'application/json' });
140
+ res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
134
141
  res.end(JSON.stringify({ sessions: [] }));
135
142
  }
136
143
  return;
@@ -138,39 +145,67 @@ const server = http.createServer((req, res) => {
138
145
  // Delete session
139
146
  if (req.url?.startsWith('/api/sessions/') && req.method === 'DELETE') {
140
147
  const tunnelId = req.url.replace('/api/sessions/', '').replace(/\.\w+$/, '');
148
+ if (!/^[a-zA-Z0-9._-]+$/.test(tunnelId)) {
149
+ res.writeHead(400, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
150
+ res.end(JSON.stringify({ error: 'Invalid tunnel ID' }));
151
+ return;
152
+ }
141
153
  try {
142
- execSync(`devtunnel delete ${tunnelId} --force`, { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
143
- res.writeHead(200, { 'Content-Type': 'application/json' });
154
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
155
+ res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
144
156
  res.end(JSON.stringify({ deleted: true }));
145
157
  }
146
158
  catch {
147
- res.writeHead(200, { 'Content-Type': 'application/json' });
159
+ res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
148
160
  res.end(JSON.stringify({ deleted: false }));
149
161
  }
150
162
  return;
151
163
  }
152
164
  // Static files
153
165
  const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../remote-ui');
154
- let filePath = path.join(uiDir, req.url === '/' ? 'index.html' : req.url || 'index.html');
166
+ const decodedUrl = decodeURIComponent(req.url || '/');
167
+ if (decodedUrl.includes('..')) {
168
+ res.writeHead(400);
169
+ res.end();
170
+ return;
171
+ }
172
+ let filePath = path.resolve(uiDir, decodedUrl === '/' ? 'index.html' : decodedUrl.replace(/^\//, ''));
155
173
  if (!filePath.startsWith(uiDir)) {
156
174
  res.writeHead(403);
157
175
  res.end();
158
176
  return;
159
177
  }
160
178
  if (!fs.existsSync(filePath))
161
- filePath = path.join(uiDir, 'index.html');
179
+ filePath = path.resolve(uiDir, 'index.html');
162
180
  const ext = path.extname(filePath);
163
181
  const mimes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' };
164
- res.writeHead(200, { 'Content-Type': mimes[ext] || 'application/octet-stream' });
182
+ const securityHeaders = {
183
+ 'Content-Type': mimes[ext] || 'application/octet-stream',
184
+ 'X-Frame-Options': 'DENY',
185
+ '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:;",
187
+ };
188
+ res.writeHead(200, securityHeaders);
165
189
  fs.createReadStream(filePath).pipe(res);
166
190
  });
167
- const wss = new WebSocketServer({ server });
168
- wss.on('connection', (ws) => {
191
+ const wss = new WebSocketServer({
192
+ server,
193
+ maxPayload: 1048576,
194
+ verifyClient: (info) => {
195
+ const url = new URL(info.req.url, `http://${info.req.headers.host}`);
196
+ return url.searchParams.get('token') === sessionToken;
197
+ },
198
+ });
199
+ // ─── Security: Audit log for remote PTY input ──────────────
200
+ const auditLogPath = path.join(os.tmpdir(), `cli-tunnel-audit-${Date.now()}.log`);
201
+ const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
202
+ wss.on('connection', (ws, req) => {
169
203
  const id = Math.random().toString(36).substring(2);
204
+ const remoteAddress = req.socket.remoteAddress || 'unknown';
170
205
  connections.set(id, ws);
171
- // Replay history
206
+ // Replay history with secrets redacted
172
207
  for (const event of acpEventLog) {
173
- ws.send(JSON.stringify({ type: '_replay', data: event }));
208
+ ws.send(JSON.stringify({ type: '_replay', data: redactSecrets(event) }));
174
209
  }
175
210
  ws.send(JSON.stringify({ type: '_replay_done' }));
176
211
  ws.on('message', (data) => {
@@ -178,10 +213,13 @@ wss.on('connection', (ws) => {
178
213
  try {
179
214
  const msg = JSON.parse(raw);
180
215
  if (msg.type === 'pty_input' && ptyProcess) {
216
+ auditLog.write(`${new Date().toISOString()} [${remoteAddress}] ${JSON.stringify(msg.data)}\n`);
181
217
  ptyProcess.write(msg.data);
182
218
  }
183
219
  if (msg.type === 'pty_resize' && ptyProcess) {
184
- ptyProcess.resize(msg.cols, msg.rows);
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);
185
223
  }
186
224
  }
187
225
  catch {
@@ -218,49 +256,91 @@ async function main() {
218
256
  console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
219
257
  console.log(` ${DIM}Name:${RESET} ${displayName}`);
220
258
  console.log(` ${DIM}Port:${RESET} ${actualPort}`);
259
+ console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
221
260
  // Tunnel
222
261
  if (hasTunnel) {
262
+ // Check if devtunnel is installed
263
+ let devtunnelInstalled = false;
223
264
  try {
224
265
  execSync('devtunnel --version', { stdio: 'pipe' });
225
- const labels = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`]
226
- .map(l => `--labels ${l}`).join(' ');
227
- const createOut = execSync(`devtunnel create ${labels} --expiration 1d --json`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
228
- const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
229
- const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
230
- execSync(`devtunnel port create ${tunnelId} -p ${actualPort} --protocol http`, { stdio: 'pipe' });
231
- const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
232
- const url = await new Promise((resolve, reject) => {
233
- const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
234
- let out = '';
235
- hostProc.stdout?.on('data', (d) => {
236
- out += d.toString();
237
- const match = out.match(/https:\/\/[^\s]+/);
238
- if (match) {
239
- clearTimeout(timeout);
240
- resolve(match[0]);
241
- }
242
- });
243
- hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
244
- });
245
- console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${url}${RESET}\n`);
246
- try {
247
- // @ts-ignore
248
- const qr = (await import('qrcode-terminal'));
249
- qr.default.generate(url, { small: true }, (code) => console.log(code));
266
+ devtunnelInstalled = true;
267
+ }
268
+ catch {
269
+ console.log(`\n ${YELLOW}⚠ devtunnel CLI not found!${RESET}\n`);
270
+ console.log(` ${BOLD}To enable remote access, install Microsoft Dev Tunnels:${RESET}\n`);
271
+ if (process.platform === 'win32') {
272
+ console.log(` ${GREEN}winget install Microsoft.devtunnel${RESET}`);
250
273
  }
251
- catch { }
252
- process.on('SIGINT', () => { hostProc.kill(); try {
253
- execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
274
+ else if (process.platform === 'darwin') {
275
+ console.log(` ${GREEN}brew install --cask devtunnel${RESET}`);
254
276
  }
255
- catch { } });
256
- process.on('exit', () => { hostProc.kill(); try {
257
- execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
277
+ else {
278
+ console.log(` ${GREEN}curl -sL https://aka.ms/DevTunnelCliInstall | bash${RESET}`);
258
279
  }
259
- catch { } });
280
+ console.log(`\n Then authenticate once:\n`);
281
+ console.log(` ${GREEN}devtunnel user login${RESET}\n`);
282
+ console.log(` ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}\n`);
283
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
260
284
  }
261
- catch (err) {
262
- console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${err.message}\n`);
285
+ // Check if logged in
286
+ if (devtunnelInstalled) {
287
+ try {
288
+ const userInfo = execSync('devtunnel user show', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
289
+ if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
290
+ throw new Error('not logged in');
291
+ }
292
+ }
293
+ catch {
294
+ console.log(`\n ${YELLOW}⚠ devtunnel not authenticated!${RESET}\n`);
295
+ console.log(` Run this once to log in:\n`);
296
+ console.log(` ${GREEN}devtunnel user login${RESET}\n`);
297
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
298
+ devtunnelInstalled = false;
299
+ }
263
300
  }
301
+ if (devtunnelInstalled) {
302
+ 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'] });
306
+ const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
307
+ const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
308
+ execSync(`devtunnel port create ${tunnelId} -p ${actualPort} --protocol http`, { stdio: 'pipe' });
309
+ const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
310
+ const url = await new Promise((resolve, reject) => {
311
+ const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
312
+ let out = '';
313
+ hostProc.stdout?.on('data', (d) => {
314
+ out += d.toString();
315
+ const match = out.match(/https:\/\/[^\s]+/);
316
+ if (match) {
317
+ clearTimeout(timeout);
318
+ resolve(match[0]);
319
+ }
320
+ });
321
+ hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
322
+ });
323
+ const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
324
+ console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
325
+ try {
326
+ // @ts-ignore
327
+ const qr = (await import('qrcode-terminal'));
328
+ qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
329
+ }
330
+ catch { }
331
+ process.on('SIGINT', () => { hostProc.kill(); try {
332
+ execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
333
+ }
334
+ catch { } });
335
+ process.on('exit', () => { hostProc.kill(); try {
336
+ execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
337
+ }
338
+ catch { } });
339
+ }
340
+ catch (err) {
341
+ console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${err.message}\n`);
342
+ }
343
+ } // end if (devtunnelInstalled)
264
344
  }
265
345
  console.log(` ${DIM}Starting ${command}...${RESET}\n`);
266
346
  // Spawn PTY
@@ -285,10 +365,18 @@ async function main() {
285
365
  }
286
366
  catch { /* use as-is */ }
287
367
  }
368
+ // Security: filter sensitive environment variables
369
+ const safeEnv = {};
370
+ const sensitivePatterns = /token|secret|key|password|credential|api_key|private/i;
371
+ for (const [k, v] of Object.entries(process.env)) {
372
+ if (!sensitivePatterns.test(k) && v !== undefined) {
373
+ safeEnv[k] = v;
374
+ }
375
+ }
288
376
  ptyProcess = nodePty.spawn(resolvedCmd, commandArgs, {
289
377
  name: 'xterm-256color',
290
378
  cols, rows, cwd,
291
- env: process.env,
379
+ env: safeEnv,
292
380
  });
293
381
  ptyProcess.onData((data) => {
294
382
  process.stdout.write(data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
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
@@ -339,19 +339,26 @@
339
339
  }
340
340
 
341
341
  // ─── WebSocket ───────────────────────────────────────────
342
+ let reconnectAttempt = 0;
343
+
342
344
  function connect() {
343
345
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
344
- ws = new WebSocket(`${proto}//${location.host}`);
346
+ 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);
345
349
  setStatus('connecting', 'Connecting...');
346
350
 
347
351
  ws.onopen = () => {
348
352
  connected = true;
353
+ reconnectAttempt = 0;
349
354
  setTimeout(() => initializeACP(1), 1000);
350
355
  };
351
356
  ws.onclose = () => {
352
357
  connected = false; acpReady = false; sessionId = null;
353
358
  setStatus('offline', 'Disconnected');
354
- setTimeout(connect, 3000);
359
+ const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempt)) + Math.random() * 1000;
360
+ reconnectAttempt++;
361
+ setTimeout(connect, delay);
355
362
  };
356
363
  ws.onerror = () => setStatus('offline', 'Error');
357
364
  ws.onmessage = (e) => {
@@ -534,7 +541,7 @@
534
541
  requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
535
542
  }
536
543
  function escapeHtml(s) {
537
- const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML;
544
+ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, ''');
538
545
  }
539
546
  function formatText(text) {
540
547
  return escapeHtml(text)
@@ -7,7 +7,7 @@
7
7
  <meta name="apple-mobile-web-app-capable" content="yes">
8
8
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
9
  <title>cli-tunnel</title>
10
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
10
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" integrity="sha384-tStR1zLfWgsiXCF3IgfB3lBa8KmBe/lG287CL9WCeKgQYcp1bjb4/+mwN6oti4Co" crossorigin="anonymous">
11
11
  <link rel="stylesheet" href="/styles.css">
12
12
  </head>
13
13
  <body>
@@ -50,8 +50,8 @@
50
50
  </footer>
51
51
  </div>
52
52
  <div id="permission-overlay" class="hidden"></div>
53
- <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
54
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
53
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js" integrity="sha384-J4qzUjBl1FxyLsl/kQPQIOeINsmp17OHYXDOMpMxlKX53ZfYsL+aWHpgArvOuof9" crossorigin="anonymous"></script>
54
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js" integrity="sha384-XGqKrV8Jrukp1NITJbOEHwg01tNkuXr6uB6YEj69ebpYU3v7FvoGgEg23C1Gcehk" crossorigin="anonymous"></script>
55
55
  <script src="/app.js"></script>
56
56
  </body>
57
57
  </html>