@fanboynz/network-scanner 3.0.3 → 3.1.2

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/lib/nettools.js CHANGED
@@ -561,8 +561,19 @@ async function whoisLookup(domain = '', timeout = 10000, whoisServer = '', debug
561
561
 
562
562
  const { stdout, stderr } = await execFileWithTimeout('whois', whoisArgs, timeout);
563
563
  const duration = Date.now() - startTime;
564
-
565
- if (stderr && stderr.trim()) {
564
+
565
+ // Treat stderr as failure ONLY when there's no usable stdout. Many whois
566
+ // servers/clients emit non-fatal notices to stderr — referral/redirect
567
+ // lines, rate-limit and GDPR "data redacted" banners, registry
568
+ // disclaimers — while returning the real record on stdout. The old "any
569
+ // stderr -> fail" path discarded those valid records (and triggered
570
+ // needless fallback retries in whoisLookupWithRetry). When stdout has
571
+ // content we prefer it and treat the lookup as successful; the downstream
572
+ // term match is the real gate, so this can't manufacture a match. stderr
573
+ // is still surfaced in debug logging below for visibility.
574
+ const hasUsableStdout = !!(stdout && stdout.trim());
575
+
576
+ if (!hasUsableStdout && stderr && stderr.trim()) {
566
577
  if (debugMode) {
567
578
  if (logFunc) {
568
579
  logFunc(`${messageColors.highlight('[whois]')} Lookup failed for ${cleanDomain} after ${duration}ms`);
@@ -604,6 +615,14 @@ async function whoisLookup(domain = '', timeout = 10000, whoisServer = '', debug
604
615
  console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Server: ${selectedServer || 'default'}`));
605
616
  console.log(formatLogMessage('debug', `${messageColors.highlight('[whois]')} Output length: ${stdout.length} characters`));
606
617
  }
618
+ // Non-fatal stderr alongside usable stdout — kept visible so a real
619
+ // problem (truncation, partial referral) is still diagnosable even
620
+ // though we're treating the lookup as a success.
621
+ if (stderr && stderr.trim()) {
622
+ const note = `${messageColors.highlight('[whois]')} Non-fatal stderr (stdout used): ${stderr.trim()}`;
623
+ if (logFunc) logFunc(note);
624
+ else console.log(formatLogMessage('debug', note));
625
+ }
607
626
  }
608
627
 
609
628
  return {
@@ -1021,66 +1040,6 @@ async function digLookup(domain = '', recordType = 'A', timeout = 5000) {
1021
1040
  }
1022
1041
  }
1023
1042
 
1024
- /**
1025
- * Checks if whois output contains all specified search terms (AND logic)
1026
- * @param {string} whoisOutput - The whois lookup output
1027
- * @param {Array<string>} searchTerms - Array of terms that must all be present
1028
- * @returns {boolean} True if all terms are found
1029
- */
1030
- function checkWhoisTerms(whoisOutput, searchTerms) {
1031
- if (!searchTerms || !Array.isArray(searchTerms) || searchTerms.length === 0) {
1032
- return false;
1033
- }
1034
-
1035
- const lowerOutput = whoisOutput.toLowerCase();
1036
- return searchTerms.every(term => lowerOutput.includes(term.toLowerCase()));
1037
- }
1038
-
1039
- /**
1040
- * Checks if whois output contains any of the specified search terms (OR logic)
1041
- * @param {string} whoisOutput - The whois lookup output
1042
- * @param {Array<string>} searchTerms - Array of terms where at least one must be present
1043
- * @returns {boolean} True if any term is found
1044
- */
1045
- function checkWhoisTermsOr(whoisOutput, searchTerms) {
1046
- if (!searchTerms || !Array.isArray(searchTerms) || searchTerms.length === 0) {
1047
- return false;
1048
- }
1049
-
1050
- const lowerOutput = whoisOutput.toLowerCase();
1051
- return searchTerms.some(term => lowerOutput.includes(term.toLowerCase()));
1052
- }
1053
-
1054
- /**
1055
- * Checks if dig output contains all specified search terms (AND logic)
1056
- * @param {string} digOutput - The dig lookup output
1057
- * @param {Array<string>} searchTerms - Array of terms that must all be present
1058
- * @returns {boolean} True if all terms are found
1059
- */
1060
- function checkDigTerms(digOutput, searchTerms) {
1061
- if (!searchTerms || !Array.isArray(searchTerms) || searchTerms.length === 0) {
1062
- return false;
1063
- }
1064
-
1065
- const lowerOutput = digOutput.toLowerCase();
1066
- return searchTerms.every(term => lowerOutput.includes(term.toLowerCase()));
1067
- }
1068
-
1069
- /**
1070
- * Checks if dig output contains any of the specified search terms (OR logic)
1071
- * @param {string} digOutput - The dig lookup output
1072
- * @param {Array<string>} searchTerms - Array of terms where at least one must be present
1073
- * @returns {boolean} True if any term is found
1074
- */
1075
- function checkDigTermsOr(digOutput, searchTerms) {
1076
- if (!searchTerms || !Array.isArray(searchTerms) || searchTerms.length === 0) {
1077
- return false;
1078
- }
1079
-
1080
- const lowerOutput = digOutput.toLowerCase();
1081
- return searchTerms.some(term => lowerOutput.includes(term.toLowerCase()));
1082
- }
1083
-
1084
1043
  /**
1085
1044
  * Enhanced dry run callback factory for better nettools reporting
1086
1045
  * @param {Map} matchedDomains - The matched domains collection
@@ -1221,7 +1180,20 @@ function createNetToolsHandler(config) {
1221
1180
  const digDedupeKey = `${digDomain}:${digConfigKey}`;
1222
1181
  const needsWhoisLookup = (hasWhois || hasWhoisOr) && !processedWhoisDomains.has(whoisDedupeKey);
1223
1182
  const needsDigLookup = (hasDig || hasDigOr) && !processedDigDomains.has(digDedupeKey);
1224
-
1183
+
1184
+ // Claim the dedupe keys NOW, synchronously, before executeNetToolsLookup
1185
+ // hits its first await. These Sets are shared across all concurrent URL
1186
+ // handlers, so the .has() checks above and these .add() claims must sit in
1187
+ // the same uninterrupted synchronous span to be atomic. The whois claim
1188
+ // was already safe (its add ran before any await), but the dig claim used
1189
+ // to live AFTER the whois lookup's await — opening a window where a second
1190
+ // handler for the same domain passed the dig check before the first
1191
+ // claimed it, running dig twice. Claiming both here closes that window.
1192
+ // Matches the existing claim-before-lookup semantics (no rollback on
1193
+ // failure: a failed lookup still suppresses retries for the TTL).
1194
+ if (needsWhoisLookup) processedWhoisDomains.add(whoisDedupeKey);
1195
+ if (needsDigLookup) processedDigDomains.add(digDedupeKey);
1196
+
1225
1197
  // Skip if we don't need to perform any lookups
1226
1198
  if (!needsWhoisLookup && !needsDigLookup) {
1227
1199
  if (forceDebug) {
@@ -1320,9 +1292,9 @@ function createNetToolsHandler(config) {
1320
1292
 
1321
1293
  // Perform whois lookup if either whois or whois-or is configured
1322
1294
  if (needsWhoisLookup) {
1323
- // Mark whois root domain+config as being processed
1324
- processedWhoisDomains.add(whoisDedupeKey);
1325
-
1295
+ // Dedupe key already claimed up-front (before the first await) to keep
1296
+ // the has()/add() span atomic across concurrent handlers.
1297
+
1326
1298
  // Check whois cache first - cache key includes server for accuracy
1327
1299
  const selectedServer = selectWhoisServer(whoisServer, whoisServerMode);
1328
1300
  const whoisCacheKey = `${whoisRootDomain}-${(selectedServer && selectedServer !== '') ? selectedServer : 'default'}`;
@@ -1438,11 +1410,10 @@ function createNetToolsHandler(config) {
1438
1410
  if (whoisResult) {
1439
1411
 
1440
1412
  if (whoisResult.success) {
1441
- // Lowercase the output ONCE checkWhoisTerms / checkWhoisTermsOr
1442
- // each call .toLowerCase() on their input independently, which
1443
- // re-allocates a multi-KB lowercased string per call. Pre-lowering
1444
- // here lets the AND check, OR check, and matched-term find share
1445
- // a single allocation.
1413
+ // Lowercase the output ONCE. The AND check, OR check, and
1414
+ // matched-term find below each need a lowercased copy; doing it
1415
+ // per-check would re-allocate a multi-KB string each time, so
1416
+ // pre-lower here and let all three share a single allocation.
1446
1417
  const whoisOutputLower = whoisResult.output.toLowerCase();
1447
1418
 
1448
1419
  // Check AND terms if configured
@@ -1578,9 +1549,10 @@ function createNetToolsHandler(config) {
1578
1549
 
1579
1550
  // Perform dig lookup if configured
1580
1551
  if (needsDigLookup) {
1581
- // Mark dig domain+config as being processed (includes specific subdomain)
1582
- processedDigDomains.add(digDedupeKey);
1583
-
1552
+ // Dedupe key already claimed up-front (before the first await) to keep
1553
+ // the has()/add() span atomic across concurrent handlers — this used
1554
+ // to claim here, after the whois await, which left the race window.
1555
+
1584
1556
  if (forceDebug) {
1585
1557
  const digTypes = [];
1586
1558
  if (hasDig) digTypes.push('dig-and');
@@ -1826,8 +1798,7 @@ function createNetToolsHandler(config) {
1826
1798
 
1827
1799
  // Public surface kept narrow on purpose -- only what nwss.js actually
1828
1800
  // imports (verified via repo-wide grep). Internal helpers
1829
- // (whoisLookup, whoisLookupWithRetry, digLookup, checkWhoisTerms,
1830
- // checkWhoisTermsOr, checkDigTerms, checkDigTermsOr, selectWhoisServer,
1801
+ // (whoisLookup, whoisLookupWithRetry, digLookup, selectWhoisServer,
1831
1802
  // getCommonWhoisServers, suggestWhoisServers, execFileWithTimeout,
1832
1803
  // markResolved, digOutputIndicatesResolution, loadDiskCache,
1833
1804
  // saveDiskCache, enforceCacheCap, stripAnsiColors) stay as module-local
@@ -8,7 +8,8 @@
8
8
  // VPN tunnel � not just the site that requested it. For isolated
9
9
  // per-site VPN with concurrency, a SOCKS proxy approach is needed.
10
10
 
11
- const { execSync, spawn } = require('child_process');
11
+ const { execSync, spawn, spawnSync } = require('child_process');
12
+ const crypto = require('crypto');
12
13
  const fs = require('fs');
13
14
  const path = require('path');
14
15
  const { formatLogMessage, messageColors } = require('./colorize');
@@ -22,10 +23,17 @@ const OPENVPN_TAG = messageColors.processing('[openvpn]');
22
23
  function getExternalIP(tunDevice) {
23
24
  const services = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com'];
24
25
  for (const service of services) {
25
- try {
26
- const iface = tunDevice ? `--interface ${tunDevice}` : '';
27
- return execSync(`curl -s -m 5 ${iface} ${service}`, { encoding: 'utf8', timeout: 8000 }).trim();
28
- } catch {}
26
+ // spawnSync with arg array (no shell) — tunDevice flows from
27
+ // findTunDevice (kernel-assigned /sys/class/net names, practically
28
+ // safe) but the pattern with execSync + interpolation was bad style.
29
+ // Match wireguard_vpn.js's spawn-array approach for consistency.
30
+ const args = ['-s', '-m', '5'];
31
+ if (tunDevice) args.push('--interface', tunDevice);
32
+ args.push(service);
33
+ const result = spawnSync('curl', args, { encoding: 'utf8', timeout: 8000 });
34
+ if (result.status === 0 && result.stdout) {
35
+ return result.stdout.trim();
36
+ }
29
37
  }
30
38
  return null;
31
39
  }
@@ -127,7 +135,13 @@ function checkTunDevice() {
127
135
  * Ensure temp directory exists with secure permissions
128
136
  */
129
137
  function ensureTempDir() {
130
- fs.mkdirSync(TEMP_DIR, { recursive: true, mode: 0o755 });
138
+ // 0o700 matches wireguard_vpn.js other users on the box can't list
139
+ // the dir to discover which connection names exist. Individual files
140
+ // inside are already 0o600 so contents were safe before, but directory
141
+ // listing leaked the connection-name list. Note: mode is only applied
142
+ // on creation; an existing dir from a prior run with mode 0o755 keeps
143
+ // its mode until disconnectAll's rm -rf + next session creates fresh.
144
+ fs.mkdirSync(TEMP_DIR, { recursive: true, mode: 0o700 });
131
145
  }
132
146
 
133
147
  /**
@@ -169,8 +183,19 @@ function resolveConnectionName(vpnConfig) {
169
183
  if (vpnConfig.config) {
170
184
  return path.basename(vpnConfig.config, '.ovpn');
171
185
  }
172
- const index = activeConnections.size;
173
- return `nwss-ovpn${index}`;
186
+ // Inline-only config without explicit name: derive a stable name from a
187
+ // hash of the content so connect and disconnect resolve to the same name
188
+ // across calls. The old `nwss-ovpn${activeConnections.size}` used the
189
+ // live Map size, so disconnect computed a DIFFERENT name than connect
190
+ // did (size had grown in between) and silently failed to find the
191
+ // entry — the connection would leak until disconnectAll. Same fix as
192
+ // wireguard_vpn.js commit 478a3ad.
193
+ if (vpnConfig.config_inline) {
194
+ const hash = crypto.createHash('sha1').update(vpnConfig.config_inline).digest('hex').slice(0, 8);
195
+ return `nwss-ovpn${hash}`;
196
+ }
197
+ // Last resort — should be unreachable if validation ran first.
198
+ return 'nwss-ovpn-unknown';
174
199
  }
175
200
 
176
201
  /**
@@ -359,14 +384,23 @@ async function startConnection(configPath, vpnConfig, forceDebug = false) {
359
384
  return { success: true, connection: connectionName, tunDevice: existing.tunDevice, alreadyActive: true };
360
385
  }
361
386
 
362
- // Kill any stale processes from a previous run using this config
363
- try {
364
- execSync(`sudo pkill -TERM -f "openvpn.*${connectionName}" 2>/dev/null`, {
365
- encoding: 'utf8', timeout: 3000
366
- });
367
- // Brief wait for cleanup
368
- execSync('sleep 1', { timeout: 3000 });
369
- } catch {}
387
+ // Kill any stale processes from a previous run using this config.
388
+ // spawnSync with arg array (no shell) — connectionName flows from
389
+ // user config (vpnConfig.name). Naive shell interpolation here was
390
+ // vulnerable to '`; rm -rf ~; #' style injection.
391
+ const pkillRes = spawnSync('sudo', ['pkill', '-TERM', '-f', `openvpn.*${connectionName}`], {
392
+ encoding: 'utf8', timeout: 3000
393
+ });
394
+ // Only sleep if pkill actually matched + killed something (status 0).
395
+ // pkill exits 1 when no processes match — pre-execSync code threw on
396
+ // non-zero and the surrounding try/catch swallowed it, so the sleep
397
+ // never ran in the no-stale-process case. With spawnSync (no throw
398
+ // on non-zero), we have to gate this explicitly to preserve the same
399
+ // behavior — otherwise every fresh connect would waste 1s of
400
+ // blocking event loop.
401
+ if (pkillRes.status === 0) {
402
+ spawnSync('sleep', ['1'], { timeout: 3000 });
403
+ }
370
404
 
371
405
  ensureTempDir();
372
406
  const logPath = path.join(TEMP_DIR, `${connectionName}.log`);
@@ -411,7 +445,17 @@ async function startConnection(configPath, vpnConfig, forceDebug = false) {
411
445
  if (!result.connected) {
412
446
  // Kill the process if still running
413
447
  try { child.kill('SIGTERM'); } catch {}
414
- setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 3000);
448
+ // C2: check exitCode/signalCode before SIGKILL — child.kill uses the
449
+ // captured PID, and Linux PIDs can be reused. If the process already
450
+ // exited in the 3s window, a reused PID could belong to an unrelated
451
+ // process that we'd then SIGKILL. unref() lets the event loop exit
452
+ // naturally instead of waiting on this background cleanup timer.
453
+ const sigkillTimer = setTimeout(() => {
454
+ if (child.exitCode === null && child.signalCode === null) {
455
+ try { child.kill('SIGKILL'); } catch {}
456
+ }
457
+ }, 3000);
458
+ if (typeof sigkillTimer.unref === 'function') sigkillTimer.unref();
415
459
  return { success: false, connection: connectionName, error: result.error };
416
460
  }
417
461
 
@@ -441,21 +485,19 @@ function stopConnection(connectionName, forceDebug = false) {
441
485
  }
442
486
 
443
487
  try {
444
- // Find the actual openvpn PID (child of sudo) and kill it
445
- try {
446
- execSync(`sudo pkill -TERM -f "openvpn.*${connectionName}" 2>/dev/null`, {
447
- encoding: 'utf8', timeout: 3000
448
- });
449
- } catch {}
488
+ // Find the actual openvpn PID (child of sudo) and kill it.
489
+ // spawnSync with arg array — connectionName from user config, naive
490
+ // shell interpolation was vulnerable to ';rm -rf ~' style injection.
491
+ spawnSync('sudo', ['pkill', '-TERM', '-f', `openvpn.*${connectionName}`], {
492
+ encoding: 'utf8', timeout: 3000
493
+ });
450
494
 
451
495
  const killed = waitForProcessExit(info.pid, 5000);
452
496
  if (!killed) {
453
- try {
454
- execSync(`sudo pkill -9 -f "openvpn.*${connectionName}" 2>/dev/null`, {
455
- encoding: 'utf8', timeout: 3000
456
- });
457
- } catch {}
458
- }
497
+ spawnSync('sudo', ['pkill', '-9', '-f', `openvpn.*${connectionName}`], {
498
+ encoding: 'utf8', timeout: 3000
499
+ });
500
+ }
459
501
  } catch (killErr) {
460
502
  // Process may already be dead
461
503
  if (forceDebug) {
@@ -532,13 +574,18 @@ function checkConnection(connectionName, testHost = '1.1.1.1', forceDebug = fals
532
574
  return { connected: false, error: `OpenVPN process exited with code ${info.process.exitCode}` };
533
575
  }
534
576
 
535
- // Ping through the tunnel interface
577
+ // Ping through the tunnel interface. spawnSync with arg array —
578
+ // testHost flows from user config (vpnConfig.test_host), naive shell
579
+ // interpolation was vulnerable to '1.1.1.1; rm -rf ~' style injection.
536
580
  try {
537
581
  const iface = info.tunDevice || 'tun0';
538
- const result = execSync(
539
- `ping -c 1 -W 5 -I ${iface} ${testHost} 2>&1`,
540
- { encoding: 'utf8', timeout: 8000 }
541
- );
582
+ const pingRes = spawnSync('ping', ['-c', '1', '-W', '5', '-I', iface, testHost], {
583
+ encoding: 'utf8', timeout: 8000
584
+ });
585
+ if (pingRes.status !== 0) {
586
+ throw new Error((pingRes.stderr || pingRes.stdout || '').split('\n')[0] || `ping failed for ${testHost}`);
587
+ }
588
+ const result = pingRes.stdout;
542
589
 
543
590
  const latencyMatch = result.match(/time=([0-9.]+)\s*ms/);
544
591
  const latencyMs = latencyMatch ? parseFloat(latencyMatch[1]) : null;
@@ -660,6 +707,23 @@ function validateOvpnConfig(ovpnConfig) {
660
707
  result.warnings.push('Both "config" and "config_inline" provided; "config" takes precedence');
661
708
  }
662
709
 
710
+ // D1: Validate user-provided connection 'name' to prevent path traversal
711
+ // via writeAuthFile/writeInlineConfig's path.join, AND to limit the blast
712
+ // radius of any future shell-interpolation that re-introduces ${name}.
713
+ // The current shell calls all use spawnSync with arg arrays (S1 fix), but
714
+ // the regex still blocks values that would confuse pkill's -f pattern
715
+ // match. Length 32 = generous for connection names but bounded.
716
+ // Mirror of wireguard_vpn.js F1 validation.
717
+ if (ovpnConfig.name !== undefined && ovpnConfig.name !== null) {
718
+ if (typeof ovpnConfig.name !== 'string' || !/^[a-zA-Z0-9_-]{1,32}$/.test(ovpnConfig.name)) {
719
+ result.isValid = false;
720
+ result.errors.push(
721
+ `Invalid 'name' value ${JSON.stringify(ovpnConfig.name)}: ` +
722
+ `must match /^[a-zA-Z0-9_-]{1,32}$/ (path-safe chars, max 32)`
723
+ );
724
+ }
725
+ }
726
+
663
727
  // Validate config file exists
664
728
  if (ovpnConfig.config) {
665
729
  const configPath = ovpnConfig.config;
@@ -796,7 +860,24 @@ async function connectForSite(siteConfig, forceDebug = false) {
796
860
  }
797
861
  }
798
862
 
799
- const externalIP = getExternalIP(startResult.tunDevice);
863
+ // C3: Only fetch external IP when debug-logging would actually use it.
864
+ // getExternalIP runs up to 3 sequential 8s-timeout curls (~24s worst
865
+ // case of blocking event loop) per VPN connect. Without this gate,
866
+ // every successful OpenVPN connect burned 1-24s on a value that's
867
+ // only displayed in the nwss info log when present — and only
868
+ // genuinely useful for debugging. Matches wireguard_vpn.js's
869
+ // forceDebug-gated approach (commit b97dedb).
870
+ //
871
+ // UX trade-off: the nwss info log (nwss.js:2273) only shows the IP
872
+ // when forceDebug is on. Users wanting the IP in non-debug runs need
873
+ // --debug; same behavior as WireGuard.
874
+ let externalIP = null;
875
+ if (forceDebug) {
876
+ externalIP = getExternalIP(startResult.tunDevice);
877
+ if (externalIP) {
878
+ console.log(formatLogMessage('debug', `${OPENVPN_TAG} ${connectionName} external IP: ${externalIP}`));
879
+ }
880
+ }
800
881
  return { success: true, connection: connectionName, tunDevice: startResult.tunDevice, externalIP };
801
882
  }
802
883
 
package/lib/proxy.js CHANGED
@@ -253,8 +253,12 @@ function getProxyArgs(siteConfig, forceDebug = false) {
253
253
  console.warn(formatLogMessage('proxy', `proxy_remote_dns ignored: SOCKS4 cannot do proxy-side DNS resolution (use SOCKS5)`));
254
254
  }
255
255
 
256
- // Bypass list: domains that skip the proxy
257
- const bypass = siteConfig.proxy_bypass || siteConfig.socks5_bypass || [];
256
+ // Bypass list: domains that skip the proxy. Accept either an array (the
257
+ // documented form) or a single string — a bare "localhost" used to throw
258
+ // `bypass.join is not a function` here, in the browser-launch path. Same
259
+ // string-or-array tolerance as the dig/whois siteConfig fields.
260
+ const rawBypass = siteConfig.proxy_bypass || siteConfig.socks5_bypass || [];
261
+ const bypass = Array.isArray(rawBypass) ? rawBypass : [rawBypass];
258
262
  if (bypass.length > 0) {
259
263
  args.push(`--proxy-bypass-list=${bypass.join(';')}`);
260
264
  }