@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.
@@ -33,8 +33,15 @@ function getExternalIP(interfaceName) {
33
33
  // Track active interfaces for cleanup
34
34
  const activeInterfaces = new Map();
35
35
 
36
- // Temp config directory for inline configs
37
- const TEMP_CONFIG_DIR = '/tmp/nwss-wireguard';
36
+ // Temp config directory for inline configs — namespaced per process.
37
+ // disconnectAll() rmSync's this whole directory on exit; a fixed shared path
38
+ // meant two concurrent nwss processes using inline configs would clobber each
39
+ // other: the first to exit deleted the survivor's live .conf, so its
40
+ // `wg-quick down <path>` then failed (file gone) and the kernel interface
41
+ // leaked. Scoping to the PID keeps each process's teardown to its own files.
42
+ // (Stale dirs from a crashed process are left for manual cleanup — sweeping
43
+ // them would reintroduce the same cross-process destruction this avoids.)
44
+ const TEMP_CONFIG_DIR = path.join('/tmp/nwss-wireguard', String(process.pid));
38
45
 
39
46
  /**
40
47
  * Check if running with sufficient privileges
@@ -85,9 +92,11 @@ function resolveInterfaceName(vpnConfig) {
85
92
  * @returns {string} Path to temp config file
86
93
  */
87
94
  function writeInlineConfig(interfaceName, configContent) {
88
- if (!fs.existsSync(TEMP_CONFIG_DIR)) {
89
- fs.mkdirSync(TEMP_CONFIG_DIR, { recursive: true, mode: 0o700 });
90
- }
95
+ // mkdirSync with recursive:true is a no-op when the directory exists,
96
+ // so the prior existsSync gate was redundant. Saves one stat() syscall
97
+ // per VPN bringup and follows the standard "act, don't check-then-act"
98
+ // idiom (also avoids a microscopic TOCTOU window).
99
+ fs.mkdirSync(TEMP_CONFIG_DIR, { recursive: true, mode: 0o700 });
91
100
 
92
101
  const configPath = path.join(TEMP_CONFIG_DIR, `${interfaceName}.conf`);
93
102
  fs.writeFileSync(configPath, configContent, { mode: 0o600 });
@@ -114,6 +123,14 @@ function interfaceUp(configPath, interfaceName, forceDebug = false) {
114
123
  // spawnSync with arg array (no shell) — configPath comes from user
115
124
  // JSON, so naive `sudo wg-quick up "${configPath}"` was vulnerable
116
125
  // to a `";rm -rf ~;"` payload escaping the quotes.
126
+ //
127
+ // NOTE: an "already exists" failure here usually means a previous
128
+ // run's teardown failed and left the kernel interface alive. Recover
129
+ // manually with `sudo wg-quick down <name>` or `sudo ip link delete
130
+ // <name>`. A self-heal mechanism was tried (commit e032bde) and
131
+ // reverted because it raced with concurrent nwss processes sharing
132
+ // the same VPN config — process B's self-heal would destroy process
133
+ // A's live interface. Keep the manual-recovery default for safety.
117
134
  const upRes = spawnSync('sudo', ['wg-quick', 'up', configPath], {
118
135
  encoding: 'utf8',
119
136
  timeout: 15000
@@ -193,10 +210,12 @@ function interfaceDown(interfaceName, forceDebug = false) {
193
210
  // down failure where the kernel interface might persist briefly.
194
211
  // Was previously only inside the try block, so failure paths
195
212
  // leaked the temp file.
213
+ //
214
+ // No existsSync gate — unlinkSync throws ENOENT for missing files
215
+ // and the try/catch already swallows it. Saves one stat() and
216
+ // closes a microscopic TOCTOU window.
196
217
  const tempPath = path.join(TEMP_CONFIG_DIR, `${interfaceName}.conf`);
197
- if (fs.existsSync(tempPath)) {
198
- try { fs.unlinkSync(tempPath); } catch {}
199
- }
218
+ try { fs.unlinkSync(tempPath); } catch {}
200
219
  }
201
220
  }
202
221
 
@@ -310,6 +329,22 @@ function validateVpnConfig(vpnConfig) {
310
329
  result.warnings.push('Both "config" and "config_inline" provided; "config" takes precedence');
311
330
  }
312
331
 
332
+ // F1: Validate user-provided interface name to prevent path traversal via
333
+ // resolveInterfaceName → writeInlineConfig's path.join. Also enforces
334
+ // Linux IFNAMSIZ (15 chars max). User would need to attack their own
335
+ // config (same trust boundary as rest of nwss + must run as root for WG),
336
+ // so this is defensive rather than security-critical — but the validation
337
+ // catches typos that would otherwise produce confusing wg-quick errors.
338
+ if (vpnConfig.interface !== undefined && vpnConfig.interface !== null) {
339
+ if (typeof vpnConfig.interface !== 'string' || !/^[a-zA-Z0-9_-]{1,15}$/.test(vpnConfig.interface)) {
340
+ result.isValid = false;
341
+ result.errors.push(
342
+ `Invalid 'interface' name ${JSON.stringify(vpnConfig.interface)}: ` +
343
+ `must match /^[a-zA-Z0-9_-]{1,15}$/ (Linux IFNAMSIZ limit + path-safe chars)`
344
+ );
345
+ }
346
+ }
347
+
313
348
  // Validate config file exists
314
349
  if (vpnConfig.config) {
315
350
  const configPath = vpnConfig.config;
@@ -365,8 +400,11 @@ async function connectForSite(siteConfig, forceDebug = false) {
365
400
 
366
401
  const interfaceName = resolveInterfaceName(vpnConfig);
367
402
 
368
- // Resolve config path
369
- let configPath;
403
+ // Resolve config path. A file-based config path is fixed up-front; an
404
+ // inline config is a temp file we (re)write inside the retry loop below
405
+ // (it can't be hoisted — see the writeInlineConfig call for why).
406
+ const useInlineConfig = !vpnConfig.config;
407
+ let configPath = null;
370
408
  if (vpnConfig.config) {
371
409
  configPath = vpnConfig.config;
372
410
  // Resolve wg-quick style: if no path separators, look in /etc/wireguard/
@@ -376,8 +414,6 @@ async function connectForSite(siteConfig, forceDebug = false) {
376
414
  configPath = etcPath;
377
415
  }
378
416
  }
379
- } else {
380
- configPath = writeInlineConfig(interfaceName, vpnConfig.config_inline);
381
417
  }
382
418
 
383
419
  const maxAttempts = vpnConfig.retry ? vpnConfig.max_retries + 1 : 1;
@@ -395,9 +431,25 @@ async function connectForSite(siteConfig, forceDebug = false) {
395
431
  await new Promise(resolve => setTimeout(resolve, 2000));
396
432
  }
397
433
 
434
+ // (Re)write the inline temp config before each attempt. interfaceDown's
435
+ // finally unlinks the temp .conf (both the retry reset just above and any
436
+ // teardown from a prior run), so hoisting this above the loop meant a
437
+ // retry's `wg-quick up` ran against a deleted file and always failed.
438
+ // Idempotent on the first attempt.
439
+ if (useInlineConfig) {
440
+ configPath = writeInlineConfig(interfaceName, vpnConfig.config_inline);
441
+ }
442
+
398
443
  const upResult = interfaceUp(configPath, interfaceName, forceDebug);
399
444
  if (!upResult.success) {
400
445
  if (attempt === maxAttempts) {
446
+ // interfaceUp never registered the interface (no activeInterfaces
447
+ // entry), so interfaceDown's finally won't run to clean the temp.
448
+ // Unlink the inline config here so a fully-failed connect doesn't
449
+ // leave a PrivateKey-bearing .conf in /tmp until process exit.
450
+ if (useInlineConfig && configPath) {
451
+ try { fs.unlinkSync(configPath); } catch {}
452
+ }
401
453
  return upResult;
402
454
  }
403
455
  continue;