@fanboynz/network-scanner 3.0.2 → 3.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/CHANGELOG.md +34 -0
- package/lib/adblock-rust.js +17 -4
- package/lib/adblock.js +92 -15
- package/lib/browserhealth.js +57 -28
- package/lib/cdp.js +68 -34
- package/lib/clear_sitedata.js +68 -20
- package/lib/compress.js +26 -58
- package/lib/curl.js +44 -22
- package/lib/domain-cache.js +8 -57
- package/lib/dry-run.js +9 -4
- package/lib/fingerprint.js +735 -114
- package/lib/interaction.js +262 -26
- package/lib/nettools.js +47 -76
- package/lib/openvpn_vpn.js +116 -35
- package/lib/searchstring.js +15 -237
- package/lib/validate_rules.js +285 -3
- package/lib/wireguard_vpn.js +64 -12
- package/nwss.js +529 -217
- package/package.json +1 -1
- package/regex-tool/index.html +321 -628
- package/scripts/test-stealth.js +39 -13
package/lib/validate_rules.js
CHANGED
|
@@ -583,7 +583,11 @@ function validateRulesetFile(filePath, options = {}) {
|
|
|
583
583
|
errors.push(`Line ${lineNumber}: ${validation.error} - ${line}`);
|
|
584
584
|
|
|
585
585
|
if (errors.length >= maxErrors) {
|
|
586
|
-
|
|
586
|
+
// Lines remaining in the file = total lines − current index − 1.
|
|
587
|
+
// (Previously `stats.total - i - 1`, which mixed "non-empty lines
|
|
588
|
+
// processed" with "file line index" and went negative when empties
|
|
589
|
+
// were interleaved.)
|
|
590
|
+
errors.push(`... (stopping after ${maxErrors} errors, ${lines.length - i - 1} lines remaining)`);
|
|
587
591
|
break;
|
|
588
592
|
}
|
|
589
593
|
}
|
|
@@ -1075,9 +1079,286 @@ function testDomainValidation() {
|
|
|
1075
1079
|
return allPassed;
|
|
1076
1080
|
}
|
|
1077
1081
|
|
|
1082
|
+
// ─── Per-site config normalization (runs on every scan, not just --validate-config) ───
|
|
1083
|
+
//
|
|
1084
|
+
// Catches the silent-failure class that bit a user across multiple scan iterations:
|
|
1085
|
+
// 1. Typo'd siteConfig keys (whois_terms vs whois) silently ignored.
|
|
1086
|
+
// 2. Boolean fields given truthy/falsy non-boolean values (interact: 1 vs interact: true)
|
|
1087
|
+
// silently disabled by strict `=== true` checks downstream.
|
|
1088
|
+
// 3. Misleading downstream warnings that blame the wrong field.
|
|
1089
|
+
//
|
|
1090
|
+
// normalizeSiteConfig() mutates siteConfig in place (coercing 1→true, etc) and returns
|
|
1091
|
+
// warnings the caller surfaces. Designed to run at scan startup, ALWAYS, not gated on
|
|
1092
|
+
// --validate-config (which most users never run).
|
|
1093
|
+
|
|
1094
|
+
// Whitelist of every siteConfig.X key read across nwss.js + lib/*.js.
|
|
1095
|
+
// Regenerate via BOTH:
|
|
1096
|
+
// grep -hoE "siteConfig\.[a-zA-Z_][a-zA-Z0-9_]*" nwss.js lib/*.js | sort -u
|
|
1097
|
+
// grep -hoE "siteConfig\[['\"][^'\"]+['\"]\]" nwss.js lib/*.js | sort -u
|
|
1098
|
+
// The second pattern catches bracket-notation access required for keys with
|
|
1099
|
+
// hyphens (e.g. 'dig-or', 'whois-or'). Dot-notation grep alone missed these
|
|
1100
|
+
// and produced false 'unknown siteConfig key' warnings for valid config.
|
|
1101
|
+
// Also grep for destructured siteConfig keys (master destructure block in
|
|
1102
|
+
// processUrl) — those don't show up in either pattern.
|
|
1103
|
+
const KNOWN_SITE_CONFIG_KEYS = new Set([
|
|
1104
|
+
'adblock_rules', 'blocked', 'bypass_cache', 'capture_popups',
|
|
1105
|
+
'capture_popups_max_depth', 'capture_popups_window_ms', 'cdp', 'cdp_specific',
|
|
1106
|
+
'clear_sitedata', 'clear_sitedata_full_on_reload',
|
|
1107
|
+
'cloudflare_bypass', 'cloudflare_max_retries', 'comments',
|
|
1108
|
+
'cloudflare_parallel_detection', 'cloudflare_phish', 'cloudflare_retry_on_error',
|
|
1109
|
+
'css_blocked', 'curl', 'cursor_mode', 'custom_headers', 'delay',
|
|
1110
|
+
'delay_uncapped', 'detect_js_patterns', 'dig', 'dig-or', 'digRecordType', 'dig_subdomain',
|
|
1111
|
+
'disable_adblock', 'dnsmasq', 'dnsmasq_old', 'evaluateOnNewDocument',
|
|
1112
|
+
'even_blocked',
|
|
1113
|
+
'filterRegex', 'fingerprint_protection', 'firstParty', 'flowproxy_additional_delay',
|
|
1114
|
+
'flowproxy_delay', 'flowproxy_detection', 'flowproxy_js_timeout', 'flowproxy_nav_timeout',
|
|
1115
|
+
'flowproxy_page_timeout', 'forcereload', 'ghost_cursor_duration',
|
|
1116
|
+
'ghost_cursor_hesitate', 'ghost_cursor_overshoot', 'ghost_cursor_speed',
|
|
1117
|
+
'goto_options', 'grep', 'headful', 'ignore_similar', 'ignore_similar_ignored_domains',
|
|
1118
|
+
'ignore_similar_threshold', 'interact', 'interact_click_count', 'interact_clicks',
|
|
1119
|
+
'interact_duration', 'interact_intensity', 'interact_scrolling', 'isBrave',
|
|
1120
|
+
'js_redirect_timeout', 'localhost', 'max_redirects', 'openvpn', 'pihole',
|
|
1121
|
+
'plain', 'privoxy', 'proxy', 'proxy_bypass', 'proxy_debug', 'proxy_remote_dns',
|
|
1122
|
+
'realistic_click', 'referrer_disable', 'referrer_headers', 'regex_and',
|
|
1123
|
+
'reload', 'resourceTypes', 'screenshot', 'searchstring', 'searchstring_and',
|
|
1124
|
+
'socks5_bypass', 'socks5_debug', 'socks5_proxy', 'socks5_remote_dns',
|
|
1125
|
+
'subDomains',
|
|
1126
|
+
'thirdParty', 'timeout', 'unbound', 'url', 'userAgent', 'verbose', 'vpn',
|
|
1127
|
+
'whois', 'whois-or', 'whois_delay', 'whois_max_retries', 'whois_retry_on_error',
|
|
1128
|
+
'whois_retry_on_timeout', 'whois_server', 'whois_server_mode',
|
|
1129
|
+
'whois_timeout_multiplier', 'whois_use_fallback', 'window_cleanup',
|
|
1130
|
+
'window_cleanup_threshold',
|
|
1131
|
+
// Internal sentinel added by nwss.js when fanning array URLs into tasks.
|
|
1132
|
+
'_originalUrl',
|
|
1133
|
+
]);
|
|
1134
|
+
|
|
1135
|
+
// Boolean siteConfig fields where strict `=== true` is used downstream.
|
|
1136
|
+
// Listed only for fields with UNAMBIGUOUS boolean semantics — fields with
|
|
1137
|
+
// multi-type overloads stay out:
|
|
1138
|
+
// forcereload : true | string[]
|
|
1139
|
+
// cloudflare_bypass : true | 'debug'
|
|
1140
|
+
// cloudflare_phish : true | 'debug'
|
|
1141
|
+
// window_cleanup : true | 'all' | 'realtime'
|
|
1142
|
+
// cursor_mode : string ('ghost')
|
|
1143
|
+
// Update both this set AND the strict-equality call sites if a new boolean
|
|
1144
|
+
// siteConfig field is added.
|
|
1145
|
+
const BOOLEAN_SITE_CONFIG_FIELDS = new Set([
|
|
1146
|
+
'adblock_rules', 'bypass_cache', 'capture_popups', 'cdp', 'clear_sitedata',
|
|
1147
|
+
'clear_sitedata_full_on_reload', 'curl', 'delay_uncapped',
|
|
1148
|
+
'detect_js_patterns', 'dig_subdomain',
|
|
1149
|
+
'disable_adblock', 'dnsmasq', 'dnsmasq_old', 'evaluateOnNewDocument',
|
|
1150
|
+
'even_blocked', 'firstParty', 'flowproxy_detection',
|
|
1151
|
+
'grep', 'headful', 'ignore_similar', 'ignore_similar_ignored_domains',
|
|
1152
|
+
'interact', 'interact_clicks', 'interact_scrolling', 'isBrave', 'localhost',
|
|
1153
|
+
'pihole', 'plain', 'privoxy', 'proxy_debug', 'proxy_remote_dns',
|
|
1154
|
+
'realistic_click', 'referrer_disable', 'regex_and', 'screenshot',
|
|
1155
|
+
'searchstring_and', 'socks5_debug', 'socks5_remote_dns', 'thirdParty',
|
|
1156
|
+
'unbound', 'whois_retry_on_error', 'whois_retry_on_timeout', 'whois_use_fallback',
|
|
1157
|
+
]);
|
|
1158
|
+
|
|
1159
|
+
// Fields that accept BOTH `"x"` (single term) and `["x", "y"]` (multi-term).
|
|
1160
|
+
// Downstream consumers (nwss.js line ~2824, lib/nettools.js line ~1149-1152)
|
|
1161
|
+
// do `Array.isArray(val) && val.length > 0` checks, so a string value
|
|
1162
|
+
// previously caused silent feature-disable. normalizeSiteConfig() now wraps
|
|
1163
|
+
// any string value in a single-element array so both forms are first-class.
|
|
1164
|
+
// Non-string non-array values still warn (and stay as-is, since we don't
|
|
1165
|
+
// know how to coerce them).
|
|
1166
|
+
const STRING_TO_ARRAY_FIELDS = new Set([
|
|
1167
|
+
'dig', 'dig-or', 'whois', 'whois-or',
|
|
1168
|
+
]);
|
|
1169
|
+
|
|
1170
|
+
// Truthy-but-not-true → true. Falsy-but-not-false → false. Otherwise leave alone.
|
|
1171
|
+
// Strings are lower-cased before matching so "True"/"TRUE"/"Yes"/etc all match.
|
|
1172
|
+
function _coerceBooleanLike(val) {
|
|
1173
|
+
if (val === true || val === false) return { coerced: false, value: val };
|
|
1174
|
+
const s = typeof val === 'string' ? val.toLowerCase() : val;
|
|
1175
|
+
if (s === 1 || s === '1' || s === 'true' || s === 'yes' || s === 'on') {
|
|
1176
|
+
return { coerced: true, value: true };
|
|
1177
|
+
}
|
|
1178
|
+
if (s === 0 || s === '0' || s === 'false' || s === 'no' || s === 'off') {
|
|
1179
|
+
return { coerced: true, value: false };
|
|
1180
|
+
}
|
|
1181
|
+
return { coerced: false, value: val };
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Tiny Levenshtein for "did you mean?" suggestions. Inlined rather than
|
|
1185
|
+
// imported from lib/ignore_similar (which has its own dependency tree we
|
|
1186
|
+
// don't want to drag into validation) -- 18 lines of well-known algorithm.
|
|
1187
|
+
function _editDistance(a, b) {
|
|
1188
|
+
if (a === b) return 0;
|
|
1189
|
+
if (!a) return b.length;
|
|
1190
|
+
if (!b) return a.length;
|
|
1191
|
+
const m = a.length, n = b.length;
|
|
1192
|
+
let prev = new Array(n + 1);
|
|
1193
|
+
let curr = new Array(n + 1);
|
|
1194
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
1195
|
+
for (let i = 1; i <= m; i++) {
|
|
1196
|
+
curr[0] = i;
|
|
1197
|
+
for (let j = 1; j <= n; j++) {
|
|
1198
|
+
curr[j] = a[i - 1] === b[j - 1]
|
|
1199
|
+
? prev[j - 1]
|
|
1200
|
+
: 1 + Math.min(prev[j - 1], prev[j], curr[j - 1]);
|
|
1201
|
+
}
|
|
1202
|
+
[prev, curr] = [curr, prev];
|
|
1203
|
+
}
|
|
1204
|
+
return prev[n];
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Suggest a known key for an unknown one. Two parallel candidate searches,
|
|
1208
|
+
// then pick the better signal:
|
|
1209
|
+
//
|
|
1210
|
+
// 1. EDIT-DISTANCE candidate — classic typo case ('intract' → 'interact').
|
|
1211
|
+
// Threshold scales with the unknown key's length (40%, min 2) so short
|
|
1212
|
+
// typos stay matchable.
|
|
1213
|
+
//
|
|
1214
|
+
// 2. PREFIX candidate — "user added a suffix to a known root" case.
|
|
1215
|
+
// 'whois_terms' starts with 'whois' (known key) → suggest 'whois'.
|
|
1216
|
+
// Requires the prefix to be at least 3 chars to avoid spurious matches
|
|
1217
|
+
// on accidental 1-2 letter prefixes. Among multiple prefix candidates,
|
|
1218
|
+
// we take the LONGEST (most specific category boundary).
|
|
1219
|
+
//
|
|
1220
|
+
// Ranking: if there's a very close edit-distance match (≤2 edits), prefer
|
|
1221
|
+
// it — almost certainly a misspelling of that specific key (e.g.
|
|
1222
|
+
// 'whois_max_retri' → 'whois_max_retries' at distance 2 beats the prefix
|
|
1223
|
+
// match 'whois'). Otherwise prefer the prefix match when present, since
|
|
1224
|
+
// "extra suffix on a known root" is a stronger signal than a 4+-edit
|
|
1225
|
+
// distance to an unrelated key.
|
|
1226
|
+
function _suggestKey(unknownKey, knownKeys) {
|
|
1227
|
+
const threshold = Math.max(2, Math.floor(unknownKey.length * 0.4));
|
|
1228
|
+
let distBest = null, distBestVal = Infinity;
|
|
1229
|
+
let prefixBest = null, prefixBestLen = 0;
|
|
1230
|
+
|
|
1231
|
+
for (const k of knownKeys) {
|
|
1232
|
+
const d = _editDistance(unknownKey, k);
|
|
1233
|
+
if (d < distBestVal && d <= threshold) {
|
|
1234
|
+
distBestVal = d;
|
|
1235
|
+
distBest = k;
|
|
1236
|
+
}
|
|
1237
|
+
if (k.length >= 3 && unknownKey !== k &&
|
|
1238
|
+
unknownKey.startsWith(k) && k.length > prefixBestLen) {
|
|
1239
|
+
prefixBest = k;
|
|
1240
|
+
prefixBestLen = k.length;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
if (distBest && distBestVal <= 2) return distBest;
|
|
1245
|
+
return prefixBest || distBest;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Per-site validation + boolean coercion run at scan startup (always, not
|
|
1250
|
+
* gated on --validate-config).
|
|
1251
|
+
*
|
|
1252
|
+
* Mutates siteConfig in place to coerce boolean-like values (1, 0, "true",
|
|
1253
|
+
* "false", "yes", "no", "on", "off") to true/false for fields in
|
|
1254
|
+
* BOOLEAN_SITE_CONFIG_FIELDS. Returns warnings the caller surfaces via the
|
|
1255
|
+
* usual logging path.
|
|
1256
|
+
*
|
|
1257
|
+
* Catches the failure classes:
|
|
1258
|
+
* 1. Unknown siteConfig keys → typo warning + "did you mean?" suggestion.
|
|
1259
|
+
* Example: 'whois_terms' → "did you mean 'whois'?"
|
|
1260
|
+
* 2. Boolean field with truthy non-boolean value → coerce + warn.
|
|
1261
|
+
* Example: 'interact: 1' → coerced to 'interact: true', warning emitted.
|
|
1262
|
+
* 3. Boolean field with non-boolean non-truthy value → warn only, no coerce.
|
|
1263
|
+
* Example: 'interact: "maybe"' → warned, left alone.
|
|
1264
|
+
*
|
|
1265
|
+
* @param {object} siteConfig - mutated in place
|
|
1266
|
+
* @param {number} siteIndex - for warning messages
|
|
1267
|
+
* @returns {{warnings: string[], errors: string[]}}
|
|
1268
|
+
*/
|
|
1269
|
+
function normalizeSiteConfig(siteConfig, siteIndex = 0) {
|
|
1270
|
+
const warnings = [];
|
|
1271
|
+
const errors = [];
|
|
1272
|
+
if (!siteConfig || typeof siteConfig !== 'object') {
|
|
1273
|
+
errors.push(`Site ${siteIndex}: not an object`);
|
|
1274
|
+
return { warnings, errors };
|
|
1275
|
+
}
|
|
1276
|
+
const tag = siteConfig.url ? `Site ${siteIndex} (${siteConfig.url})` : `Site ${siteIndex}`;
|
|
1277
|
+
|
|
1278
|
+
// 1. Unknown-key detection. Scan every top-level key; report with
|
|
1279
|
+
// Levenshtein-based suggestion when close to a known key.
|
|
1280
|
+
for (const key of Object.keys(siteConfig)) {
|
|
1281
|
+
if (KNOWN_SITE_CONFIG_KEYS.has(key)) continue;
|
|
1282
|
+
const suggestion = _suggestKey(key, KNOWN_SITE_CONFIG_KEYS);
|
|
1283
|
+
warnings.push(
|
|
1284
|
+
`${tag}: unknown siteConfig key '${key}'` +
|
|
1285
|
+
(suggestion ? ` — did you mean '${suggestion}'?` : '') +
|
|
1286
|
+
' — value will be ignored at runtime'
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// 2. Boolean coercion for known boolean fields. Mutates siteConfig.
|
|
1291
|
+
for (const field of BOOLEAN_SITE_CONFIG_FIELDS) {
|
|
1292
|
+
if (!(field in siteConfig)) continue;
|
|
1293
|
+
const original = siteConfig[field];
|
|
1294
|
+
if (original === undefined || original === null) continue;
|
|
1295
|
+
const { coerced, value } = _coerceBooleanLike(original);
|
|
1296
|
+
if (coerced) {
|
|
1297
|
+
siteConfig[field] = value;
|
|
1298
|
+
warnings.push(
|
|
1299
|
+
`${tag}: '${field}' value ${JSON.stringify(original)} should be ${value} ` +
|
|
1300
|
+
`(boolean) — coerced for compatibility; please update config to use ${value}`
|
|
1301
|
+
);
|
|
1302
|
+
} else if (typeof original !== 'boolean') {
|
|
1303
|
+
warnings.push(
|
|
1304
|
+
`${tag}: '${field}' should be boolean (true/false), got ${JSON.stringify(original)} ` +
|
|
1305
|
+
`— may not work as expected (downstream strict-equality check will treat as disabled)`
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// 3. String → single-element array coercion for fields that accept both
|
|
1311
|
+
// forms (dig, dig-or, whois, whois-or). Downstream consumers all gate on
|
|
1312
|
+
// Array.isArray(), so a bare string value previously silently disabled
|
|
1313
|
+
// the feature. Wrapping in [val] is the canonical "user gave one term"
|
|
1314
|
+
// outcome and matches user intent. Both forms are first-class — no
|
|
1315
|
+
// warning is emitted on the string path, just the in-place mutation.
|
|
1316
|
+
//
|
|
1317
|
+
// Empty string is left alone: the downstream `siteConfig.dig && ...`
|
|
1318
|
+
// check sees the empty string as falsy and disables the feature. If we
|
|
1319
|
+
// coerced "" to [""], nettools' array.length>0 check would PASS and then
|
|
1320
|
+
// every dig/whois output would match (`"".includes(anything)` is true),
|
|
1321
|
+
// turning a clearly-empty config into a match-everything one.
|
|
1322
|
+
//
|
|
1323
|
+
// Non-string non-array values DO warn since we can't sensibly coerce.
|
|
1324
|
+
for (const field of STRING_TO_ARRAY_FIELDS) {
|
|
1325
|
+
if (!(field in siteConfig)) continue;
|
|
1326
|
+
const val = siteConfig[field];
|
|
1327
|
+
if (val === undefined || val === null) continue;
|
|
1328
|
+
if (typeof val === 'string') {
|
|
1329
|
+
if (val.length > 0) siteConfig[field] = [val];
|
|
1330
|
+
// empty string: leave as-is (preserves disable-on-falsy semantics)
|
|
1331
|
+
} else if (!Array.isArray(val)) {
|
|
1332
|
+
warnings.push(
|
|
1333
|
+
`${tag}: '${field}' should be a string or array of strings, got ${typeof val} ` +
|
|
1334
|
+
`(${JSON.stringify(val).slice(0, 60)}) — feature will be disabled at runtime`
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// 4. Dependent-flag implication: clear_sitedata_full_on_reload only takes
|
|
1340
|
+
// effect inside the `if (clear_sitedata === true)` guard at nwss.js:4627
|
|
1341
|
+
// — setting it WITHOUT clear_sitedata: true silently does nothing. That's
|
|
1342
|
+
// the same silent-failure pattern this validator was created to prevent,
|
|
1343
|
+
// so auto-enable clear_sitedata and warn the user. They almost certainly
|
|
1344
|
+
// intended both to be true; opt-in to heavy-storage clearing without
|
|
1345
|
+
// opt-in to clearing-at-all doesn't make sense as a configuration.
|
|
1346
|
+
if (siteConfig.clear_sitedata_full_on_reload === true &&
|
|
1347
|
+
siteConfig.clear_sitedata !== true) {
|
|
1348
|
+
siteConfig.clear_sitedata = true;
|
|
1349
|
+
warnings.push(
|
|
1350
|
+
`${tag}: 'clear_sitedata_full_on_reload: true' requires 'clear_sitedata: true' ` +
|
|
1351
|
+
`— auto-enabled clear_sitedata for compatibility; please add 'clear_sitedata: true' ` +
|
|
1352
|
+
`to your config explicitly`
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
return { warnings, errors };
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1078
1359
|
// Public surface used by nwss.js (validateRulesetFile, validateFullConfig,
|
|
1079
|
-
// testDomainValidation, cleanRulesetFile). The rest
|
|
1080
|
-
// isValidDomainLabel, isValidTLD, isIPAddress, isIPv4, isIPv6,
|
|
1360
|
+
// testDomainValidation, cleanRulesetFile, normalizeSiteConfig). The rest
|
|
1361
|
+
// (isValidDomain, isValidDomainLabel, isValidTLD, isIPAddress, isIPv4, isIPv6,
|
|
1081
1362
|
// validateRegexPattern, validateAdblockModifiers, validateAdblockRule,
|
|
1082
1363
|
// validateSiteConfig) stay internal-helper-but-exported for now since
|
|
1083
1364
|
// downstream callers MAY import them via the dotted path even if grep
|
|
@@ -1100,5 +1381,6 @@ module.exports = {
|
|
|
1100
1381
|
cleanRulesetFile,
|
|
1101
1382
|
validateSiteConfig,
|
|
1102
1383
|
validateFullConfig,
|
|
1384
|
+
normalizeSiteConfig,
|
|
1103
1385
|
testDomainValidation
|
|
1104
1386
|
};
|
package/lib/wireguard_vpn.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|