@miraj181/ipingyou 2.1.6 → 2.1.15

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/src/lib/ssh.js CHANGED
@@ -1,11 +1,26 @@
1
1
  import crypto from 'node:crypto';
2
2
 
3
+ const SAFE_HOSTNAME_PATTERN = /^(?=.{1,253}$)(?!.*\.\.)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
4
+
5
+ export function assertSafeHostname(hostname, label = 'hostname') {
6
+ const normalized = String(hostname || '').trim().replace(/\.$/, '').toLowerCase();
7
+ if (!normalized || !SAFE_HOSTNAME_PATTERN.test(normalized)) {
8
+ throw new Error(`Invalid ${label}`);
9
+ }
10
+ return normalized;
11
+ }
12
+
3
13
  export function extractHostname(url) {
14
+ let parsed;
4
15
  try {
5
- return new URL(url).hostname;
16
+ parsed = new URL(url);
6
17
  } catch {
7
- return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
18
+ throw new Error('Invalid tunnel URL');
8
19
  }
20
+ if (parsed.protocol !== 'https:') {
21
+ throw new Error('Tunnel URL must use HTTPS');
22
+ }
23
+ return assertSafeHostname(parsed.hostname, 'tunnel hostname');
9
24
  }
10
25
 
11
26
  export function quoteRemoteShell(value) {
@@ -25,8 +40,9 @@ export function formatScpRemotePath(remotePath) {
25
40
  }
26
41
 
27
42
  export function getSshControlOptions(hostname) {
43
+ const safeHostname = assertSafeHostname(hostname, 'ssh hostname');
28
44
  if (process.platform === 'win32') return [];
29
- const hash = crypto.createHash('sha1').update(hostname).digest('hex').slice(0, 10);
45
+ const hash = crypto.createHash('sha256').update(safeHostname).digest('hex').slice(0, 10);
30
46
  return [
31
47
  '-o', 'ControlMaster=auto',
32
48
  '-o', 'ControlPersist=5m',
@@ -34,6 +50,11 @@ export function getSshControlOptions(hostname) {
34
50
  ];
35
51
  }
36
52
 
53
+ export function buildProxyCommandOption(hostname) {
54
+ const safeHostname = assertSafeHostname(hostname, 'tunnel hostname');
55
+ return ['-o', `ProxyCommand=cloudflared access tcp --hostname ${safeHostname}`];
56
+ }
57
+
37
58
  export function getKnownHostsOptions(persistKnownHosts = true) {
38
59
  if (persistKnownHosts) {
39
60
  return ['-o', 'StrictHostKeyChecking=accept-new'];
@@ -48,9 +69,8 @@ export function getKnownHostsOptions(persistKnownHosts = true) {
48
69
 
49
70
  export function buildSshArgs(hostname, privateKeyPath, extraOptions = [], options = {}) {
50
71
  const { persistKnownHosts = true } = options;
51
- const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
52
72
  const sshArgs = [
53
- '-o', `ProxyCommand=${proxyCommand}`,
73
+ ...buildProxyCommandOption(hostname),
54
74
  ...getKnownHostsOptions(persistKnownHosts),
55
75
  '-o', 'IdentitiesOnly=yes',
56
76
  ...getSshControlOptions(hostname),
package/src/lib/tunnel.js CHANGED
@@ -15,6 +15,7 @@ export async function spawnTunnelSupervised(targetUrl, onUrlGenerated) {
15
15
  activeChild = execa('cloudflared', ['tunnel', '--url', targetUrl], {
16
16
  reject: false,
17
17
  all: true,
18
+ buffer: false,
18
19
  });
19
20
 
20
21
  trackPID(activeChild.pid);
@@ -0,0 +1,81 @@
1
+ import { Worker } from 'node:worker_threads';
2
+
3
+ const workerUrl = new URL('./workers/crypto-checksum-worker.js', import.meta.url);
4
+ const workersDisabled = process.env.IPINGYOU_DISABLE_WORKERS === '1';
5
+ const MAX_PENDING_TASKS = 128;
6
+
7
+ let worker = null;
8
+ let requestCounter = 0;
9
+ const pending = new Map();
10
+
11
+ function resetWorker(err) {
12
+ for (const { reject } of pending.values()) {
13
+ reject(err);
14
+ }
15
+ pending.clear();
16
+ worker = null;
17
+ }
18
+
19
+ function ensureWorker() {
20
+ if (worker || workersDisabled) return worker;
21
+
22
+ worker = new Worker(workerUrl, {
23
+ resourceLimits: {
24
+ maxOldGenerationSizeMb: 128,
25
+ },
26
+ });
27
+ worker.unref();
28
+
29
+ worker.on('message', (message) => {
30
+ const { id, ok, result, error } = message || {};
31
+ const entry = pending.get(id);
32
+ if (!entry) return;
33
+ pending.delete(id);
34
+ if (pending.size === 0) worker?.unref();
35
+ if (ok) {
36
+ entry.resolve(result);
37
+ return;
38
+ }
39
+ entry.reject(new Error(error || 'Worker task failed'));
40
+ });
41
+
42
+ worker.on('error', (err) => {
43
+ resetWorker(err);
44
+ });
45
+
46
+ worker.on('exit', (code) => {
47
+ if (code !== 0) {
48
+ resetWorker(new Error(`Worker exited with code ${code}`));
49
+ } else {
50
+ worker = null;
51
+ }
52
+ });
53
+
54
+ return worker;
55
+ }
56
+
57
+ export function canUseWorkers() {
58
+ return !workersDisabled;
59
+ }
60
+
61
+ export function runWorkerTask(type, payload) {
62
+ if (!canUseWorkers()) {
63
+ throw new Error('Worker threads are disabled');
64
+ }
65
+ const activeWorker = ensureWorker();
66
+ if (!activeWorker) {
67
+ throw new Error('Worker is not available');
68
+ }
69
+ if (pending.size >= MAX_PENDING_TASKS) {
70
+ const error = new Error('Worker task queue is at capacity');
71
+ error.code = 'WORKER_QUEUE_FULL';
72
+ throw error;
73
+ }
74
+
75
+ const id = ++requestCounter;
76
+ return new Promise((resolve, reject) => {
77
+ pending.set(id, { resolve, reject });
78
+ activeWorker.ref();
79
+ activeWorker.postMessage({ id, type, payload });
80
+ });
81
+ }
@@ -0,0 +1,70 @@
1
+ import { parentPort } from 'node:worker_threads';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+
5
+ function deriveKey(password, salt) {
6
+ return crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
7
+ }
8
+
9
+ function encryptPayload(plaintext, password) {
10
+ const salt = crypto.randomBytes(16);
11
+ const key = deriveKey(password, salt);
12
+ const iv = crypto.randomBytes(16);
13
+
14
+ const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
15
+ let enc = cipher.update(plaintext, 'utf8', 'base64');
16
+ enc += cipher.final('base64');
17
+
18
+ return {
19
+ iv: iv.toString('hex'),
20
+ ciphertext: enc,
21
+ salt: salt.toString('hex'),
22
+ };
23
+ }
24
+
25
+ function decryptPayload(ivHex, cipherBase64, password, saltHex) {
26
+ const salt = Buffer.from(saltHex, 'hex');
27
+ const key = deriveKey(password, salt);
28
+ const iv = Buffer.from(ivHex, 'hex');
29
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
30
+ let dec = decipher.update(cipherBase64, 'base64', 'utf8');
31
+ dec += decipher.final('utf8');
32
+ return dec;
33
+ }
34
+
35
+ function checksumFile(filePath) {
36
+ return new Promise((resolve, reject) => {
37
+ const hash = crypto.createHash('sha256');
38
+ const stream = fs.createReadStream(filePath);
39
+ stream.on('error', reject);
40
+ stream.on('data', (chunk) => hash.update(chunk));
41
+ stream.on('end', () => resolve(hash.digest('hex')));
42
+ });
43
+ }
44
+
45
+ async function executeTask(type, payload) {
46
+ switch (type) {
47
+ case 'encrypt':
48
+ return encryptPayload(payload.plaintext, payload.password);
49
+ case 'decrypt':
50
+ return {
51
+ plaintext: decryptPayload(payload.ivHex, payload.cipherBase64, payload.password, payload.saltHex),
52
+ };
53
+ case 'checksum':
54
+ return {
55
+ digest: await checksumFile(payload.filePath),
56
+ };
57
+ default:
58
+ throw new Error(`Unsupported worker task: ${type}`);
59
+ }
60
+ }
61
+
62
+ parentPort.on('message', async (message) => {
63
+ const { id, type, payload } = message || {};
64
+ try {
65
+ const result = await executeTask(type, payload || {});
66
+ parentPort.postMessage({ id, ok: true, result });
67
+ } catch (err) {
68
+ parentPort.postMessage({ id, ok: false, error: err.message });
69
+ }
70
+ });
package/src/modes/ai.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * AI Mode — privacy-first local/remote task assistant powered by Groq.
3
3
  */
4
4
 
5
- import { execa, execaCommand } from 'execa';
5
+ import { execa } from 'execa';
6
+ import { parse as shellParse } from 'shell-quote';
6
7
  import chalk from 'chalk';
7
8
  import inquirer from 'inquirer';
8
9
  import fs from 'node:fs';
@@ -210,8 +211,13 @@ function showRateLimitWarnings(rateLimit) {
210
211
  }
211
212
 
212
213
  async function runLocalCommand(command) {
213
- const result = await execaCommand(command, {
214
- shell: true,
214
+ const parsed = shellParse(command);
215
+ // Filter out non-string tokens (shell operators like |, &&, ; etc.) to prevent injection
216
+ const args = parsed.filter(token => typeof token === 'string');
217
+ if (args.length === 0) {
218
+ return { exitCode: 1, stdout: '', stderr: 'Empty or unsafe command after parsing' };
219
+ }
220
+ const result = await execa(args[0], args.slice(1), {
215
221
  reject: false,
216
222
  timeout: 30000,
217
223
  maxBuffer: 1024 * 1024,
@@ -568,12 +574,10 @@ async function tryAITransfer(task, context) {
568
574
  if (context && context.scope === 'remote' && context.hostname && context.username) {
569
575
  console.log(chalk.dim(` Using active remote session: ${context.username}@${context.hostname}`));
570
576
 
571
- const { getSshControlOptions, formatScpRemotePath } = await import('../lib/ssh.js');
572
-
573
- const proxyCommand = `cloudflared access tcp --hostname ${context.hostname}`;
577
+ const { buildProxyCommandOption, getSshControlOptions, formatScpRemotePath } = await import('../lib/ssh.js');
574
578
  const scpArgs = [
575
579
  '-r',
576
- '-o', `ProxyCommand=${proxyCommand}`,
580
+ ...buildProxyCommandOption(context.hostname),
577
581
  '-o', 'StrictHostKeyChecking=accept-new',
578
582
  '-o', 'IdentitiesOnly=yes',
579
583
  ...getSshControlOptions(context.hostname),
@@ -624,4 +628,3 @@ async function tryAITransfer(task, context) {
624
628
  return true;
625
629
  }
626
630
  }
627
-
@@ -24,9 +24,10 @@ import { getConfig, saveAlias } from '../lib/config.js';
24
24
  import { pushTelemetry, requestHostApproval, resolveUID, revokeUID, waitForApproval } from '../lib/broker.js';
25
25
  import { calculateChecksum } from '../lib/checksum.js';
26
26
  import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
27
- import { buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
28
- import { buildTmuxSessionName, tmuxSocketCommand } from '../lib/tmux.js';
27
+ import { buildProxyCommandOption, buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
28
+ import { buildTmuxSessionName, TMUX_SOCKET_PATH } from '../lib/tmux.js';
29
29
  import open from 'open';
30
+ import { secureSensitiveUrl } from '../lib/secure-print.js';
30
31
  import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
31
32
 
32
33
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
@@ -89,9 +90,13 @@ async function connectSSH(username, hostname, privateKeyPath, persistKnownHosts
89
90
 
90
91
  sshArgs.push(`${username}@${hostname}`);
91
92
  const tmuxSession = buildTmuxSessionName(username);
92
- const tmuxPrepare = `${tmuxSocketCommand()} has-session -t ${tmuxSession} 2>/dev/null || ${tmuxSocketCommand()} new-session -d -s ${tmuxSession}`;
93
- const tmuxAttach = `${tmuxSocketCommand()} attach -t ${tmuxSession}`;
94
- sshArgs.push('-t', `${tmuxPrepare} && ${tmuxAttach} || exec $SHELL -l`);
93
+ const quotedSocket = quoteRemoteShell(TMUX_SOCKET_PATH);
94
+ const quotedSession = quoteRemoteShell(tmuxSession);
95
+ const tmuxSocketCmdStr = `tmux -S ${quotedSocket}`;
96
+ const tmuxPrepare = `${tmuxSocketCmdStr} has-session -t ${quotedSession} 2>/dev/null || ${tmuxSocketCmdStr} new-session -d -s ${quotedSession}`;
97
+ const tmuxAttach = `${tmuxSocketCmdStr} attach -t ${quotedSession}`;
98
+ const tmuxCommand = `if command -v tmux >/dev/null 2>&1; then (${tmuxPrepare} && ${tmuxAttach}) || exec $SHELL -l; else exec $SHELL -l; fi`;
99
+ sshArgs.push('-t', tmuxCommand);
95
100
 
96
101
  const child = execa('ssh', sshArgs, {
97
102
  stdio: 'inherit',
@@ -182,12 +187,10 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
182
187
 
183
188
  await showConnectionTrace('Local', 'Remote SCP');
184
189
 
185
- const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
186
-
187
190
  // Construct SCP args
188
191
  const scpArgs = [
189
192
  '-r', // recursive just in case
190
- '-o', `ProxyCommand=${proxyCommand}`,
193
+ ...buildProxyCommandOption(hostname),
191
194
  ...getKnownHostsOptions(persistKnownHosts),
192
195
  '-o', 'IdentitiesOnly=yes',
193
196
  ...getSshControlOptions(hostname)
@@ -218,6 +221,16 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
218
221
  const child = execa('scp', scpArgs, {
219
222
  stdio: ['inherit', 'pipe', 'pipe'],
220
223
  reject: false,
224
+ buffer: false,
225
+ });
226
+ const maxDiagnosticBytes = 64 * 1024;
227
+ let stderrOutput = Buffer.alloc(0);
228
+ child.stdout?.resume();
229
+ child.stderr?.on('data', (chunk) => {
230
+ const next = Buffer.concat([stderrOutput, Buffer.from(chunk)]);
231
+ stderrOutput = next.length > maxDiagnosticBytes
232
+ ? next.subarray(next.length - maxDiagnosticBytes)
233
+ : next;
221
234
  });
222
235
 
223
236
  trackPID(child.pid);
@@ -257,9 +270,10 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
257
270
  console.log(chalk.green(` ✅ Transfer completed successfully!`));
258
271
  recordEvent('scp_transfer_success', { direction, localPath, remotePath, hostname });
259
272
  } else {
273
+ const stderr = stderrOutput.toString('utf8');
260
274
  console.error(chalk.red(' ❌ SCP transfer failed'));
261
- if (result.stderr) console.error(chalk.dim(` ${result.stderr.trim()}`));
262
- recordEvent('scp_transfer_failed', { direction, localPath, remotePath, hostname, error: result.stderr });
275
+ if (stderr) console.error(chalk.dim(` ${stderr.trim()}`));
276
+ recordEvent('scp_transfer_failed', { direction, localPath, remotePath, hostname, error: stderr });
263
277
  }
264
278
  } catch (err) {
265
279
  console.error(chalk.red(` ❌ SCP error: ${err.message}`));
@@ -268,10 +282,9 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
268
282
 
269
283
  async function downloadSpecificRemotePath(username, hostname, privateKeyPath, remotePath, localPath, persistKnownHosts = true) {
270
284
  await showConnectionTrace('Local', 'Remote SCP');
271
- const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
272
285
  const scpArgs = [
273
286
  '-r',
274
- '-o', `ProxyCommand=${proxyCommand}`,
287
+ ...buildProxyCommandOption(hostname),
275
288
  ...getKnownHostsOptions(persistKnownHosts),
276
289
  '-o', 'IdentitiesOnly=yes',
277
290
  ...getSshControlOptions(hostname),
@@ -662,8 +675,7 @@ export async function performSCPNonInteractive(params = {}) {
662
675
  const privateKeyPath = payload.privateKey ? await writeEphemeralPrivateKey(payload.privateKey) : null;
663
676
 
664
677
  // Build scp args similar to performSCP
665
- const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
666
- const scpArgs = ['-r', '-o', `ProxyCommand=${proxyCommand}`, ...getKnownHostsOptions(persistKnownHosts), '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
678
+ const scpArgs = ['-r', ...buildProxyCommandOption(hostname), ...getKnownHostsOptions(persistKnownHosts), '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
667
679
  if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
668
680
 
669
681
  const remoteSpec = `${username}@${hostname}:${formatScpRemotePath(remotePath)}`;
@@ -701,7 +713,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
701
713
  const fullUrl = `${chatUrl}#${password}`;
702
714
  await open(fullUrl);
703
715
  } catch {
704
- console.log(chalk.cyan(` 👉 Please open: ${chatUrl}#${password}`));
716
+ console.log(chalk.cyan(` 👉 Please open: ${secureSensitiveUrl(chatUrl, password)}`));
705
717
  }
706
718
  } else {
707
719
  spinner.warn('The host has not started a chat room yet.');
@@ -2,7 +2,7 @@
2
2
  * Doctor Mode — non-invasive diagnostics for iPingYou.
3
3
  */
4
4
 
5
- import { execa, execaCommand } from 'execa';
5
+ import { execa } from 'execa';
6
6
  import chalk from 'chalk';
7
7
  import fs from 'node:fs';
8
8
  import os from 'node:os';
@@ -66,7 +66,7 @@ async function commandFound(command) {
66
66
  async function checkSshService() {
67
67
  const osInfo = detectOS();
68
68
  if (osInfo.isMac) {
69
- const launchctl = await execaCommand('launchctl print-disabled system', { reject: false, timeout: 5000 });
69
+ const launchctl = await execa('launchctl', ['print-disabled', 'system'], { reject: false, timeout: 5000 });
70
70
  const launchctlOutput = `${launchctl.stdout || ''}\n${launchctl.stderr || ''}`.toLowerCase();
71
71
  const launchctlMatch = launchctlOutput.match(/"com\.openssh\.sshd"\s*=>\s*(enabled|disabled)/);
72
72
  if (launchctlMatch) {
@@ -78,7 +78,7 @@ async function checkSshService() {
78
78
  };
79
79
  }
80
80
 
81
- const result = await execaCommand('systemsetup -getremotelogin', { reject: false, timeout: 5000 });
81
+ const result = await execa('systemsetup', ['-getremotelogin'], { reject: false, timeout: 5000 });
82
82
  const output = result.stdout || result.stderr || '';
83
83
  if (/on/i.test(output)) return { status: 'pass', detail: 'Remote Login is on' };
84
84
  if (/off/i.test(output)) {
@@ -96,8 +96,8 @@ async function checkSshService() {
96
96
  }
97
97
 
98
98
  if (osInfo.isLinux) {
99
- const ssh = await execaCommand('systemctl is-active ssh', { reject: false, timeout: 5000 });
100
- const sshd = ssh.exitCode === 0 ? ssh : await execaCommand('systemctl is-active sshd', { reject: false, timeout: 5000 });
99
+ const ssh = await execa('systemctl', ['is-active', 'ssh'], { reject: false, timeout: 5000 });
100
+ const sshd = ssh.exitCode === 0 ? ssh : await execa('systemctl', ['is-active', 'sshd'], { reject: false, timeout: 5000 });
101
101
  if (sshd.exitCode === 0) return { status: 'pass', detail: 'SSH service is active' };
102
102
  return {
103
103
  status: 'warn',
@@ -107,7 +107,7 @@ async function checkSshService() {
107
107
  }
108
108
 
109
109
  if (osInfo.isWindows) {
110
- const result = await execaCommand('sc query sshd', { reject: false, timeout: 5000 });
110
+ const result = await execa('sc', ['query', 'sshd'], { reject: false, timeout: 5000 });
111
111
  if (/RUNNING/i.test(result.stdout)) return { status: 'pass', detail: 'OpenSSH Server is running' };
112
112
  return {
113
113
  status: 'warn',
@@ -196,7 +196,11 @@ function checkAiSafety() {
196
196
  }
197
197
 
198
198
  async function runProjectSelfTest(label, command) {
199
- const result = await execaCommand(command, {
199
+ // Prefer splitting simple commands into args to avoid shell interpolation
200
+ const parts = String(command).split(' ').filter(Boolean);
201
+ const cmd = parts.shift();
202
+ const args = parts;
203
+ const result = await execa(cmd, args, {
200
204
  reject: false,
201
205
  timeout: 30000,
202
206
  maxBuffer: 1024 * 1024,