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 +65 -17
- package/package.json +1 -1
- package/remote-ui/app.js +10 -3
- package/remote-ui/index.html +3 -3
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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({
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
379
|
+
env: safeEnv,
|
|
332
380
|
});
|
|
333
381
|
ptyProcess.onData((data) => {
|
|
334
382
|
process.stdout.write(data);
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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)
|
package/remote-ui/index.html
CHANGED
|
@@ -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>
|