cli-tunnel 1.0.2 → 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,6 +256,7 @@ 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) {
223
262
  // Check if devtunnel is installed
@@ -281,11 +320,12 @@ async function main() {
281
320
  });
282
321
  hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
283
322
  });
284
- console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${url}${RESET}\n`);
323
+ const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
324
+ console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
285
325
  try {
286
326
  // @ts-ignore
287
327
  const qr = (await import('qrcode-terminal'));
288
- qr.default.generate(url, { small: true }, (code) => console.log(code));
328
+ qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
289
329
  }
290
330
  catch { }
291
331
  process.on('SIGINT', () => { hostProc.kill(); try {
@@ -325,10 +365,18 @@ async function main() {
325
365
  }
326
366
  catch { /* use as-is */ }
327
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
+ }
328
376
  ptyProcess = nodePty.spawn(resolvedCmd, commandArgs, {
329
377
  name: 'xterm-256color',
330
378
  cols, rows, cwd,
331
- env: process.env,
379
+ env: safeEnv,
332
380
  });
333
381
  ptyProcess.onData((data) => {
334
382
  process.stdout.write(data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.0.2",
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>