securenow 8.11.2 → 8.11.3

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/firewall.js CHANGED
@@ -1,1470 +1,1470 @@
1
- 'use strict';
2
-
3
- const http = require('http');
4
- const https = require('https');
5
- const { createMatcher } = require('./cidr');
6
- const { resolveClientIp } = require('./resolve-ip');
7
- const challenge = require('./challenge');
8
-
9
- let _options = null;
10
- let _matcher = null;
11
- let _syncTimer = null;
12
- let _pollTimer = null;
13
- let _lastModified = null;
14
- let _lastVersion = null;
15
- let _lastSyncEtag = null;
16
- let _lastUnifiedEtag = null;
17
- let _initialized = false;
18
- let _consecutiveErrors = 0;
19
- let _layers = [];
20
- let _rawIps = [];
21
- let _blocklistRules = [];
22
- let _stats = { syncs: 0, blocked: 0, rateLimited: 0, challenged: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
23
- let _localhostFallbackTried = false;
24
- let _eventQueue = [];
25
- let _eventTimer = null;
26
- let _remainingApiUrlFallbacks = [];
27
-
28
- // Remote toggle - set by /firewall/sync when an appKey is in scope. Default
29
- // true so a missing/unreachable backend fails open (matches pre-7.3 behavior).
30
- // When the dashboard / CLI flips this off, the next poll suppresses
31
- // enforcement without restarting the host process.
32
- let _remoteEnabled = true;
33
- let _lastRemoteEnabled = null;
34
-
35
- // Allowlist state
36
- let _allowlistMatcher = null;
37
- let _allowlistRawIps = [];
38
- let _lastAllowlistModified = null;
39
- let _lastAllowlistVersion = null;
40
- let _lastAllowlistSyncEtag = null;
41
-
42
- // Route/method-scoped policy state. Global IP/CIDR blocks stay in _matcher so
43
- // TCP, OS firewall, and Cloud WAF layers never over-enforce an HTTP-only rule.
44
- let _rateLimitRules = [];
45
- let _lastRateLimitVersion = null;
46
- let _rateLimitBuckets = new Map();
47
-
48
- // CAPTCHA / proof-of-work challenge state. _challengeConfig.secret signs the
49
- // challenge tokens + clearance cookies (delivered per-app over sync). When a
50
- // matched request carries no valid clearance the SDK serves an interstitial
51
- // instead of passing it through.
52
- let _challengeRules = [];
53
- let _lastChallengeVersion = null;
54
- let _challengeConfig = null;
55
- // Per-IP count of failed/abandoned challenges, for escalation reporting.
56
- let _challengeFails = new Map();
57
-
58
- // Circuit breaker
59
- const CIRCUIT_OPEN_THRESHOLD = 5;
60
- const CIRCUIT_OPEN_COOLDOWN_MS = 120_000;
61
- let _circuitState = 'closed';
62
- let _circuitOpenedAt = 0;
63
-
64
- // In-flight guard and 429 back-off
65
- let _pollInflight = false;
66
- let _retryAfterUntil = 0;
67
-
68
- // Firewall control-plane traffic is low-frequency and correctness-critical.
69
- // Use non-keep-alive agents so clustered apps do not reuse sockets that an
70
- // upstream load balancer has silently closed between sync cycles.
71
- const _httpAgent = new http.Agent({ keepAlive: false });
72
- const _httpsAgent = new https.Agent({ keepAlive: false });
73
- const EVENT_FLUSH_INTERVAL_MS = 2_000;
74
- const EVENT_BATCH_SIZE = 25;
75
- const EVENT_QUEUE_MAX = 1_000;
76
- const TRANSIENT_NETWORK_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND']);
77
-
78
- // Unified sync uses /firewall/sync (v2). Falls back to legacy on 404.
79
- let _useUnifiedSync = true;
80
-
81
- function originalConsoleMethod(method) {
82
- const originals = console.__securenow_original || console.__securenowOriginalConsole;
83
- return (originals && originals[method]) || console[method] || console.log;
84
- }
85
-
86
- function firewallConsole(method, ...args) {
87
- if (!_options || !_options.log) return;
88
- try {
89
- originalConsoleMethod(method).apply(console, args);
90
- } catch (_) {}
91
- }
92
-
93
- function fwLog(...args) {
94
- firewallConsole('log', ...args);
95
- }
96
-
97
- function fwWarn(...args) {
98
- firewallConsole('warn', ...args);
99
- }
100
-
101
- // Circuit Breaker
102
-
103
- function maybeOpenCircuit() {
104
- if (_circuitState === 'open') return;
105
- if (_consecutiveErrors >= CIRCUIT_OPEN_THRESHOLD) {
106
- _circuitState = 'open';
107
- _circuitOpenedAt = Date.now();
108
- if (_options && _options.log) {
109
- fwWarn('[securenow] Firewall: circuit breaker OPEN - pausing polling for %ds', CIRCUIT_OPEN_COOLDOWN_MS / 1000);
110
- }
111
- }
112
- }
113
-
114
- function resetCircuit() {
115
- if (_circuitState !== 'closed') {
116
- _circuitState = 'closed';
117
- if (_options && _options.log) fwLog('[securenow] Firewall: circuit breaker CLOSED - API healthy');
118
- }
119
- _consecutiveErrors = 0;
120
- }
121
-
122
- function shouldSkipRequest() {
123
- if (Date.now() < _retryAfterUntil) return true;
124
- if (_circuitState === 'closed') return false;
125
- if (_circuitState === 'open') {
126
- if (Date.now() - _circuitOpenedAt >= CIRCUIT_OPEN_COOLDOWN_MS) {
127
- _circuitState = 'half-open';
128
- return false;
129
- }
130
- return true;
131
- }
132
- return false; // half-open: allow one probe
133
- }
134
-
135
- function handleRetryAfter(res) {
136
- const ra = res.headers['retry-after'];
137
- if (!ra) return;
138
- const secs = parseInt(ra, 10);
139
- if (secs > 0 && secs <= 300) {
140
- _retryAfterUntil = Date.now() + secs * 1000;
141
- if (_options && _options.log) {
142
- fwWarn('[securenow] Firewall: API returned 429, backing off for %ds', secs);
143
- }
144
- }
145
- }
146
-
147
- // HTTP helpers
148
-
149
- function buildUrl(apiUrl, path) {
150
- return apiUrl.replace(/\/+$/, '') + '/api/v1' + path;
151
- }
152
-
153
- function buildFirewallUrl(path) {
154
- const query = new URLSearchParams();
155
- if (_options && _options.appKey) query.set('app', _options.appKey);
156
- if (_options && _options.environment) query.set('env', _options.environment);
157
- const suffix = query.toString() ? `?${query.toString()}` : '';
158
- return buildUrl(_options.apiUrl, path) + suffix;
159
- }
160
-
161
- function jitter(baseMs) {
162
- return baseMs * (0.8 + Math.random() * 0.4);
163
- }
164
-
165
- function agentFor(url) {
166
- return url.startsWith('https') ? _httpsAgent : _httpAgent;
167
- }
168
-
169
- function isTransientNetworkError(err) {
170
- if (!err) return false;
171
- if (err.code && TRANSIENT_NETWORK_CODES.has(err.code)) return true;
172
- return /socket hang up|connection reset|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|timed out/i.test(String(err.message || ''));
173
- }
174
-
175
- function isApiReachabilityError(err) {
176
- if (isTransientNetworkError(err)) return true;
177
- const text = `${err && err.code || ''} ${err && err.message || ''}`;
178
- return /TLS|SSL|certificate|CERT_|UNABLE_TO_VERIFY|self signed/i.test(text);
179
- }
180
-
181
- function formatRequestError(err) {
182
- if (!err) return 'unknown error';
183
- const parts = [err.message || String(err)];
184
- if (err.code && !parts[0].includes(err.code)) parts.push(`code=${err.code}`);
185
- if (err.syscall) parts.push(`syscall=${err.syscall}`);
186
- return parts.join(' ');
187
- }
188
-
189
- function resetApiUrlFallbacks() {
190
- const seen = new Set([_options && _options.apiUrl].filter(Boolean));
191
- _remainingApiUrlFallbacks = [];
192
- for (const candidate of Array.isArray(_options && _options.apiUrlFallbacks) ? _options.apiUrlFallbacks : []) {
193
- const url = String(candidate || '').trim().replace(/\/$/, '');
194
- if (!url || seen.has(url)) continue;
195
- seen.add(url);
196
- _remainingApiUrlFallbacks.push(url);
197
- }
198
- }
199
-
200
- function switchToNextApiUrl(reason) {
201
- if (!_options || _remainingApiUrlFallbacks.length === 0) return false;
202
- const previous = _options.apiUrl;
203
- _options.apiUrl = _remainingApiUrlFallbacks.shift();
204
- _lastUnifiedEtag = null;
205
- _lastSyncEtag = null;
206
- _lastAllowlistSyncEtag = null;
207
- if (_options.log) {
208
- fwWarn('[securenow] Firewall: %s unreachable (%s), retrying sync via %s',
209
- previous,
210
- reason || 'network error',
211
- _options.apiUrl);
212
- }
213
- return true;
214
- }
215
-
216
- function requestOnce(method, url, body, extraHeaders, timeout, callback) {
217
- const mod = url.startsWith('https') ? https : http;
218
- const parsed = new URL(url);
219
- const payload = body == null ? null : JSON.stringify(body || {});
220
-
221
- const req = mod.request({
222
- hostname: parsed.hostname,
223
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
224
- path: parsed.pathname + parsed.search,
225
- method,
226
- headers: {
227
- 'Authorization': `Bearer ${_options.apiKey}`,
228
- 'User-Agent': 'securenow-firewall-sdk',
229
- 'Connection': 'close',
230
- ...(payload
231
- ? {
232
- 'Content-Type': 'application/json',
233
- 'Content-Length': Buffer.byteLength(payload),
234
- }
235
- : {}),
236
- ...extraHeaders,
237
- },
238
- timeout,
239
- agent: agentFor(url),
240
- }, (res) => {
241
- let data = '';
242
- res.on('data', (chunk) => { data += chunk; });
243
- res.on('end', () => { done(null, res, data); });
244
- });
245
-
246
- let finished = false;
247
- function done(...args) {
248
- if (finished) return;
249
- finished = true;
250
- callback(...args);
251
- }
252
-
253
- req.on('error', (err) => done(err));
254
- req.on('timeout', () => {
255
- const err = new Error('Request timed out');
256
- err.code = 'ETIMEDOUT';
257
- done(err);
258
- req.destroy(err);
259
- });
260
- if (payload) req.write(payload);
261
- req.end();
262
- }
263
-
264
- function requestWithRetry(method, url, body, extraHeaders, timeout, callback) {
265
- requestOnce(method, url, body, extraHeaders, timeout, (err, res, data) => {
266
- if (!err || !isTransientNetworkError(err)) return callback(err, res, data);
267
-
268
- if (_options && _options.log) {
269
- fwWarn('[securenow] Firewall: transient API socket error, retrying once:', formatRequestError(err));
270
- }
271
-
272
- const retryTimer = setTimeout(() => {
273
- requestOnce(method, url, body, extraHeaders, timeout, callback);
274
- }, 100);
275
- if (retryTimer.unref) retryTimer.unref();
276
- });
277
- }
278
-
279
- function httpGet(url, extraHeaders, timeout, callback) {
280
- requestWithRetry('GET', url, null, extraHeaders, timeout, callback);
281
- }
282
-
283
- function httpPostJson(url, body, timeout, callback) {
284
- requestWithRetry('POST', url, body, {}, timeout, callback);
285
- }
286
-
287
- function scheduleEventFlush() {
288
- if (_eventTimer || _eventQueue.length === 0) return;
289
- _eventTimer = setTimeout(() => {
290
- _eventTimer = null;
291
- flushFirewallEvents();
292
- }, EVENT_FLUSH_INTERVAL_MS);
293
- if (_eventTimer.unref) _eventTimer.unref();
294
- }
295
-
296
- function flushFirewallEvents() {
297
- if (!_options || !_options.apiKey || !_options.apiUrl || _eventQueue.length === 0) return;
298
- if (shouldSkipRequest()) {
299
- _eventQueue = [];
300
- return;
301
- }
302
-
303
- const batch = _eventQueue.splice(0, EVENT_BATCH_SIZE);
304
- const url = buildFirewallUrl('/firewall/events');
305
-
306
- httpPostJson(url, { events: batch }, 5000, (err, res) => {
307
- if (err || !res || res.statusCode >= 400) {
308
- if (_options.log) {
309
- const msg = err ? formatRequestError(err) : `API returned ${res.statusCode}`;
310
- fwWarn('[securenow] Firewall: failed to report blocked-request ledger:', msg);
311
- }
312
- }
313
- if (_eventQueue.length) scheduleEventFlush();
314
- });
315
- }
316
-
317
- function reportFirewallEvent(event) {
318
- if (!_options || !_options.apiKey || !_options.apiUrl) return;
319
-
320
- const item = {
321
- applicationKey: _options.appKey || null,
322
- environment: _options.environment || 'production',
323
- action: 'blocked',
324
- statusCode: (_options && _options.statusCode) || 403,
325
- occurredAt: new Date().toISOString(),
326
- ...event,
327
- };
328
-
329
- _eventQueue.push(item);
330
- if (_eventQueue.length > EVENT_QUEUE_MAX) {
331
- _eventQueue.splice(0, _eventQueue.length - EVENT_QUEUE_MAX);
332
- }
333
-
334
- if (_eventQueue.length >= EVENT_BATCH_SIZE) flushFirewallEvents();
335
- else scheduleEventFlush();
336
- }
337
-
338
- function normalizePrefixPattern(pattern) {
339
- const value = String(pattern || '');
340
- return value.endsWith('*') ? value.slice(0, -1) : value;
341
- }
342
-
343
- function normalizeBlocklistRules(rules) {
344
- if (!Array.isArray(rules)) return [];
345
- return rules
346
- .map((rule) => {
347
- if (!rule || !rule.ip) return null;
348
- const pathMatchMode = ['exact', 'prefix', 'regex'].includes(String(rule.pathMatchMode || '').toLowerCase())
349
- ? String(rule.pathMatchMode).toLowerCase()
350
- : 'prefix';
351
- return {
352
- ...rule,
353
- ip: String(rule.ip || '').trim(),
354
- method: String(rule.method || 'ALL').toUpperCase(),
355
- pathPattern: pathMatchMode === 'prefix'
356
- ? normalizePrefixPattern(rule.pathPattern)
357
- : String(rule.pathPattern || '').trim(),
358
- pathMatchMode,
359
- };
360
- })
361
- .filter(Boolean);
362
- }
363
-
364
- function setBlocklistData(ips, rules) {
365
- const normalizedIps = Array.isArray(ips) ? ips : [];
366
- _rawIps = normalizedIps;
367
- _blocklistRules = normalizeBlocklistRules(rules);
368
- _matcher = createMatcher(normalizedIps);
369
- _stats.syncs++;
370
- notifyLayers(normalizedIps);
371
- }
372
-
373
- // Unified Sync (v2 - single request for everything)
374
-
375
- function doUnifiedSync(callback) {
376
- const query = new URLSearchParams();
377
- if (_options.appKey) query.set('app', _options.appKey);
378
- if (_options.environment) query.set('env', _options.environment);
379
- const suffix = query.toString() ? `?${query.toString()}` : '';
380
- const url = buildUrl(_options.apiUrl, '/firewall/sync') + suffix;
381
- const headers = {};
382
- if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
383
- if (_lastVersion) headers['X-Blocklist-Version'] = _lastVersion;
384
- if (_lastAllowlistVersion) headers['X-Allowlist-Version'] = _lastAllowlistVersion;
385
- if (_lastRateLimitVersion) headers['X-Rate-Limit-Version'] = _lastRateLimitVersion;
386
- if (_lastChallengeVersion) headers['X-Challenge-Version'] = _lastChallengeVersion;
387
- if (_lastUnifiedEtag) headers['If-None-Match'] = _lastUnifiedEtag;
388
-
389
- httpGet(url, headers, 8000, (err, res, data) => {
390
- if (err) return callback(err);
391
-
392
- _stats.versionChecks++;
393
- if (res.headers && res.headers.etag) _lastUnifiedEtag = res.headers.etag;
394
-
395
- if (res.statusCode === 304) {
396
- return callback(null, { blChanged: false, alChanged: false });
397
- }
398
-
399
- if (res.statusCode === 404) {
400
- _useUnifiedSync = false;
401
- if (_options.log) fwLog('[securenow] Firewall: /sync not available, using legacy endpoints');
402
- return callback(null, { blChanged: false, alChanged: false, useLegacy: true });
403
- }
404
-
405
- if (res.statusCode === 429) { handleRetryAfter(res); }
406
-
407
- if (res.statusCode !== 200) {
408
- return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
409
- }
410
-
411
- try {
412
- const body = JSON.parse(data);
413
-
414
- let blChanged = false;
415
- let alChanged = false;
416
-
417
- // Apply remote per-app toggle. Absent body.app means the backend either
418
- // doesn't know about appKey-scoped sync (older API) or no appKey was
419
- // sent - leave the previous value untouched (default true on first run).
420
- if (body.app && typeof body.app.firewallEnabled === 'boolean') {
421
- const next = body.app.firewallEnabled;
422
- if (next !== _lastRemoteEnabled) {
423
- _remoteEnabled = next;
424
- if (_options.log) {
425
- fwLog('[securenow] Firewall: remote toggle -> %s (app=%s)',
426
- next ? 'ENABLED' : 'DISABLED', body.app.key || _options.appKey);
427
- }
428
- _lastRemoteEnabled = next;
429
- }
430
- }
431
-
432
- // Update blocklist version + data
433
- if (body.blocklist) {
434
- const newVer = body.blocklist.version;
435
- if (newVer !== _lastVersion) {
436
- _lastVersion = newVer;
437
- }
438
- }
439
-
440
- if (body.blocklistIps || body.blocklistRules) {
441
- setBlocklistData(body.blocklistIps || [], body.blocklistRules || []);
442
- blChanged = true;
443
- }
444
-
445
- // Update allowlist version + data
446
- if (body.allowlist) {
447
- const newVer = body.allowlist.version;
448
- if (newVer !== _lastAllowlistVersion) {
449
- _lastAllowlistVersion = newVer;
450
- }
451
- }
452
-
453
- if (body.allowlistIps) {
454
- _allowlistRawIps = body.allowlistIps;
455
- _allowlistMatcher = createMatcher(body.allowlistIps);
456
- alChanged = true;
457
- }
458
-
459
- if (body.rateLimits) {
460
- const newVer = body.rateLimits.version;
461
- if (newVer !== _lastRateLimitVersion) {
462
- _lastRateLimitVersion = newVer;
463
- }
464
- }
465
-
466
- if (Array.isArray(body.rateLimitRules)) {
467
- _rateLimitRules = body.rateLimitRules;
468
- pruneRateLimitBuckets(_rateLimitRules);
469
- }
470
-
471
- // Per-app challenge config (secret + defaults) rides on the app block.
472
- if (body.app && body.app.challenge && typeof body.app.challenge === 'object') {
473
- _challengeConfig = body.app.challenge;
474
- }
475
-
476
- if (body.challenges) {
477
- const newVer = body.challenges.version;
478
- if (newVer !== _lastChallengeVersion) {
479
- _lastChallengeVersion = newVer;
480
- }
481
- }
482
-
483
- if (Array.isArray(body.challengeRules)) {
484
- _challengeRules = body.challengeRules;
485
- }
486
-
487
- callback(null, { blChanged, alChanged, rlChanged: Array.isArray(body.rateLimitRules) });
488
- } catch (e) {
489
- callback(new Error(`Failed to parse sync response: ${e.message}`));
490
- }
491
- });
492
- }
493
-
494
- // Legacy Sync (v1 - separate endpoints, kept for backward compat)
495
-
496
- function legacyBlocklistSync(callback) {
497
- const url = buildFirewallUrl('/firewall/blocklist');
498
- const headers = {};
499
- if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
500
- if (_lastSyncEtag) headers['If-None-Match'] = _lastSyncEtag;
501
- else if (_lastModified) headers['If-Modified-Since'] = _lastModified;
502
-
503
- httpGet(url, headers, 10000, (err, res, data) => {
504
- if (err) return callback(err);
505
- if (res.statusCode === 304) return callback(null, false);
506
- if (res.statusCode === 429) { handleRetryAfter(res); }
507
- if (res.statusCode !== 200) return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
508
-
509
- try {
510
- const body = JSON.parse(data);
511
- const ips = body.ips || [];
512
- setBlocklistData(ips, body.rules || body.blocklistRules || []);
513
- _lastModified = res.headers['last-modified'] || null;
514
- if (res.headers['etag']) _lastSyncEtag = res.headers['etag'];
515
- callback(null, true, _matcher.stats());
516
- } catch (e) {
517
- callback(new Error(`Failed to parse blocklist: ${e.message}`));
518
- }
519
- });
520
- }
521
-
522
- function legacyAllowlistSync(callback) {
523
- const url = buildFirewallUrl('/firewall/allowlist');
524
- const headers = {};
525
- if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
526
- if (_lastAllowlistSyncEtag) headers['If-None-Match'] = _lastAllowlistSyncEtag;
527
- else if (_lastAllowlistModified) headers['If-Modified-Since'] = _lastAllowlistModified;
528
-
529
- httpGet(url, headers, 10000, (err, res, data) => {
530
- if (err) return callback(err);
531
- if (res.statusCode === 304) return callback(null, false);
532
- if (res.statusCode === 429) { handleRetryAfter(res); }
533
- if (res.statusCode !== 200) return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
534
-
535
- try {
536
- const body = JSON.parse(data);
537
- const ips = body.ips || [];
538
- _allowlistRawIps = ips;
539
- _allowlistMatcher = createMatcher(ips);
540
- _lastAllowlistModified = res.headers['last-modified'] || null;
541
- if (res.headers['etag']) _lastAllowlistSyncEtag = res.headers['etag'];
542
- callback(null, true, _allowlistMatcher.stats());
543
- } catch (e) {
544
- callback(new Error(`Failed to parse allowlist: ${e.message}`));
545
- }
546
- });
547
- }
548
-
549
- function legacyVersionCheck(callback) {
550
- const url = buildFirewallUrl('/firewall/blocklist/version');
551
- const headers = {};
552
- if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
553
- if (_lastVersion) headers['If-None-Match'] = _lastVersion;
554
-
555
- httpGet(url, headers, 5000, (err, res, data) => {
556
- if (err) return callback(err);
557
- _stats.versionChecks++;
558
- if (res.statusCode === 304) return callback(null, false, false);
559
- if (res.statusCode === 429) { handleRetryAfter(res); }
560
- if (res.statusCode !== 200) return callback(new Error(`API ${res.statusCode}`));
561
-
562
- try {
563
- const body = JSON.parse(data);
564
- const version = body.version || null;
565
- const changed = version !== _lastVersion;
566
- if (changed) _lastVersion = version;
567
- callback(null, changed, false);
568
- } catch (_e) { callback(null, false, false); }
569
- });
570
- }
571
-
572
- function legacyAllowlistVersionCheck(callback) {
573
- const url = buildFirewallUrl('/firewall/allowlist/version');
574
- const headers = {};
575
- if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
576
- if (_lastAllowlistVersion) headers['If-None-Match'] = _lastAllowlistVersion;
577
-
578
- httpGet(url, headers, 5000, (err, res, data) => {
579
- if (err) return callback(err);
580
- if (res.statusCode === 304) return callback(null, false);
581
- if (res.statusCode === 429) { handleRetryAfter(res); }
582
- if (res.statusCode !== 200) return callback(new Error(`API ${res.statusCode}`));
583
-
584
- try {
585
- const body = JSON.parse(data);
586
- const version = body.version || null;
587
- const changed = version !== _lastAllowlistVersion;
588
- if (changed) _lastAllowlistVersion = version;
589
- callback(null, changed);
590
- } catch (_e) { callback(null, false); }
591
- });
592
- }
593
-
594
- function doLegacyPoll(callback) {
595
- let pending = 2;
596
- let blChanged = false;
597
- let alChanged = false;
598
- let firstError = null;
599
-
600
- function checkDone() {
601
- if (--pending > 0) return;
602
- if (firstError) return callback(firstError);
603
-
604
- let syncsPending = 0;
605
- if (blChanged) syncsPending++;
606
- if (alChanged) syncsPending++;
607
- if (syncsPending === 0) return callback(null, { blChanged: false, alChanged: false });
608
-
609
- function syncDone() {
610
- if (--syncsPending > 0) return;
611
- callback(null, { blChanged, alChanged });
612
- }
613
-
614
- if (blChanged) {
615
- legacyBlocklistSync((err, changed, stats) => {
616
- if (err && _options.log) fwWarn('[securenow] Firewall: sync failed (using stale list):', formatRequestError(err));
617
- else if (changed && stats && _options.log) fwLog('[securenow] Firewall: re-synced %d blocked IPs', stats.total);
618
- syncDone();
619
- });
620
- }
621
- if (alChanged) {
622
- legacyAllowlistSync((err, changed, stats) => {
623
- if (err && _options.log) fwWarn('[securenow] Firewall: allowlist sync failed:', formatRequestError(err));
624
- else if (changed && stats && _options.log) fwLog('[securenow] Firewall: re-synced %d allowed IPs', stats.total);
625
- syncDone();
626
- });
627
- }
628
- }
629
-
630
- legacyVersionCheck((err, changed) => {
631
- if (err) firstError = firstError || err;
632
- blChanged = changed;
633
- checkDone();
634
- });
635
-
636
- legacyAllowlistVersionCheck((err, changed) => {
637
- if (err) firstError = firstError || err;
638
- alChanged = changed;
639
- checkDone();
640
- });
641
- }
642
-
643
- // Unified poll loop
644
-
645
- function notifyLayers(ips) {
646
- for (const layer of _layers) {
647
- try { if (typeof layer.sync === 'function') layer.sync(ips); } catch (_) {}
648
- }
649
- }
650
-
651
- function pollOnce(callback) {
652
- if (_pollInflight || shouldSkipRequest()) return callback(null);
653
- _pollInflight = true;
654
-
655
- const done = (err, result) => {
656
- _pollInflight = false;
657
- if (err) {
658
- if (isApiReachabilityError(err) && switchToNextApiUrl(formatRequestError(err))) {
659
- _pollInflight = false;
660
- const retryTimer = setTimeout(() => pollOnce(callback), 1000);
661
- if (retryTimer.unref) retryTimer.unref();
662
- return;
663
- }
664
- _consecutiveErrors++;
665
- _stats.errors++;
666
- maybeOpenCircuit();
667
- if (_options.log) fwWarn('[securenow] Firewall: poll failed:', formatRequestError(err));
668
- return callback(err);
669
- }
670
- _consecutiveErrors = 0;
671
- resetCircuit();
672
- if (result) {
673
- if (result.blChanged && _options.log && _matcher) {
674
- const s = _matcher.stats();
675
- fwLog('[securenow] Firewall: re-synced %d global blocked IPs (%d exact + %d CIDR ranges) and %d scoped block rules',
676
- s.total, s.exact, s.cidr, _blocklistRules.length);
677
- }
678
- if (result.alChanged && _options.log && _allowlistMatcher) {
679
- const s = _allowlistMatcher.stats();
680
- fwLog('[securenow] Firewall: re-synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
681
- }
682
- if (result.rlChanged && _options.log) {
683
- fwLog('[securenow] Firewall: re-synced %d rate-limit rules', _rateLimitRules.length);
684
- }
685
- }
686
- callback(null);
687
- };
688
-
689
- if (_useUnifiedSync) {
690
- doUnifiedSync((err, result) => {
691
- if (err) return done(err);
692
- if (result && result.useLegacy) {
693
- doLegacyPoll(done);
694
- } else {
695
- done(null, result);
696
- }
697
- });
698
- } else {
699
- doLegacyPoll(done);
700
- }
701
- }
702
-
703
- function scheduleNextPoll() {
704
- const baseMs = (_options.versionCheckInterval || 10) * 1000;
705
- const backoffMs = Math.min(baseMs * Math.pow(2, _consecutiveErrors), 120_000);
706
- const delayMs = jitter(backoffMs);
707
-
708
- _pollTimer = setTimeout(() => {
709
- pollOnce(() => { scheduleNextPoll(); });
710
- }, delayMs);
711
- if (_pollTimer.unref) _pollTimer.unref();
712
- }
713
-
714
- function startSyncLoop() {
715
- const fullSyncIntervalMs = (_options.syncInterval || 300) * 1000;
716
- const BASE_RETRY = 5000;
717
- const MAX_RETRY = 120_000;
718
- let _initAttempt = 0;
719
-
720
- function initialSync() {
721
- // Use unified endpoint for initial sync too
722
- const syncFn = _useUnifiedSync ? doUnifiedSync : (cb) => doLegacyPoll(cb);
723
-
724
- syncFn((err, result) => {
725
- if (err) {
726
- const isConnErr = /ECONNREFUSED|ENOTFOUND|timed out/i.test(err.message);
727
- if (isApiReachabilityError(err) && switchToNextApiUrl(formatRequestError(err))) {
728
- const retryTimer = setTimeout(initialSync, 1000);
729
- if (retryTimer.unref) retryTimer.unref();
730
- return;
731
- }
732
- if (isConnErr && !_localhostFallbackTried && _options.apiUrl !== 'http://localhost:4000') {
733
- _localhostFallbackTried = true;
734
- const origUrl = _options.apiUrl;
735
- _options.apiUrl = 'http://localhost:4000';
736
- if (_options.log) fwLog('[securenow] Firewall: %s unreachable, trying http://localhost:4000', origUrl);
737
- const retryTimer = setTimeout(initialSync, 1000);
738
- if (retryTimer.unref) retryTimer.unref();
739
- return;
740
- }
741
- if (result && result.useLegacy) {
742
- const retryTimer = setTimeout(initialSync, 1000);
743
- if (retryTimer.unref) retryTimer.unref();
744
- return;
745
- }
746
- if (_options.log) fwWarn('[securenow] Firewall: initial sync failed:', formatRequestError(err));
747
- if (_options.failMode === 'closed') {
748
- _matcher = { isBlocked: () => true, stats: () => ({ exact: 0, cidr: 0, total: 0 }) };
749
- }
750
- _initAttempt++;
751
- const delay = Math.min(BASE_RETRY * Math.pow(2, _initAttempt), MAX_RETRY);
752
- const retryTimer = setTimeout(initialSync, jitter(delay));
753
- if (retryTimer.unref) retryTimer.unref();
754
- return;
755
- }
756
-
757
- if (result && result.useLegacy) {
758
- const retryTimer = setTimeout(initialSync, 1000);
759
- if (retryTimer.unref) retryTimer.unref();
760
- return;
761
- }
762
-
763
- _initialized = true;
764
- if (_options.log && _matcher) {
765
- const s = _matcher.stats();
766
- fwLog('[securenow] Firewall: synced %d global blocked IPs (%d exact + %d CIDR ranges) and %d scoped block rules',
767
- s.total, s.exact, s.cidr, _blocklistRules.length);
768
- }
769
- if (_options.log && _allowlistMatcher) {
770
- const s = _allowlistMatcher.stats();
771
- if (s.total > 0) fwLog('[securenow] Firewall: synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
772
- }
773
- if (_options.log && _rateLimitRules.length > 0) {
774
- fwLog('[securenow] Firewall: synced %d rate-limit rules', _rateLimitRules.length);
775
- }
776
- });
777
- }
778
-
779
- initialSync();
780
- scheduleNextPoll();
781
-
782
- // Safety-net full sync timer (less frequent, uses same path)
783
- _syncTimer = setInterval(() => {
784
- if (shouldSkipRequest()) return;
785
- // Force a full re-fetch by clearing versions so unified endpoint returns full data
786
- const savedBlVer = _lastVersion;
787
- const savedAlVer = _lastAllowlistVersion;
788
- const savedRlVer = _lastRateLimitVersion;
789
- const savedUnifiedEtag = _lastUnifiedEtag;
790
- _lastVersion = null;
791
- _lastAllowlistVersion = null;
792
- _lastRateLimitVersion = null;
793
- _lastUnifiedEtag = null;
794
- pollOnce((err) => {
795
- if (err) {
796
- _lastVersion = savedBlVer;
797
- _lastAllowlistVersion = savedAlVer;
798
- _lastRateLimitVersion = savedRlVer;
799
- _lastUnifiedEtag = savedUnifiedEtag;
800
- }
801
- });
802
- }, fullSyncIntervalMs);
803
- if (_syncTimer.unref) _syncTimer.unref();
804
- }
805
-
806
- // Layer 1: HTTP Handler
807
-
808
- const _origHttpCreate = http.createServer;
809
- const _origHttpsCreate = https.createServer;
810
- let _httpPatched = false;
811
-
812
- function blockedHtml(ip) {
813
- const maskedIp = ip || 'unknown';
814
- return `<!DOCTYPE html>
815
- <html lang="en">
816
- <head>
817
- <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
818
- <title>Access Blocked - Security Alert</title>
819
- <style>
820
- *{margin:0;padding:0;box-sizing:border-box}
821
- body{min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5}
822
- .wrap{text-align:center;max-width:540px;padding:2.5rem 2rem}
823
- .icon{width:64px;height:64px;margin:0 auto 1.5rem;border-radius:50%;background:rgba(220,38,38,.12);display:flex;align-items:center;justify-content:center}
824
- .icon svg{width:32px;height:32px;fill:none;stroke:#dc2626;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
825
- .badge{display:inline-block;padding:.25rem .75rem;border-radius:999px;background:rgba(220,38,38,.15);color:#f87171;font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-bottom:1.25rem}
826
- h1{font-size:1.6rem;font-weight:700;margin-bottom:.6rem;color:#fff}
827
- .sub{font-size:1rem;color:#f87171;font-weight:600;margin-bottom:1.25rem}
828
- p{font-size:.9rem;line-height:1.7;color:#a1a1aa;margin-bottom:.5rem}
829
- .ip-box{display:inline-block;margin:1rem 0;padding:.6rem 1.5rem;border-radius:8px;background:rgba(255,255,255,.04);border:1px solid rgba(220,38,38,.25);font-family:"SF Mono",SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace;font-size:.95rem;color:#fca5a5;letter-spacing:.03em}
830
- .divider{width:48px;height:2px;background:rgba(220,38,38,.3);margin:1.5rem auto}
831
- .contact{font-size:.85rem;color:#71717a;line-height:1.7}
832
- .contact a{color:#f87171;text-decoration:none;font-weight:500}
833
- .contact a:hover{text-decoration:underline}
834
- .footer{margin-top:2rem;font-size:.7rem;color:#3f3f46}
835
- .powered{margin-top:1.5rem;font-size:.75rem;color:#52525b}.powered a{color:#a1a1aa;text-decoration:none;font-weight:600;transition:color .2s}.powered a:hover{color:#f87171;text-decoration:underline}
836
- </style>
837
- </head>
838
- <body>
839
- <div class="wrap">
840
- <div class="icon"><svg viewBox="0 0 24 24"><path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg></div>
841
- <span class="badge">Security Alert</span>
842
- <h1>Access Blocked</h1>
843
- <p class="sub">Malicious activity detected from your IP address</p>
844
- <p>Your IP has been flagged and blocked due to suspicious or malicious behavior originating from your network. Our security team has been notified and is actively investigating.</p>
845
- <div class="ip-box">${maskedIp}</div>
846
- <div class="divider"></div>
847
- <p class="contact">If you believe this is a mistake, please contact us at<br><a href="mailto:contact@securenow.ai?subject=Blocked%20IP%20Appeal%20-%20${encodeURIComponent(maskedIp)}&body=IP:%20${encodeURIComponent(maskedIp)}%0ATimestamp:%20${encodeURIComponent(new Date().toISOString())}%0A%0APlease%20describe%20why%20you%20believe%20this%20block%20is%20incorrect:">contact@securenow.ai</a><br>Include your IP address and the time of this incident.</p>
848
- <p class="footer">Ref: ${maskedIp} &mdash; ${new Date().toISOString()} &mdash; HTTP 403</p>
849
- <p class="powered">Protected by <a href="https://securenow.ai" rel="dofollow" target="_blank">SecureNow</a></p>
850
- </div>
851
- </body>
852
- </html>`;
853
- }
854
-
855
- function wrapListener(originalListener) {
856
- return function firewallGuard(req, res) {
857
- _stats.allowed++;
858
- return originalListener.call(this, req, res);
859
- };
860
- }
861
-
862
- function sendBlockResponse(req, res, ip) {
863
- const code = (_options && _options.statusCode) || 403;
864
- const accept = req.headers['accept'] || '';
865
- if (accept.includes('text/html')) {
866
- res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8' });
867
- res.end(blockedHtml(ip));
868
- } else {
869
- res.writeHead(code, { 'Content-Type': 'application/json' });
870
- res.end(JSON.stringify({ error: 'Forbidden', ip }));
871
- }
872
- }
873
-
874
- function sendRateLimitResponse(req, res, ip, decision) {
875
- const retryAfter = Math.max(1, decision.retryAfter || 1);
876
- const headers = {
877
- 'Retry-After': String(retryAfter),
878
- 'X-RateLimit-Limit': String(decision.limit || ''),
879
- 'X-RateLimit-Window': String(decision.windowSeconds || ''),
880
- 'X-SecureNow-Rate-Limit-Rule': decision.ruleId || '',
881
- };
882
- const accept = req.headers['accept'] || '';
883
- if (accept.includes('text/html')) {
884
- res.writeHead(429, { ...headers, 'Content-Type': 'text/html; charset=utf-8' });
885
- res.end('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Too Many Requests</title></head><body><h1>Too Many Requests</h1><p>Please retry later.</p></body></html>');
886
- } else {
887
- res.writeHead(429, { ...headers, 'Content-Type': 'application/json' });
888
- res.end(JSON.stringify({
889
- error: 'Too Many Requests',
890
- ip,
891
- retryAfter,
892
- }));
893
- }
894
- }
895
-
896
- function requestPath(req) {
897
- try {
898
- return new URL(req.url || '/', 'http://localhost').pathname || '/';
899
- } catch (_) {
900
- return req.url || '/';
901
- }
902
- }
903
-
904
- function ruleIpMatches(rule, ip) {
905
- const target = String(rule.ip || '').trim();
906
- if (!target) return true;
907
- try {
908
- return createMatcher([target]).isBlocked(ip);
909
- } catch (_) {
910
- return false;
911
- }
912
- }
913
-
914
- function rulePathMatches(rule, path) {
915
- const pattern = String(rule.pathPattern || '').trim();
916
- if (!pattern) return true;
917
- const mode = rule.pathMatchMode || 'prefix';
918
- if (mode === 'exact') return path === pattern;
919
- if (mode === 'regex') {
920
- try { return new RegExp(pattern).test(path); } catch (_) { return false; }
921
- }
922
- return path.startsWith(normalizePrefixPattern(pattern));
923
- }
924
-
925
- function rateLimitKey(rule, ip) {
926
- const id = rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`;
927
- const subject = rule.keyBy === 'global' ? 'global' : ip;
928
- return `${id}|${subject}`;
929
- }
930
-
931
- function checkBlocklistRules(req, ip) {
932
- if (!_blocklistRules || _blocklistRules.length === 0) return null;
933
- const method = String(req.method || 'GET').toUpperCase();
934
- const path = requestPath(req);
935
-
936
- for (const rule of _blocklistRules) {
937
- if (!rule) continue;
938
- const ruleMethod = String(rule.method || 'ALL').toUpperCase();
939
- if (ruleMethod !== 'ALL' && ruleMethod !== method) continue;
940
- if (!ruleIpMatches(rule, ip)) continue;
941
- if (!rulePathMatches(rule, path)) continue;
942
- return {
943
- rule,
944
- ruleId: rule.id || rule._id || '',
945
- matchedEntry: rule.ip || ip,
946
- path,
947
- };
948
- }
949
-
950
- return null;
951
- }
952
-
953
- function checkRateLimitRules(req, ip) {
954
- if (!_rateLimitRules || _rateLimitRules.length === 0) return null;
955
- const method = String(req.method || 'GET').toUpperCase();
956
- const path = requestPath(req);
957
- const now = Date.now();
958
-
959
- for (const rule of _rateLimitRules) {
960
- if (!rule) continue;
961
- const ruleMethod = String(rule.method || 'ALL').toUpperCase();
962
- if (ruleMethod !== 'ALL' && ruleMethod !== method) continue;
963
- if (!ruleIpMatches(rule, ip)) continue;
964
- if (!rulePathMatches(rule, path)) continue;
965
-
966
- const limit = Math.max(1, Number(rule.limit || 1) || 1);
967
- const windowSeconds = Math.max(1, Number(rule.windowSeconds || 60) || 60);
968
- const windowMs = windowSeconds * 1000;
969
- const key = rateLimitKey(rule, ip);
970
- let bucket = _rateLimitBuckets.get(key);
971
- if (!bucket || now - bucket.windowStart >= windowMs) {
972
- bucket = { windowStart: now, count: 0 };
973
- _rateLimitBuckets.set(key, bucket);
974
- }
975
-
976
- if (bucket.count >= limit) {
977
- const retryAfter = Math.ceil((bucket.windowStart + windowMs - now) / 1000);
978
- return {
979
- rule,
980
- ruleId: rule.id || rule._id || '',
981
- limit,
982
- windowSeconds,
983
- retryAfter,
984
- path,
985
- };
986
- }
987
- bucket.count++;
988
- }
989
-
990
- return null;
991
- }
992
-
993
- function pruneRateLimitBuckets(rules) {
994
- const ids = new Set((rules || []).map((rule) => String(rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`)));
995
- for (const key of _rateLimitBuckets.keys()) {
996
- const idx = key.indexOf('|');
997
- const ruleKey = idx === -1 ? key : key.slice(0, idx);
998
- if (!ids.has(ruleKey)) _rateLimitBuckets.delete(key);
999
- }
1000
- }
1001
-
1002
- // ── Challenge (CAPTCHA / proof-of-work) enforcement ──
1003
-
1004
- const CHALLENGE_INTERNAL_PATH = '/__securenow/challenge';
1005
-
1006
- function isSecureRequest(req) {
1007
- if (req.socket && req.socket.encrypted) return true;
1008
- const xfp = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
1009
- return xfp === 'https';
1010
- }
1011
-
1012
- function challengeDifficulty(rule) {
1013
- const d = Number(rule && rule.difficulty);
1014
- if (Number.isFinite(d) && d >= 4 && d <= 28) return d;
1015
- const cfg = Number(_challengeConfig && _challengeConfig.difficulty);
1016
- if (Number.isFinite(cfg) && cfg >= 4) return cfg;
1017
- return challenge.DEFAULTS.difficulty;
1018
- }
1019
-
1020
- function findChallengeRuleById(rid) {
1021
- if (!rid || rid === '*') return null;
1022
- for (const rule of _challengeRules) {
1023
- if (String(rule.id || rule._id || '') === String(rid)) return rule;
1024
- }
1025
- return null;
1026
- }
1027
-
1028
- function challengeClearanceTtl(rule) {
1029
- const t = Number(rule && rule.clearanceTtlSeconds);
1030
- if (Number.isFinite(t) && t >= 30) return t;
1031
- const cfg = Number(_challengeConfig && _challengeConfig.clearanceTtl);
1032
- if (Number.isFinite(cfg) && cfg >= 30) return cfg;
1033
- return challenge.DEFAULTS.clearanceTtl;
1034
- }
1035
-
1036
- function checkChallengeRules(req, ip) {
1037
- if (!_challengeRules || _challengeRules.length === 0) return null;
1038
- if (!_challengeConfig || !_challengeConfig.secret) return null;
1039
- const method = String(req.method || 'GET').toUpperCase();
1040
- const path = requestPath(req);
1041
- for (const rule of _challengeRules) {
1042
- if (!rule) continue;
1043
- const ruleMethod = String(rule.method || 'ALL').toUpperCase();
1044
- if (ruleMethod !== 'ALL' && ruleMethod !== method) continue;
1045
- if (!ruleIpMatches(rule, ip)) continue;
1046
- if (!rulePathMatches(rule, path)) continue;
1047
- return { rule, ruleId: rule.id || rule._id || '', path };
1048
- }
1049
- return null;
1050
- }
1051
-
1052
- function hasValidClearance(req, ip) {
1053
- const secret = _challengeConfig && _challengeConfig.secret;
1054
- if (!secret) return false;
1055
- const cookie = challenge.readClearanceCookie(req);
1056
- if (!cookie) return false;
1057
- const ua = req.headers['user-agent'] || '';
1058
- return challenge.verifyClearance(secret, { cookie, ip, ua }).ok;
1059
- }
1060
-
1061
- function buildClearanceCookie(value, maxAge, secure) {
1062
- let cookie = `${challenge.CLEARANCE_COOKIE}=${value}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Lax`;
1063
- if (secure) cookie += '; Secure';
1064
- return cookie;
1065
- }
1066
-
1067
- function readJsonBody(req, maxBytes, cb) {
1068
- let size = 0;
1069
- const chunks = [];
1070
- let done = false;
1071
- const finish = (err, val) => { if (done) return; done = true; cb(err, val); };
1072
- req.on('data', (chunk) => {
1073
- size += chunk.length;
1074
- if (size > maxBytes) {
1075
- finish(new Error('too_large'));
1076
- try { req.destroy(); } catch (_) {}
1077
- return;
1078
- }
1079
- chunks.push(chunk);
1080
- });
1081
- req.on('end', () => {
1082
- if (done) return;
1083
- try { finish(null, JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); }
1084
- catch (e) { finish(e); }
1085
- });
1086
- req.on('error', (e) => finish(e));
1087
- }
1088
-
1089
- function noteChallengeFail(ip) {
1090
- if (_challengeFails.size > 50_000) _challengeFails.clear();
1091
- const n = (_challengeFails.get(ip) || 0) + 1;
1092
- _challengeFails.set(ip, n);
1093
- return n;
1094
- }
1095
-
1096
- // Serve the proof-of-work interstitial (or a JSON challenge for API clients).
1097
- function sendChallengeResponse(req, res, ip, decision) {
1098
- const secret = _challengeConfig.secret;
1099
- const rule = decision.rule || {};
1100
- const difficulty = challengeDifficulty(rule);
1101
- const rid = decision.ruleId || '*';
1102
- const challengeTtl = Number(_challengeConfig.challengeTtl) || challenge.DEFAULTS.challengeTtl;
1103
- const token = challenge.signChallenge(secret, { ip, difficulty, rid, challengeTtl });
1104
- const baseHeaders = { 'X-SecureNow-Challenge': 'required', 'Cache-Control': 'no-store' };
1105
-
1106
- reportFirewallEvent({
1107
- action: 'captcha_challenged',
1108
- source: 'challenge',
1109
- statusCode: 403,
1110
- ip,
1111
- matchedEntry: rid,
1112
- method: req.method || '',
1113
- path: decision.path || req.url || '',
1114
- userAgent: req.headers['user-agent'] || '',
1115
- });
1116
-
1117
- const accept = req.headers['accept'] || '';
1118
- if (accept.includes('text/html')) {
1119
- res.writeHead(403, { ...baseHeaders, 'Content-Type': 'text/html; charset=utf-8' });
1120
- res.end(challenge.challengeHtml({
1121
- token,
1122
- difficulty,
1123
- submitPath: CHALLENGE_INTERNAL_PATH,
1124
- returnPath: req.url || '/',
1125
- ip,
1126
- }));
1127
- } else {
1128
- res.writeHead(403, { ...baseHeaders, 'Content-Type': 'application/json' });
1129
- res.end(JSON.stringify({
1130
- error: 'challenge_required',
1131
- challenge: { token, alg: challenge.ALG_POW, difficulty, submit: CHALLENGE_INTERNAL_PATH },
1132
- }));
1133
- }
1134
- }
1135
-
1136
- // Handle the internal solve endpoint (POST /__securenow/challenge). Verifies
1137
- // the proof-of-work — locally with the per-app secret, or by proxying to the
1138
- // API — then issues a clearance cookie on success.
1139
- function handleChallengeInternal(req, res, ip) {
1140
- if (String(req.method || '').toUpperCase() !== 'POST') {
1141
- res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' });
1142
- res.end(JSON.stringify({ ok: false, reason: 'method_not_allowed' }));
1143
- return true;
1144
- }
1145
-
1146
- const secret = _challengeConfig && _challengeConfig.secret;
1147
- const verifyMode = (_challengeConfig && _challengeConfig.verify) || 'auto';
1148
-
1149
- readJsonBody(req, 8192, (err, body) => {
1150
- if (err || !body || !body.token) {
1151
- res.writeHead(400, { 'Content-Type': 'application/json' });
1152
- res.end(JSON.stringify({ ok: false, reason: 'bad_request' }));
1153
- return;
1154
- }
1155
- const ua = req.headers['user-agent'] || '';
1156
- const rid = body.rid || '*';
1157
- const clearanceTtl = challengeClearanceTtl(findChallengeRuleById(rid));
1158
- const useProxy = verifyMode === 'proxy' || !secret;
1159
-
1160
- const onPass = (value, maxAge) => {
1161
- _challengeFails.delete(ip);
1162
- res.setHeader('Set-Cookie', buildClearanceCookie(value, maxAge || clearanceTtl, isSecureRequest(req)));
1163
- res.writeHead(204);
1164
- res.end();
1165
- reportFirewallEvent({
1166
- action: 'captcha_passed', source: 'challenge', statusCode: 204,
1167
- ip, matchedEntry: rid, method: 'POST', path: CHALLENGE_INTERNAL_PATH, userAgent: ua,
1168
- });
1169
- };
1170
- const onFail = (reason) => {
1171
- const n = noteChallengeFail(ip);
1172
- res.writeHead(400, { 'Content-Type': 'application/json' });
1173
- res.end(JSON.stringify({ ok: false, reason: reason || 'verification_failed' }));
1174
- reportFirewallEvent({
1175
- action: 'captcha_failed', source: 'challenge', statusCode: 400,
1176
- ip, matchedEntry: String(n), method: 'POST', path: CHALLENGE_INTERNAL_PATH, userAgent: ua,
1177
- });
1178
- };
1179
-
1180
- if (useProxy) {
1181
- const url = buildFirewallUrl('/firewall/verify-challenge');
1182
- httpPostJson(url, { token: body.token, nonce: body.nonce, ip, ua, rid, clearanceTtl }, 8000, (e, r, data) => {
1183
- if (e || !r || r.statusCode !== 200) return onFail('proxy_unreachable');
1184
- let parsed;
1185
- try { parsed = JSON.parse(data); } catch (_) { return onFail('proxy_bad_response'); }
1186
- if (!parsed || !parsed.ok || !parsed.clearance) return onFail(parsed && parsed.reason);
1187
- onPass(parsed.clearance.value, parsed.clearance.maxAge);
1188
- });
1189
- } else {
1190
- const result = challenge.verifyChallengeSolution(secret, { token: body.token, nonce: body.nonce, ip });
1191
- if (!result.ok) return onFail(result.reason);
1192
- const cl = challenge.issueClearance(secret, { ip, ua, rid, clearanceTtl });
1193
- onPass(cl.value, cl.maxAge);
1194
- }
1195
- });
1196
- return true;
1197
- }
1198
-
1199
- function firewallRequestHandler(req, res) {
1200
- // Remote disable wins over everything: when the dashboard / CLI flips the
1201
- // toggle off, requests pass through the SDK as if the firewall weren't
1202
- // installed. The poll loop keeps running so we re-enable within seconds.
1203
- if (_remoteEnabled === false) {
1204
- _stats.suppressedDisabled++;
1205
- return false;
1206
- }
1207
-
1208
- const ip = resolveClientIp(req);
1209
-
1210
- // SecureNow-internal challenge solve endpoint. Handled before any IP rule
1211
- // check so a challenged visitor can always submit their proof-of-work.
1212
- if (requestPath(req) === CHALLENGE_INTERNAL_PATH) {
1213
- return handleChallengeInternal(req, res, ip);
1214
- }
1215
-
1216
- // Allowlist check: if active, only listed IPs are allowed through
1217
- if (_allowlistMatcher && _allowlistMatcher.stats().total > 0) {
1218
- if (!_allowlistMatcher.isBlocked(ip)) {
1219
- _stats.blocked++;
1220
- if (_options && _options.log) fwLog('[securenow] Firewall: blocked %s via HTTP (not in allowlist)', ip);
1221
- reportFirewallEvent({
1222
- source: 'allowlist',
1223
- ip,
1224
- matchedEntry: '',
1225
- method: req.method || '',
1226
- path: req.url || '',
1227
- userAgent: req.headers['user-agent'] || '',
1228
- });
1229
- sendBlockResponse(req, res, ip);
1230
- return true;
1231
- }
1232
- return false;
1233
- }
1234
-
1235
- // Blocklist check
1236
- if (_matcher && _matcher.isBlocked(ip)) {
1237
- _stats.blocked++;
1238
- if (_options && _options.log) fwLog('[securenow] Firewall: blocked %s via HTTP', ip);
1239
- reportFirewallEvent({
1240
- source: 'blocklist',
1241
- ip,
1242
- matchedEntry: ip,
1243
- method: req.method || '',
1244
- path: req.url || '',
1245
- userAgent: req.headers['user-agent'] || '',
1246
- });
1247
- sendBlockResponse(req, res, ip);
1248
- return true;
1249
- }
1250
-
1251
- const blockRuleDecision = checkBlocklistRules(req, ip);
1252
- if (blockRuleDecision) {
1253
- _stats.blocked++;
1254
- if (_options && _options.log) {
1255
- fwLog('[securenow] Firewall: blocked %s via HTTP (rule=%s)', ip, blockRuleDecision.ruleId || 'scoped');
1256
- }
1257
- reportFirewallEvent({
1258
- source: 'blocklist',
1259
- ip,
1260
- matchedEntry: blockRuleDecision.matchedEntry || ip,
1261
- method: req.method || '',
1262
- path: blockRuleDecision.path || req.url || '',
1263
- userAgent: req.headers['user-agent'] || '',
1264
- });
1265
- sendBlockResponse(req, res, ip);
1266
- return true;
1267
- }
1268
-
1269
- // Challenge check sits between hard-block and rate-limit: a matched-but-
1270
- // uncleared visitor gets the interstitial (and does not consume a rate-limit
1271
- // bucket); once they hold a valid clearance they fall through to rate-limit.
1272
- const challengeDecision = checkChallengeRules(req, ip);
1273
- if (challengeDecision && !hasValidClearance(req, ip)) {
1274
- _stats.challenged = (_stats.challenged || 0) + 1;
1275
- if (_options && _options.log) {
1276
- fwLog('[securenow] Firewall: challenged %s via HTTP (rule=%s)', ip, challengeDecision.ruleId || 'scoped');
1277
- }
1278
- sendChallengeResponse(req, res, ip, challengeDecision);
1279
- return true;
1280
- }
1281
-
1282
- const rateLimitDecision = checkRateLimitRules(req, ip);
1283
- if (rateLimitDecision) {
1284
- _stats.rateLimited++;
1285
- if (_options && _options.log) {
1286
- fwLog('[securenow] Firewall: rate-limited %s via HTTP (rule=%s)', ip, rateLimitDecision.ruleId || 'unknown');
1287
- }
1288
- reportFirewallEvent({
1289
- action: 'rate_limited',
1290
- source: 'rate_limit',
1291
- statusCode: 429,
1292
- ip,
1293
- matchedEntry: rateLimitDecision.ruleId || '',
1294
- method: req.method || '',
1295
- path: rateLimitDecision.path || req.url || '',
1296
- userAgent: req.headers['user-agent'] || '',
1297
- });
1298
- sendRateLimitResponse(req, res, ip, rateLimitDecision);
1299
- return true;
1300
- }
1301
-
1302
- return false;
1303
- }
1304
-
1305
- const _origEmit = http.Server.prototype.emit;
1306
- let _emitPatched = false;
1307
-
1308
- function patchEmitLayer() {
1309
- if (_emitPatched) return;
1310
- _emitPatched = true;
1311
-
1312
- http.Server.prototype.emit = function(event, req, res, ...rest) {
1313
- if (event === 'request' && req && res && !res.headersSent) {
1314
- if (firewallRequestHandler(req, res)) return true;
1315
- }
1316
- return _origEmit.call(this, event, req, res, ...rest);
1317
- };
1318
- }
1319
-
1320
- function patchHttpLayer() {
1321
- if (_httpPatched) return;
1322
- _httpPatched = true;
1323
-
1324
- patchEmitLayer();
1325
-
1326
- http.createServer = function(...args) {
1327
- if (typeof args[args.length - 1] === 'function') {
1328
- args[args.length - 1] = wrapListener(args[args.length - 1]);
1329
- }
1330
- return _origHttpCreate.apply(this, args);
1331
- };
1332
- Object.assign(http.createServer, _origHttpCreate);
1333
-
1334
- https.createServer = function(...args) {
1335
- if (typeof args[args.length - 1] === 'function') {
1336
- args[args.length - 1] = wrapListener(args[args.length - 1]);
1337
- }
1338
- return _origHttpsCreate.apply(this, args);
1339
- };
1340
- Object.assign(https.createServer, _origHttpsCreate);
1341
- }
1342
-
1343
- // Init
1344
-
1345
- function init(options) {
1346
- _options = options || {};
1347
- _options.apiUrl = String(_options.apiUrl || '').trim().replace(/\/$/, '');
1348
- _localhostFallbackTried = false;
1349
- _useUnifiedSync = true;
1350
- resetApiUrlFallbacks();
1351
-
1352
- if (_options.log) fwLog('[securenow] Firewall: ENABLED');
1353
- if (_options.log && _options.apiUrl) fwLog('[securenow] Firewall: sync endpoint=%s/api/v1/firewall/sync', _options.apiUrl);
1354
- if (_options.log && _options.environment) fwLog('[securenow] Firewall: environment=%s', _options.environment);
1355
-
1356
- patchHttpLayer();
1357
- if (_options.log) fwLog('[securenow] Firewall: Layer 1 (HTTP 403) active');
1358
-
1359
- if (_options.tcp) {
1360
- try {
1361
- const tcpLayer = require('./firewall-tcp');
1362
- tcpLayer.init(() => _matcher, _options, () => _allowlistMatcher);
1363
- _layers.push(tcpLayer);
1364
- if (_options.log) fwLog('[securenow] Firewall: Layer 2 (TCP drop) active');
1365
- } catch (e) {
1366
- if (_options.log) fwWarn('[securenow] Firewall: Layer 2 (TCP drop) failed:', e.message);
1367
- }
1368
- } else {
1369
- if (_options.log) fwLog('[securenow] Firewall: Layer 2 (TCP drop) disabled (set config.firewall.tcp=true)');
1370
- }
1371
-
1372
- if (_options.iptables) {
1373
- try {
1374
- const iptablesLayer = require('./firewall-iptables');
1375
- iptablesLayer.init(_options);
1376
- _layers.push(iptablesLayer);
1377
- if (_options.log) fwLog('[securenow] Firewall: Layer 3 (iptables) active');
1378
- } catch (e) {
1379
- if (_options.log) fwWarn('[securenow] Firewall: Layer 3 (iptables) failed:', e.message);
1380
- }
1381
- } else {
1382
- if (_options.log) fwLog('[securenow] Firewall: Layer 3 (iptables) disabled (set config.firewall.iptables=true)');
1383
- }
1384
-
1385
- if (_options.cloud) {
1386
- try {
1387
- const cloudLayer = require('./firewall-cloud');
1388
- cloudLayer.init(_options);
1389
- _layers.push(cloudLayer);
1390
- if (_options.log) fwLog('[securenow] Firewall: Layer 4 (Cloud WAF) active (%s)', _options.cloud);
1391
- } catch (e) {
1392
- if (_options.log) fwWarn('[securenow] Firewall: Layer 4 (Cloud WAF) failed:', e.message);
1393
- }
1394
- } else {
1395
- if (_options.log) fwLog('[securenow] Firewall: Layer 4 (Cloud WAF) disabled (set config.firewall.cloud=cloudflare|aws|gcp)');
1396
- }
1397
-
1398
- startSyncLoop();
1399
- }
1400
-
1401
- function shutdown() {
1402
- if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
1403
- if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
1404
- if (_eventTimer) { clearTimeout(_eventTimer); _eventTimer = null; }
1405
- flushFirewallEvents();
1406
-
1407
- _circuitState = 'closed';
1408
- _circuitOpenedAt = 0;
1409
- _consecutiveErrors = 0;
1410
- _pollInflight = false;
1411
- _retryAfterUntil = 0;
1412
- _localhostFallbackTried = false;
1413
- _blocklistRules = [];
1414
- _rateLimitRules = [];
1415
- _rateLimitBuckets = new Map();
1416
- _challengeRules = [];
1417
- _challengeConfig = null;
1418
- _lastChallengeVersion = null;
1419
- _challengeFails = new Map();
1420
- _remainingApiUrlFallbacks = [];
1421
-
1422
- _httpAgent.destroy();
1423
- _httpsAgent.destroy();
1424
-
1425
- for (const layer of _layers) {
1426
- try { if (typeof layer.shutdown === 'function') layer.shutdown(); } catch (_) {}
1427
- }
1428
- _layers = [];
1429
-
1430
- if (_emitPatched) {
1431
- http.Server.prototype.emit = _origEmit;
1432
- _emitPatched = false;
1433
- }
1434
- if (_httpPatched) {
1435
- http.createServer = _origHttpCreate;
1436
- https.createServer = _origHttpsCreate;
1437
- _httpPatched = false;
1438
- }
1439
- }
1440
-
1441
- function getStats() {
1442
- return {
1443
- ..._stats,
1444
- matcher: _matcher ? _matcher.stats() : null,
1445
- blocklistRules: _blocklistRules.length,
1446
- allowlistMatcher: _allowlistMatcher ? _allowlistMatcher.stats() : null,
1447
- rateLimitRules: _rateLimitRules.length,
1448
- rateLimitBuckets: _rateLimitBuckets.size,
1449
- initialized: _initialized,
1450
- circuitState: _circuitState,
1451
- consecutiveErrors: _consecutiveErrors,
1452
- unifiedSync: _useUnifiedSync,
1453
- remoteEnabled: _remoteEnabled,
1454
- appKey: _options ? _options.appKey || null : null,
1455
- environment: _options ? _options.environment || null : null,
1456
- };
1457
- }
1458
-
1459
- // Layers (TCP / iptables / cloud) read the matcher to populate kernel-level
1460
- // rules. When the remote toggle is off, return null so they treat the policy
1461
- // as "no IPs to block" without us mutating the cached matcher.
1462
- function getMatcher() { return _remoteEnabled === false ? null : _matcher; }
1463
- function getAllowlistMatcher() { return _remoteEnabled === false ? null : _allowlistMatcher; }
1464
- function getBlocklistRules() { return _remoteEnabled === false ? [] : _blocklistRules.slice(); }
1465
- function getRateLimitRules() { return _remoteEnabled === false ? [] : _rateLimitRules.slice(); }
1466
- function isRemoteEnabled() { return _remoteEnabled !== false; }
1467
-
1468
- function getChallengeRules() { return _challengeRules; }
1469
-
1470
- module.exports = { init, shutdown, getStats, getMatcher, getAllowlistMatcher, getBlocklistRules, getRateLimitRules, getChallengeRules, isRemoteEnabled };
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const https = require('https');
5
+ const { createMatcher } = require('./cidr');
6
+ const { resolveClientIp } = require('./resolve-ip');
7
+ const challenge = require('./challenge');
8
+
9
+ let _options = null;
10
+ let _matcher = null;
11
+ let _syncTimer = null;
12
+ let _pollTimer = null;
13
+ let _lastModified = null;
14
+ let _lastVersion = null;
15
+ let _lastSyncEtag = null;
16
+ let _lastUnifiedEtag = null;
17
+ let _initialized = false;
18
+ let _consecutiveErrors = 0;
19
+ let _layers = [];
20
+ let _rawIps = [];
21
+ let _blocklistRules = [];
22
+ let _stats = { syncs: 0, blocked: 0, rateLimited: 0, challenged: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
23
+ let _localhostFallbackTried = false;
24
+ let _eventQueue = [];
25
+ let _eventTimer = null;
26
+ let _remainingApiUrlFallbacks = [];
27
+
28
+ // Remote toggle - set by /firewall/sync when an appKey is in scope. Default
29
+ // true so a missing/unreachable backend fails open (matches pre-7.3 behavior).
30
+ // When the dashboard / CLI flips this off, the next poll suppresses
31
+ // enforcement without restarting the host process.
32
+ let _remoteEnabled = true;
33
+ let _lastRemoteEnabled = null;
34
+
35
+ // Allowlist state
36
+ let _allowlistMatcher = null;
37
+ let _allowlistRawIps = [];
38
+ let _lastAllowlistModified = null;
39
+ let _lastAllowlistVersion = null;
40
+ let _lastAllowlistSyncEtag = null;
41
+
42
+ // Route/method-scoped policy state. Global IP/CIDR blocks stay in _matcher so
43
+ // TCP, OS firewall, and Cloud WAF layers never over-enforce an HTTP-only rule.
44
+ let _rateLimitRules = [];
45
+ let _lastRateLimitVersion = null;
46
+ let _rateLimitBuckets = new Map();
47
+
48
+ // CAPTCHA / proof-of-work challenge state. _challengeConfig.secret signs the
49
+ // challenge tokens + clearance cookies (delivered per-app over sync). When a
50
+ // matched request carries no valid clearance the SDK serves an interstitial
51
+ // instead of passing it through.
52
+ let _challengeRules = [];
53
+ let _lastChallengeVersion = null;
54
+ let _challengeConfig = null;
55
+ // Per-IP count of failed/abandoned challenges, for escalation reporting.
56
+ let _challengeFails = new Map();
57
+
58
+ // Circuit breaker
59
+ const CIRCUIT_OPEN_THRESHOLD = 5;
60
+ const CIRCUIT_OPEN_COOLDOWN_MS = 120_000;
61
+ let _circuitState = 'closed';
62
+ let _circuitOpenedAt = 0;
63
+
64
+ // In-flight guard and 429 back-off
65
+ let _pollInflight = false;
66
+ let _retryAfterUntil = 0;
67
+
68
+ // Firewall control-plane traffic is low-frequency and correctness-critical.
69
+ // Use non-keep-alive agents so clustered apps do not reuse sockets that an
70
+ // upstream load balancer has silently closed between sync cycles.
71
+ const _httpAgent = new http.Agent({ keepAlive: false });
72
+ const _httpsAgent = new https.Agent({ keepAlive: false });
73
+ const EVENT_FLUSH_INTERVAL_MS = 2_000;
74
+ const EVENT_BATCH_SIZE = 25;
75
+ const EVENT_QUEUE_MAX = 1_000;
76
+ const TRANSIENT_NETWORK_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND']);
77
+
78
+ // Unified sync uses /firewall/sync (v2). Falls back to legacy on 404.
79
+ let _useUnifiedSync = true;
80
+
81
+ function originalConsoleMethod(method) {
82
+ const originals = console.__securenow_original || console.__securenowOriginalConsole;
83
+ return (originals && originals[method]) || console[method] || console.log;
84
+ }
85
+
86
+ function firewallConsole(method, ...args) {
87
+ if (!_options || !_options.log) return;
88
+ try {
89
+ originalConsoleMethod(method).apply(console, args);
90
+ } catch (_) {}
91
+ }
92
+
93
+ function fwLog(...args) {
94
+ firewallConsole('log', ...args);
95
+ }
96
+
97
+ function fwWarn(...args) {
98
+ firewallConsole('warn', ...args);
99
+ }
100
+
101
+ // Circuit Breaker
102
+
103
+ function maybeOpenCircuit() {
104
+ if (_circuitState === 'open') return;
105
+ if (_consecutiveErrors >= CIRCUIT_OPEN_THRESHOLD) {
106
+ _circuitState = 'open';
107
+ _circuitOpenedAt = Date.now();
108
+ if (_options && _options.log) {
109
+ fwWarn('[securenow] Firewall: circuit breaker OPEN - pausing polling for %ds', CIRCUIT_OPEN_COOLDOWN_MS / 1000);
110
+ }
111
+ }
112
+ }
113
+
114
+ function resetCircuit() {
115
+ if (_circuitState !== 'closed') {
116
+ _circuitState = 'closed';
117
+ if (_options && _options.log) fwLog('[securenow] Firewall: circuit breaker CLOSED - API healthy');
118
+ }
119
+ _consecutiveErrors = 0;
120
+ }
121
+
122
+ function shouldSkipRequest() {
123
+ if (Date.now() < _retryAfterUntil) return true;
124
+ if (_circuitState === 'closed') return false;
125
+ if (_circuitState === 'open') {
126
+ if (Date.now() - _circuitOpenedAt >= CIRCUIT_OPEN_COOLDOWN_MS) {
127
+ _circuitState = 'half-open';
128
+ return false;
129
+ }
130
+ return true;
131
+ }
132
+ return false; // half-open: allow one probe
133
+ }
134
+
135
+ function handleRetryAfter(res) {
136
+ const ra = res.headers['retry-after'];
137
+ if (!ra) return;
138
+ const secs = parseInt(ra, 10);
139
+ if (secs > 0 && secs <= 300) {
140
+ _retryAfterUntil = Date.now() + secs * 1000;
141
+ if (_options && _options.log) {
142
+ fwWarn('[securenow] Firewall: API returned 429, backing off for %ds', secs);
143
+ }
144
+ }
145
+ }
146
+
147
+ // HTTP helpers
148
+
149
+ function buildUrl(apiUrl, path) {
150
+ return apiUrl.replace(/\/+$/, '') + '/api/v1' + path;
151
+ }
152
+
153
+ function buildFirewallUrl(path) {
154
+ const query = new URLSearchParams();
155
+ if (_options && _options.appKey) query.set('app', _options.appKey);
156
+ if (_options && _options.environment) query.set('env', _options.environment);
157
+ const suffix = query.toString() ? `?${query.toString()}` : '';
158
+ return buildUrl(_options.apiUrl, path) + suffix;
159
+ }
160
+
161
+ function jitter(baseMs) {
162
+ return baseMs * (0.8 + Math.random() * 0.4);
163
+ }
164
+
165
+ function agentFor(url) {
166
+ return url.startsWith('https') ? _httpsAgent : _httpAgent;
167
+ }
168
+
169
+ function isTransientNetworkError(err) {
170
+ if (!err) return false;
171
+ if (err.code && TRANSIENT_NETWORK_CODES.has(err.code)) return true;
172
+ return /socket hang up|connection reset|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|timed out/i.test(String(err.message || ''));
173
+ }
174
+
175
+ function isApiReachabilityError(err) {
176
+ if (isTransientNetworkError(err)) return true;
177
+ const text = `${err && err.code || ''} ${err && err.message || ''}`;
178
+ return /TLS|SSL|certificate|CERT_|UNABLE_TO_VERIFY|self signed/i.test(text);
179
+ }
180
+
181
+ function formatRequestError(err) {
182
+ if (!err) return 'unknown error';
183
+ const parts = [err.message || String(err)];
184
+ if (err.code && !parts[0].includes(err.code)) parts.push(`code=${err.code}`);
185
+ if (err.syscall) parts.push(`syscall=${err.syscall}`);
186
+ return parts.join(' ');
187
+ }
188
+
189
+ function resetApiUrlFallbacks() {
190
+ const seen = new Set([_options && _options.apiUrl].filter(Boolean));
191
+ _remainingApiUrlFallbacks = [];
192
+ for (const candidate of Array.isArray(_options && _options.apiUrlFallbacks) ? _options.apiUrlFallbacks : []) {
193
+ const url = String(candidate || '').trim().replace(/\/$/, '');
194
+ if (!url || seen.has(url)) continue;
195
+ seen.add(url);
196
+ _remainingApiUrlFallbacks.push(url);
197
+ }
198
+ }
199
+
200
+ function switchToNextApiUrl(reason) {
201
+ if (!_options || _remainingApiUrlFallbacks.length === 0) return false;
202
+ const previous = _options.apiUrl;
203
+ _options.apiUrl = _remainingApiUrlFallbacks.shift();
204
+ _lastUnifiedEtag = null;
205
+ _lastSyncEtag = null;
206
+ _lastAllowlistSyncEtag = null;
207
+ if (_options.log) {
208
+ fwWarn('[securenow] Firewall: %s unreachable (%s), retrying sync via %s',
209
+ previous,
210
+ reason || 'network error',
211
+ _options.apiUrl);
212
+ }
213
+ return true;
214
+ }
215
+
216
+ function requestOnce(method, url, body, extraHeaders, timeout, callback) {
217
+ const mod = url.startsWith('https') ? https : http;
218
+ const parsed = new URL(url);
219
+ const payload = body == null ? null : JSON.stringify(body || {});
220
+
221
+ const req = mod.request({
222
+ hostname: parsed.hostname,
223
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
224
+ path: parsed.pathname + parsed.search,
225
+ method,
226
+ headers: {
227
+ 'Authorization': `Bearer ${_options.apiKey}`,
228
+ 'User-Agent': 'securenow-firewall-sdk',
229
+ 'Connection': 'close',
230
+ ...(payload
231
+ ? {
232
+ 'Content-Type': 'application/json',
233
+ 'Content-Length': Buffer.byteLength(payload),
234
+ }
235
+ : {}),
236
+ ...extraHeaders,
237
+ },
238
+ timeout,
239
+ agent: agentFor(url),
240
+ }, (res) => {
241
+ let data = '';
242
+ res.on('data', (chunk) => { data += chunk; });
243
+ res.on('end', () => { done(null, res, data); });
244
+ });
245
+
246
+ let finished = false;
247
+ function done(...args) {
248
+ if (finished) return;
249
+ finished = true;
250
+ callback(...args);
251
+ }
252
+
253
+ req.on('error', (err) => done(err));
254
+ req.on('timeout', () => {
255
+ const err = new Error('Request timed out');
256
+ err.code = 'ETIMEDOUT';
257
+ done(err);
258
+ req.destroy(err);
259
+ });
260
+ if (payload) req.write(payload);
261
+ req.end();
262
+ }
263
+
264
+ function requestWithRetry(method, url, body, extraHeaders, timeout, callback) {
265
+ requestOnce(method, url, body, extraHeaders, timeout, (err, res, data) => {
266
+ if (!err || !isTransientNetworkError(err)) return callback(err, res, data);
267
+
268
+ if (_options && _options.log) {
269
+ fwWarn('[securenow] Firewall: transient API socket error, retrying once:', formatRequestError(err));
270
+ }
271
+
272
+ const retryTimer = setTimeout(() => {
273
+ requestOnce(method, url, body, extraHeaders, timeout, callback);
274
+ }, 100);
275
+ if (retryTimer.unref) retryTimer.unref();
276
+ });
277
+ }
278
+
279
+ function httpGet(url, extraHeaders, timeout, callback) {
280
+ requestWithRetry('GET', url, null, extraHeaders, timeout, callback);
281
+ }
282
+
283
+ function httpPostJson(url, body, timeout, callback) {
284
+ requestWithRetry('POST', url, body, {}, timeout, callback);
285
+ }
286
+
287
+ function scheduleEventFlush() {
288
+ if (_eventTimer || _eventQueue.length === 0) return;
289
+ _eventTimer = setTimeout(() => {
290
+ _eventTimer = null;
291
+ flushFirewallEvents();
292
+ }, EVENT_FLUSH_INTERVAL_MS);
293
+ if (_eventTimer.unref) _eventTimer.unref();
294
+ }
295
+
296
+ function flushFirewallEvents() {
297
+ if (!_options || !_options.apiKey || !_options.apiUrl || _eventQueue.length === 0) return;
298
+ if (shouldSkipRequest()) {
299
+ _eventQueue = [];
300
+ return;
301
+ }
302
+
303
+ const batch = _eventQueue.splice(0, EVENT_BATCH_SIZE);
304
+ const url = buildFirewallUrl('/firewall/events');
305
+
306
+ httpPostJson(url, { events: batch }, 5000, (err, res) => {
307
+ if (err || !res || res.statusCode >= 400) {
308
+ if (_options.log) {
309
+ const msg = err ? formatRequestError(err) : `API returned ${res.statusCode}`;
310
+ fwWarn('[securenow] Firewall: failed to report blocked-request ledger:', msg);
311
+ }
312
+ }
313
+ if (_eventQueue.length) scheduleEventFlush();
314
+ });
315
+ }
316
+
317
+ function reportFirewallEvent(event) {
318
+ if (!_options || !_options.apiKey || !_options.apiUrl) return;
319
+
320
+ const item = {
321
+ applicationKey: _options.appKey || null,
322
+ environment: _options.environment || 'production',
323
+ action: 'blocked',
324
+ statusCode: (_options && _options.statusCode) || 403,
325
+ occurredAt: new Date().toISOString(),
326
+ ...event,
327
+ };
328
+
329
+ _eventQueue.push(item);
330
+ if (_eventQueue.length > EVENT_QUEUE_MAX) {
331
+ _eventQueue.splice(0, _eventQueue.length - EVENT_QUEUE_MAX);
332
+ }
333
+
334
+ if (_eventQueue.length >= EVENT_BATCH_SIZE) flushFirewallEvents();
335
+ else scheduleEventFlush();
336
+ }
337
+
338
+ function normalizePrefixPattern(pattern) {
339
+ const value = String(pattern || '');
340
+ return value.endsWith('*') ? value.slice(0, -1) : value;
341
+ }
342
+
343
+ function normalizeBlocklistRules(rules) {
344
+ if (!Array.isArray(rules)) return [];
345
+ return rules
346
+ .map((rule) => {
347
+ if (!rule || !rule.ip) return null;
348
+ const pathMatchMode = ['exact', 'prefix', 'regex'].includes(String(rule.pathMatchMode || '').toLowerCase())
349
+ ? String(rule.pathMatchMode).toLowerCase()
350
+ : 'prefix';
351
+ return {
352
+ ...rule,
353
+ ip: String(rule.ip || '').trim(),
354
+ method: String(rule.method || 'ALL').toUpperCase(),
355
+ pathPattern: pathMatchMode === 'prefix'
356
+ ? normalizePrefixPattern(rule.pathPattern)
357
+ : String(rule.pathPattern || '').trim(),
358
+ pathMatchMode,
359
+ };
360
+ })
361
+ .filter(Boolean);
362
+ }
363
+
364
+ function setBlocklistData(ips, rules) {
365
+ const normalizedIps = Array.isArray(ips) ? ips : [];
366
+ _rawIps = normalizedIps;
367
+ _blocklistRules = normalizeBlocklistRules(rules);
368
+ _matcher = createMatcher(normalizedIps);
369
+ _stats.syncs++;
370
+ notifyLayers(normalizedIps);
371
+ }
372
+
373
+ // Unified Sync (v2 - single request for everything)
374
+
375
+ function doUnifiedSync(callback) {
376
+ const query = new URLSearchParams();
377
+ if (_options.appKey) query.set('app', _options.appKey);
378
+ if (_options.environment) query.set('env', _options.environment);
379
+ const suffix = query.toString() ? `?${query.toString()}` : '';
380
+ const url = buildUrl(_options.apiUrl, '/firewall/sync') + suffix;
381
+ const headers = {};
382
+ if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
383
+ if (_lastVersion) headers['X-Blocklist-Version'] = _lastVersion;
384
+ if (_lastAllowlistVersion) headers['X-Allowlist-Version'] = _lastAllowlistVersion;
385
+ if (_lastRateLimitVersion) headers['X-Rate-Limit-Version'] = _lastRateLimitVersion;
386
+ if (_lastChallengeVersion) headers['X-Challenge-Version'] = _lastChallengeVersion;
387
+ if (_lastUnifiedEtag) headers['If-None-Match'] = _lastUnifiedEtag;
388
+
389
+ httpGet(url, headers, 8000, (err, res, data) => {
390
+ if (err) return callback(err);
391
+
392
+ _stats.versionChecks++;
393
+ if (res.headers && res.headers.etag) _lastUnifiedEtag = res.headers.etag;
394
+
395
+ if (res.statusCode === 304) {
396
+ return callback(null, { blChanged: false, alChanged: false });
397
+ }
398
+
399
+ if (res.statusCode === 404) {
400
+ _useUnifiedSync = false;
401
+ if (_options.log) fwLog('[securenow] Firewall: /sync not available, using legacy endpoints');
402
+ return callback(null, { blChanged: false, alChanged: false, useLegacy: true });
403
+ }
404
+
405
+ if (res.statusCode === 429) { handleRetryAfter(res); }
406
+
407
+ if (res.statusCode !== 200) {
408
+ return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
409
+ }
410
+
411
+ try {
412
+ const body = JSON.parse(data);
413
+
414
+ let blChanged = false;
415
+ let alChanged = false;
416
+
417
+ // Apply remote per-app toggle. Absent body.app means the backend either
418
+ // doesn't know about appKey-scoped sync (older API) or no appKey was
419
+ // sent - leave the previous value untouched (default true on first run).
420
+ if (body.app && typeof body.app.firewallEnabled === 'boolean') {
421
+ const next = body.app.firewallEnabled;
422
+ if (next !== _lastRemoteEnabled) {
423
+ _remoteEnabled = next;
424
+ if (_options.log) {
425
+ fwLog('[securenow] Firewall: remote toggle -> %s (app=%s)',
426
+ next ? 'ENABLED' : 'DISABLED', body.app.key || _options.appKey);
427
+ }
428
+ _lastRemoteEnabled = next;
429
+ }
430
+ }
431
+
432
+ // Update blocklist version + data
433
+ if (body.blocklist) {
434
+ const newVer = body.blocklist.version;
435
+ if (newVer !== _lastVersion) {
436
+ _lastVersion = newVer;
437
+ }
438
+ }
439
+
440
+ if (body.blocklistIps || body.blocklistRules) {
441
+ setBlocklistData(body.blocklistIps || [], body.blocklistRules || []);
442
+ blChanged = true;
443
+ }
444
+
445
+ // Update allowlist version + data
446
+ if (body.allowlist) {
447
+ const newVer = body.allowlist.version;
448
+ if (newVer !== _lastAllowlistVersion) {
449
+ _lastAllowlistVersion = newVer;
450
+ }
451
+ }
452
+
453
+ if (body.allowlistIps) {
454
+ _allowlistRawIps = body.allowlistIps;
455
+ _allowlistMatcher = createMatcher(body.allowlistIps);
456
+ alChanged = true;
457
+ }
458
+
459
+ if (body.rateLimits) {
460
+ const newVer = body.rateLimits.version;
461
+ if (newVer !== _lastRateLimitVersion) {
462
+ _lastRateLimitVersion = newVer;
463
+ }
464
+ }
465
+
466
+ if (Array.isArray(body.rateLimitRules)) {
467
+ _rateLimitRules = body.rateLimitRules;
468
+ pruneRateLimitBuckets(_rateLimitRules);
469
+ }
470
+
471
+ // Per-app challenge config (secret + defaults) rides on the app block.
472
+ if (body.app && body.app.challenge && typeof body.app.challenge === 'object') {
473
+ _challengeConfig = body.app.challenge;
474
+ }
475
+
476
+ if (body.challenges) {
477
+ const newVer = body.challenges.version;
478
+ if (newVer !== _lastChallengeVersion) {
479
+ _lastChallengeVersion = newVer;
480
+ }
481
+ }
482
+
483
+ if (Array.isArray(body.challengeRules)) {
484
+ _challengeRules = body.challengeRules;
485
+ }
486
+
487
+ callback(null, { blChanged, alChanged, rlChanged: Array.isArray(body.rateLimitRules) });
488
+ } catch (e) {
489
+ callback(new Error(`Failed to parse sync response: ${e.message}`));
490
+ }
491
+ });
492
+ }
493
+
494
+ // Legacy Sync (v1 - separate endpoints, kept for backward compat)
495
+
496
+ function legacyBlocklistSync(callback) {
497
+ const url = buildFirewallUrl('/firewall/blocklist');
498
+ const headers = {};
499
+ if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
500
+ if (_lastSyncEtag) headers['If-None-Match'] = _lastSyncEtag;
501
+ else if (_lastModified) headers['If-Modified-Since'] = _lastModified;
502
+
503
+ httpGet(url, headers, 10000, (err, res, data) => {
504
+ if (err) return callback(err);
505
+ if (res.statusCode === 304) return callback(null, false);
506
+ if (res.statusCode === 429) { handleRetryAfter(res); }
507
+ if (res.statusCode !== 200) return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
508
+
509
+ try {
510
+ const body = JSON.parse(data);
511
+ const ips = body.ips || [];
512
+ setBlocklistData(ips, body.rules || body.blocklistRules || []);
513
+ _lastModified = res.headers['last-modified'] || null;
514
+ if (res.headers['etag']) _lastSyncEtag = res.headers['etag'];
515
+ callback(null, true, _matcher.stats());
516
+ } catch (e) {
517
+ callback(new Error(`Failed to parse blocklist: ${e.message}`));
518
+ }
519
+ });
520
+ }
521
+
522
+ function legacyAllowlistSync(callback) {
523
+ const url = buildFirewallUrl('/firewall/allowlist');
524
+ const headers = {};
525
+ if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
526
+ if (_lastAllowlistSyncEtag) headers['If-None-Match'] = _lastAllowlistSyncEtag;
527
+ else if (_lastAllowlistModified) headers['If-Modified-Since'] = _lastAllowlistModified;
528
+
529
+ httpGet(url, headers, 10000, (err, res, data) => {
530
+ if (err) return callback(err);
531
+ if (res.statusCode === 304) return callback(null, false);
532
+ if (res.statusCode === 429) { handleRetryAfter(res); }
533
+ if (res.statusCode !== 200) return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
534
+
535
+ try {
536
+ const body = JSON.parse(data);
537
+ const ips = body.ips || [];
538
+ _allowlistRawIps = ips;
539
+ _allowlistMatcher = createMatcher(ips);
540
+ _lastAllowlistModified = res.headers['last-modified'] || null;
541
+ if (res.headers['etag']) _lastAllowlistSyncEtag = res.headers['etag'];
542
+ callback(null, true, _allowlistMatcher.stats());
543
+ } catch (e) {
544
+ callback(new Error(`Failed to parse allowlist: ${e.message}`));
545
+ }
546
+ });
547
+ }
548
+
549
+ function legacyVersionCheck(callback) {
550
+ const url = buildFirewallUrl('/firewall/blocklist/version');
551
+ const headers = {};
552
+ if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
553
+ if (_lastVersion) headers['If-None-Match'] = _lastVersion;
554
+
555
+ httpGet(url, headers, 5000, (err, res, data) => {
556
+ if (err) return callback(err);
557
+ _stats.versionChecks++;
558
+ if (res.statusCode === 304) return callback(null, false, false);
559
+ if (res.statusCode === 429) { handleRetryAfter(res); }
560
+ if (res.statusCode !== 200) return callback(new Error(`API ${res.statusCode}`));
561
+
562
+ try {
563
+ const body = JSON.parse(data);
564
+ const version = body.version || null;
565
+ const changed = version !== _lastVersion;
566
+ if (changed) _lastVersion = version;
567
+ callback(null, changed, false);
568
+ } catch (_e) { callback(null, false, false); }
569
+ });
570
+ }
571
+
572
+ function legacyAllowlistVersionCheck(callback) {
573
+ const url = buildFirewallUrl('/firewall/allowlist/version');
574
+ const headers = {};
575
+ if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
576
+ if (_lastAllowlistVersion) headers['If-None-Match'] = _lastAllowlistVersion;
577
+
578
+ httpGet(url, headers, 5000, (err, res, data) => {
579
+ if (err) return callback(err);
580
+ if (res.statusCode === 304) return callback(null, false);
581
+ if (res.statusCode === 429) { handleRetryAfter(res); }
582
+ if (res.statusCode !== 200) return callback(new Error(`API ${res.statusCode}`));
583
+
584
+ try {
585
+ const body = JSON.parse(data);
586
+ const version = body.version || null;
587
+ const changed = version !== _lastAllowlistVersion;
588
+ if (changed) _lastAllowlistVersion = version;
589
+ callback(null, changed);
590
+ } catch (_e) { callback(null, false); }
591
+ });
592
+ }
593
+
594
+ function doLegacyPoll(callback) {
595
+ let pending = 2;
596
+ let blChanged = false;
597
+ let alChanged = false;
598
+ let firstError = null;
599
+
600
+ function checkDone() {
601
+ if (--pending > 0) return;
602
+ if (firstError) return callback(firstError);
603
+
604
+ let syncsPending = 0;
605
+ if (blChanged) syncsPending++;
606
+ if (alChanged) syncsPending++;
607
+ if (syncsPending === 0) return callback(null, { blChanged: false, alChanged: false });
608
+
609
+ function syncDone() {
610
+ if (--syncsPending > 0) return;
611
+ callback(null, { blChanged, alChanged });
612
+ }
613
+
614
+ if (blChanged) {
615
+ legacyBlocklistSync((err, changed, stats) => {
616
+ if (err && _options.log) fwWarn('[securenow] Firewall: sync failed (using stale list):', formatRequestError(err));
617
+ else if (changed && stats && _options.log) fwLog('[securenow] Firewall: re-synced %d blocked IPs', stats.total);
618
+ syncDone();
619
+ });
620
+ }
621
+ if (alChanged) {
622
+ legacyAllowlistSync((err, changed, stats) => {
623
+ if (err && _options.log) fwWarn('[securenow] Firewall: allowlist sync failed:', formatRequestError(err));
624
+ else if (changed && stats && _options.log) fwLog('[securenow] Firewall: re-synced %d allowed IPs', stats.total);
625
+ syncDone();
626
+ });
627
+ }
628
+ }
629
+
630
+ legacyVersionCheck((err, changed) => {
631
+ if (err) firstError = firstError || err;
632
+ blChanged = changed;
633
+ checkDone();
634
+ });
635
+
636
+ legacyAllowlistVersionCheck((err, changed) => {
637
+ if (err) firstError = firstError || err;
638
+ alChanged = changed;
639
+ checkDone();
640
+ });
641
+ }
642
+
643
+ // Unified poll loop
644
+
645
+ function notifyLayers(ips) {
646
+ for (const layer of _layers) {
647
+ try { if (typeof layer.sync === 'function') layer.sync(ips); } catch (_) {}
648
+ }
649
+ }
650
+
651
+ function pollOnce(callback) {
652
+ if (_pollInflight || shouldSkipRequest()) return callback(null);
653
+ _pollInflight = true;
654
+
655
+ const done = (err, result) => {
656
+ _pollInflight = false;
657
+ if (err) {
658
+ if (isApiReachabilityError(err) && switchToNextApiUrl(formatRequestError(err))) {
659
+ _pollInflight = false;
660
+ const retryTimer = setTimeout(() => pollOnce(callback), 1000);
661
+ if (retryTimer.unref) retryTimer.unref();
662
+ return;
663
+ }
664
+ _consecutiveErrors++;
665
+ _stats.errors++;
666
+ maybeOpenCircuit();
667
+ if (_options.log) fwWarn('[securenow] Firewall: poll failed:', formatRequestError(err));
668
+ return callback(err);
669
+ }
670
+ _consecutiveErrors = 0;
671
+ resetCircuit();
672
+ if (result) {
673
+ if (result.blChanged && _options.log && _matcher) {
674
+ const s = _matcher.stats();
675
+ fwLog('[securenow] Firewall: re-synced %d global blocked IPs (%d exact + %d CIDR ranges) and %d scoped block rules',
676
+ s.total, s.exact, s.cidr, _blocklistRules.length);
677
+ }
678
+ if (result.alChanged && _options.log && _allowlistMatcher) {
679
+ const s = _allowlistMatcher.stats();
680
+ fwLog('[securenow] Firewall: re-synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
681
+ }
682
+ if (result.rlChanged && _options.log) {
683
+ fwLog('[securenow] Firewall: re-synced %d rate-limit rules', _rateLimitRules.length);
684
+ }
685
+ }
686
+ callback(null);
687
+ };
688
+
689
+ if (_useUnifiedSync) {
690
+ doUnifiedSync((err, result) => {
691
+ if (err) return done(err);
692
+ if (result && result.useLegacy) {
693
+ doLegacyPoll(done);
694
+ } else {
695
+ done(null, result);
696
+ }
697
+ });
698
+ } else {
699
+ doLegacyPoll(done);
700
+ }
701
+ }
702
+
703
+ function scheduleNextPoll() {
704
+ const baseMs = (_options.versionCheckInterval || 10) * 1000;
705
+ const backoffMs = Math.min(baseMs * Math.pow(2, _consecutiveErrors), 120_000);
706
+ const delayMs = jitter(backoffMs);
707
+
708
+ _pollTimer = setTimeout(() => {
709
+ pollOnce(() => { scheduleNextPoll(); });
710
+ }, delayMs);
711
+ if (_pollTimer.unref) _pollTimer.unref();
712
+ }
713
+
714
+ function startSyncLoop() {
715
+ const fullSyncIntervalMs = (_options.syncInterval || 300) * 1000;
716
+ const BASE_RETRY = 5000;
717
+ const MAX_RETRY = 120_000;
718
+ let _initAttempt = 0;
719
+
720
+ function initialSync() {
721
+ // Use unified endpoint for initial sync too
722
+ const syncFn = _useUnifiedSync ? doUnifiedSync : (cb) => doLegacyPoll(cb);
723
+
724
+ syncFn((err, result) => {
725
+ if (err) {
726
+ const isConnErr = /ECONNREFUSED|ENOTFOUND|timed out/i.test(err.message);
727
+ if (isApiReachabilityError(err) && switchToNextApiUrl(formatRequestError(err))) {
728
+ const retryTimer = setTimeout(initialSync, 1000);
729
+ if (retryTimer.unref) retryTimer.unref();
730
+ return;
731
+ }
732
+ if (isConnErr && !_localhostFallbackTried && _options.apiUrl !== 'http://localhost:4000') {
733
+ _localhostFallbackTried = true;
734
+ const origUrl = _options.apiUrl;
735
+ _options.apiUrl = 'http://localhost:4000';
736
+ if (_options.log) fwLog('[securenow] Firewall: %s unreachable, trying http://localhost:4000', origUrl);
737
+ const retryTimer = setTimeout(initialSync, 1000);
738
+ if (retryTimer.unref) retryTimer.unref();
739
+ return;
740
+ }
741
+ if (result && result.useLegacy) {
742
+ const retryTimer = setTimeout(initialSync, 1000);
743
+ if (retryTimer.unref) retryTimer.unref();
744
+ return;
745
+ }
746
+ if (_options.log) fwWarn('[securenow] Firewall: initial sync failed:', formatRequestError(err));
747
+ if (_options.failMode === 'closed') {
748
+ _matcher = { isBlocked: () => true, stats: () => ({ exact: 0, cidr: 0, total: 0 }) };
749
+ }
750
+ _initAttempt++;
751
+ const delay = Math.min(BASE_RETRY * Math.pow(2, _initAttempt), MAX_RETRY);
752
+ const retryTimer = setTimeout(initialSync, jitter(delay));
753
+ if (retryTimer.unref) retryTimer.unref();
754
+ return;
755
+ }
756
+
757
+ if (result && result.useLegacy) {
758
+ const retryTimer = setTimeout(initialSync, 1000);
759
+ if (retryTimer.unref) retryTimer.unref();
760
+ return;
761
+ }
762
+
763
+ _initialized = true;
764
+ if (_options.log && _matcher) {
765
+ const s = _matcher.stats();
766
+ fwLog('[securenow] Firewall: synced %d global blocked IPs (%d exact + %d CIDR ranges) and %d scoped block rules',
767
+ s.total, s.exact, s.cidr, _blocklistRules.length);
768
+ }
769
+ if (_options.log && _allowlistMatcher) {
770
+ const s = _allowlistMatcher.stats();
771
+ if (s.total > 0) fwLog('[securenow] Firewall: synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
772
+ }
773
+ if (_options.log && _rateLimitRules.length > 0) {
774
+ fwLog('[securenow] Firewall: synced %d rate-limit rules', _rateLimitRules.length);
775
+ }
776
+ });
777
+ }
778
+
779
+ initialSync();
780
+ scheduleNextPoll();
781
+
782
+ // Safety-net full sync timer (less frequent, uses same path)
783
+ _syncTimer = setInterval(() => {
784
+ if (shouldSkipRequest()) return;
785
+ // Force a full re-fetch by clearing versions so unified endpoint returns full data
786
+ const savedBlVer = _lastVersion;
787
+ const savedAlVer = _lastAllowlistVersion;
788
+ const savedRlVer = _lastRateLimitVersion;
789
+ const savedUnifiedEtag = _lastUnifiedEtag;
790
+ _lastVersion = null;
791
+ _lastAllowlistVersion = null;
792
+ _lastRateLimitVersion = null;
793
+ _lastUnifiedEtag = null;
794
+ pollOnce((err) => {
795
+ if (err) {
796
+ _lastVersion = savedBlVer;
797
+ _lastAllowlistVersion = savedAlVer;
798
+ _lastRateLimitVersion = savedRlVer;
799
+ _lastUnifiedEtag = savedUnifiedEtag;
800
+ }
801
+ });
802
+ }, fullSyncIntervalMs);
803
+ if (_syncTimer.unref) _syncTimer.unref();
804
+ }
805
+
806
+ // Layer 1: HTTP Handler
807
+
808
+ const _origHttpCreate = http.createServer;
809
+ const _origHttpsCreate = https.createServer;
810
+ let _httpPatched = false;
811
+
812
+ function blockedHtml(ip) {
813
+ const maskedIp = ip || 'unknown';
814
+ return `<!DOCTYPE html>
815
+ <html lang="en">
816
+ <head>
817
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
818
+ <title>Access Blocked - Security Alert</title>
819
+ <style>
820
+ *{margin:0;padding:0;box-sizing:border-box}
821
+ body{min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5}
822
+ .wrap{text-align:center;max-width:540px;padding:2.5rem 2rem}
823
+ .icon{width:64px;height:64px;margin:0 auto 1.5rem;border-radius:50%;background:rgba(220,38,38,.12);display:flex;align-items:center;justify-content:center}
824
+ .icon svg{width:32px;height:32px;fill:none;stroke:#dc2626;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
825
+ .badge{display:inline-block;padding:.25rem .75rem;border-radius:999px;background:rgba(220,38,38,.15);color:#f87171;font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-bottom:1.25rem}
826
+ h1{font-size:1.6rem;font-weight:700;margin-bottom:.6rem;color:#fff}
827
+ .sub{font-size:1rem;color:#f87171;font-weight:600;margin-bottom:1.25rem}
828
+ p{font-size:.9rem;line-height:1.7;color:#a1a1aa;margin-bottom:.5rem}
829
+ .ip-box{display:inline-block;margin:1rem 0;padding:.6rem 1.5rem;border-radius:8px;background:rgba(255,255,255,.04);border:1px solid rgba(220,38,38,.25);font-family:"SF Mono",SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace;font-size:.95rem;color:#fca5a5;letter-spacing:.03em}
830
+ .divider{width:48px;height:2px;background:rgba(220,38,38,.3);margin:1.5rem auto}
831
+ .contact{font-size:.85rem;color:#71717a;line-height:1.7}
832
+ .contact a{color:#f87171;text-decoration:none;font-weight:500}
833
+ .contact a:hover{text-decoration:underline}
834
+ .footer{margin-top:2rem;font-size:.7rem;color:#3f3f46}
835
+ .powered{margin-top:1.5rem;font-size:.75rem;color:#52525b}.powered a{color:#a1a1aa;text-decoration:none;font-weight:600;transition:color .2s}.powered a:hover{color:#f87171;text-decoration:underline}
836
+ </style>
837
+ </head>
838
+ <body>
839
+ <div class="wrap">
840
+ <div class="icon"><svg viewBox="0 0 24 24"><path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg></div>
841
+ <span class="badge">Security Alert</span>
842
+ <h1>Access Blocked</h1>
843
+ <p class="sub">Malicious activity detected from your IP address</p>
844
+ <p>Your IP has been flagged and blocked due to suspicious or malicious behavior originating from your network. Our security team has been notified and is actively investigating.</p>
845
+ <div class="ip-box">${maskedIp}</div>
846
+ <div class="divider"></div>
847
+ <p class="contact">If you believe this is a mistake, please contact us at<br><a href="mailto:contact@securenow.ai?subject=Blocked%20IP%20Appeal%20-%20${encodeURIComponent(maskedIp)}&body=IP:%20${encodeURIComponent(maskedIp)}%0ATimestamp:%20${encodeURIComponent(new Date().toISOString())}%0A%0APlease%20describe%20why%20you%20believe%20this%20block%20is%20incorrect:">contact@securenow.ai</a><br>Include your IP address and the time of this incident.</p>
848
+ <p class="footer">Ref: ${maskedIp} &mdash; ${new Date().toISOString()} &mdash; HTTP 403</p>
849
+ <p class="powered">Protected by <a href="https://securenow.ai" rel="dofollow" target="_blank">SecureNow</a></p>
850
+ </div>
851
+ </body>
852
+ </html>`;
853
+ }
854
+
855
+ function wrapListener(originalListener) {
856
+ return function firewallGuard(req, res) {
857
+ _stats.allowed++;
858
+ return originalListener.call(this, req, res);
859
+ };
860
+ }
861
+
862
+ function sendBlockResponse(req, res, ip) {
863
+ const code = (_options && _options.statusCode) || 403;
864
+ const accept = req.headers['accept'] || '';
865
+ if (accept.includes('text/html')) {
866
+ res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8' });
867
+ res.end(blockedHtml(ip));
868
+ } else {
869
+ res.writeHead(code, { 'Content-Type': 'application/json' });
870
+ res.end(JSON.stringify({ error: 'Forbidden', ip }));
871
+ }
872
+ }
873
+
874
+ function sendRateLimitResponse(req, res, ip, decision) {
875
+ const retryAfter = Math.max(1, decision.retryAfter || 1);
876
+ const headers = {
877
+ 'Retry-After': String(retryAfter),
878
+ 'X-RateLimit-Limit': String(decision.limit || ''),
879
+ 'X-RateLimit-Window': String(decision.windowSeconds || ''),
880
+ 'X-SecureNow-Rate-Limit-Rule': decision.ruleId || '',
881
+ };
882
+ const accept = req.headers['accept'] || '';
883
+ if (accept.includes('text/html')) {
884
+ res.writeHead(429, { ...headers, 'Content-Type': 'text/html; charset=utf-8' });
885
+ res.end('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Too Many Requests</title></head><body><h1>Too Many Requests</h1><p>Please retry later.</p></body></html>');
886
+ } else {
887
+ res.writeHead(429, { ...headers, 'Content-Type': 'application/json' });
888
+ res.end(JSON.stringify({
889
+ error: 'Too Many Requests',
890
+ ip,
891
+ retryAfter,
892
+ }));
893
+ }
894
+ }
895
+
896
+ function requestPath(req) {
897
+ try {
898
+ return new URL(req.url || '/', 'http://localhost').pathname || '/';
899
+ } catch (_) {
900
+ return req.url || '/';
901
+ }
902
+ }
903
+
904
+ function ruleIpMatches(rule, ip) {
905
+ const target = String(rule.ip || '').trim();
906
+ if (!target) return true;
907
+ try {
908
+ return createMatcher([target]).isBlocked(ip);
909
+ } catch (_) {
910
+ return false;
911
+ }
912
+ }
913
+
914
+ function rulePathMatches(rule, path) {
915
+ const pattern = String(rule.pathPattern || '').trim();
916
+ if (!pattern) return true;
917
+ const mode = rule.pathMatchMode || 'prefix';
918
+ if (mode === 'exact') return path === pattern;
919
+ if (mode === 'regex') {
920
+ try { return new RegExp(pattern).test(path); } catch (_) { return false; }
921
+ }
922
+ return path.startsWith(normalizePrefixPattern(pattern));
923
+ }
924
+
925
+ function rateLimitKey(rule, ip) {
926
+ const id = rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`;
927
+ const subject = rule.keyBy === 'global' ? 'global' : ip;
928
+ return `${id}|${subject}`;
929
+ }
930
+
931
+ function checkBlocklistRules(req, ip) {
932
+ if (!_blocklistRules || _blocklistRules.length === 0) return null;
933
+ const method = String(req.method || 'GET').toUpperCase();
934
+ const path = requestPath(req);
935
+
936
+ for (const rule of _blocklistRules) {
937
+ if (!rule) continue;
938
+ const ruleMethod = String(rule.method || 'ALL').toUpperCase();
939
+ if (ruleMethod !== 'ALL' && ruleMethod !== method) continue;
940
+ if (!ruleIpMatches(rule, ip)) continue;
941
+ if (!rulePathMatches(rule, path)) continue;
942
+ return {
943
+ rule,
944
+ ruleId: rule.id || rule._id || '',
945
+ matchedEntry: rule.ip || ip,
946
+ path,
947
+ };
948
+ }
949
+
950
+ return null;
951
+ }
952
+
953
+ function checkRateLimitRules(req, ip) {
954
+ if (!_rateLimitRules || _rateLimitRules.length === 0) return null;
955
+ const method = String(req.method || 'GET').toUpperCase();
956
+ const path = requestPath(req);
957
+ const now = Date.now();
958
+
959
+ for (const rule of _rateLimitRules) {
960
+ if (!rule) continue;
961
+ const ruleMethod = String(rule.method || 'ALL').toUpperCase();
962
+ if (ruleMethod !== 'ALL' && ruleMethod !== method) continue;
963
+ if (!ruleIpMatches(rule, ip)) continue;
964
+ if (!rulePathMatches(rule, path)) continue;
965
+
966
+ const limit = Math.max(1, Number(rule.limit || 1) || 1);
967
+ const windowSeconds = Math.max(1, Number(rule.windowSeconds || 60) || 60);
968
+ const windowMs = windowSeconds * 1000;
969
+ const key = rateLimitKey(rule, ip);
970
+ let bucket = _rateLimitBuckets.get(key);
971
+ if (!bucket || now - bucket.windowStart >= windowMs) {
972
+ bucket = { windowStart: now, count: 0 };
973
+ _rateLimitBuckets.set(key, bucket);
974
+ }
975
+
976
+ if (bucket.count >= limit) {
977
+ const retryAfter = Math.ceil((bucket.windowStart + windowMs - now) / 1000);
978
+ return {
979
+ rule,
980
+ ruleId: rule.id || rule._id || '',
981
+ limit,
982
+ windowSeconds,
983
+ retryAfter,
984
+ path,
985
+ };
986
+ }
987
+ bucket.count++;
988
+ }
989
+
990
+ return null;
991
+ }
992
+
993
+ function pruneRateLimitBuckets(rules) {
994
+ const ids = new Set((rules || []).map((rule) => String(rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`)));
995
+ for (const key of _rateLimitBuckets.keys()) {
996
+ const idx = key.indexOf('|');
997
+ const ruleKey = idx === -1 ? key : key.slice(0, idx);
998
+ if (!ids.has(ruleKey)) _rateLimitBuckets.delete(key);
999
+ }
1000
+ }
1001
+
1002
+ // ── Challenge (CAPTCHA / proof-of-work) enforcement ──
1003
+
1004
+ const CHALLENGE_INTERNAL_PATH = '/__securenow/challenge';
1005
+
1006
+ function isSecureRequest(req) {
1007
+ if (req.socket && req.socket.encrypted) return true;
1008
+ const xfp = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
1009
+ return xfp === 'https';
1010
+ }
1011
+
1012
+ function challengeDifficulty(rule) {
1013
+ const d = Number(rule && rule.difficulty);
1014
+ if (Number.isFinite(d) && d >= 4 && d <= 28) return d;
1015
+ const cfg = Number(_challengeConfig && _challengeConfig.difficulty);
1016
+ if (Number.isFinite(cfg) && cfg >= 4) return cfg;
1017
+ return challenge.DEFAULTS.difficulty;
1018
+ }
1019
+
1020
+ function findChallengeRuleById(rid) {
1021
+ if (!rid || rid === '*') return null;
1022
+ for (const rule of _challengeRules) {
1023
+ if (String(rule.id || rule._id || '') === String(rid)) return rule;
1024
+ }
1025
+ return null;
1026
+ }
1027
+
1028
+ function challengeClearanceTtl(rule) {
1029
+ const t = Number(rule && rule.clearanceTtlSeconds);
1030
+ if (Number.isFinite(t) && t >= 30) return t;
1031
+ const cfg = Number(_challengeConfig && _challengeConfig.clearanceTtl);
1032
+ if (Number.isFinite(cfg) && cfg >= 30) return cfg;
1033
+ return challenge.DEFAULTS.clearanceTtl;
1034
+ }
1035
+
1036
+ function checkChallengeRules(req, ip) {
1037
+ if (!_challengeRules || _challengeRules.length === 0) return null;
1038
+ if (!_challengeConfig || !_challengeConfig.secret) return null;
1039
+ const method = String(req.method || 'GET').toUpperCase();
1040
+ const path = requestPath(req);
1041
+ for (const rule of _challengeRules) {
1042
+ if (!rule) continue;
1043
+ const ruleMethod = String(rule.method || 'ALL').toUpperCase();
1044
+ if (ruleMethod !== 'ALL' && ruleMethod !== method) continue;
1045
+ if (!ruleIpMatches(rule, ip)) continue;
1046
+ if (!rulePathMatches(rule, path)) continue;
1047
+ return { rule, ruleId: rule.id || rule._id || '', path };
1048
+ }
1049
+ return null;
1050
+ }
1051
+
1052
+ function hasValidClearance(req, ip) {
1053
+ const secret = _challengeConfig && _challengeConfig.secret;
1054
+ if (!secret) return false;
1055
+ const cookie = challenge.readClearanceCookie(req);
1056
+ if (!cookie) return false;
1057
+ const ua = req.headers['user-agent'] || '';
1058
+ return challenge.verifyClearance(secret, { cookie, ip, ua }).ok;
1059
+ }
1060
+
1061
+ function buildClearanceCookie(value, maxAge, secure) {
1062
+ let cookie = `${challenge.CLEARANCE_COOKIE}=${value}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Lax`;
1063
+ if (secure) cookie += '; Secure';
1064
+ return cookie;
1065
+ }
1066
+
1067
+ function readJsonBody(req, maxBytes, cb) {
1068
+ let size = 0;
1069
+ const chunks = [];
1070
+ let done = false;
1071
+ const finish = (err, val) => { if (done) return; done = true; cb(err, val); };
1072
+ req.on('data', (chunk) => {
1073
+ size += chunk.length;
1074
+ if (size > maxBytes) {
1075
+ finish(new Error('too_large'));
1076
+ try { req.destroy(); } catch (_) {}
1077
+ return;
1078
+ }
1079
+ chunks.push(chunk);
1080
+ });
1081
+ req.on('end', () => {
1082
+ if (done) return;
1083
+ try { finish(null, JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); }
1084
+ catch (e) { finish(e); }
1085
+ });
1086
+ req.on('error', (e) => finish(e));
1087
+ }
1088
+
1089
+ function noteChallengeFail(ip) {
1090
+ if (_challengeFails.size > 50_000) _challengeFails.clear();
1091
+ const n = (_challengeFails.get(ip) || 0) + 1;
1092
+ _challengeFails.set(ip, n);
1093
+ return n;
1094
+ }
1095
+
1096
+ // Serve the proof-of-work interstitial (or a JSON challenge for API clients).
1097
+ function sendChallengeResponse(req, res, ip, decision) {
1098
+ const secret = _challengeConfig.secret;
1099
+ const rule = decision.rule || {};
1100
+ const difficulty = challengeDifficulty(rule);
1101
+ const rid = decision.ruleId || '*';
1102
+ const challengeTtl = Number(_challengeConfig.challengeTtl) || challenge.DEFAULTS.challengeTtl;
1103
+ const token = challenge.signChallenge(secret, { ip, difficulty, rid, challengeTtl });
1104
+ const baseHeaders = { 'X-SecureNow-Challenge': 'required', 'Cache-Control': 'no-store' };
1105
+
1106
+ reportFirewallEvent({
1107
+ action: 'captcha_challenged',
1108
+ source: 'challenge',
1109
+ statusCode: 403,
1110
+ ip,
1111
+ matchedEntry: rid,
1112
+ method: req.method || '',
1113
+ path: decision.path || req.url || '',
1114
+ userAgent: req.headers['user-agent'] || '',
1115
+ });
1116
+
1117
+ const accept = req.headers['accept'] || '';
1118
+ if (accept.includes('text/html')) {
1119
+ res.writeHead(403, { ...baseHeaders, 'Content-Type': 'text/html; charset=utf-8' });
1120
+ res.end(challenge.challengeHtml({
1121
+ token,
1122
+ difficulty,
1123
+ submitPath: CHALLENGE_INTERNAL_PATH,
1124
+ returnPath: req.url || '/',
1125
+ ip,
1126
+ }));
1127
+ } else {
1128
+ res.writeHead(403, { ...baseHeaders, 'Content-Type': 'application/json' });
1129
+ res.end(JSON.stringify({
1130
+ error: 'challenge_required',
1131
+ challenge: { token, alg: challenge.ALG_POW, difficulty, submit: CHALLENGE_INTERNAL_PATH },
1132
+ }));
1133
+ }
1134
+ }
1135
+
1136
+ // Handle the internal solve endpoint (POST /__securenow/challenge). Verifies
1137
+ // the proof-of-work — locally with the per-app secret, or by proxying to the
1138
+ // API — then issues a clearance cookie on success.
1139
+ function handleChallengeInternal(req, res, ip) {
1140
+ if (String(req.method || '').toUpperCase() !== 'POST') {
1141
+ res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' });
1142
+ res.end(JSON.stringify({ ok: false, reason: 'method_not_allowed' }));
1143
+ return true;
1144
+ }
1145
+
1146
+ const secret = _challengeConfig && _challengeConfig.secret;
1147
+ const verifyMode = (_challengeConfig && _challengeConfig.verify) || 'auto';
1148
+
1149
+ readJsonBody(req, 8192, (err, body) => {
1150
+ if (err || !body || !body.token) {
1151
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1152
+ res.end(JSON.stringify({ ok: false, reason: 'bad_request' }));
1153
+ return;
1154
+ }
1155
+ const ua = req.headers['user-agent'] || '';
1156
+ const rid = body.rid || '*';
1157
+ const clearanceTtl = challengeClearanceTtl(findChallengeRuleById(rid));
1158
+ const useProxy = verifyMode === 'proxy' || !secret;
1159
+
1160
+ const onPass = (value, maxAge) => {
1161
+ _challengeFails.delete(ip);
1162
+ res.setHeader('Set-Cookie', buildClearanceCookie(value, maxAge || clearanceTtl, isSecureRequest(req)));
1163
+ res.writeHead(204);
1164
+ res.end();
1165
+ reportFirewallEvent({
1166
+ action: 'captcha_passed', source: 'challenge', statusCode: 204,
1167
+ ip, matchedEntry: rid, method: 'POST', path: CHALLENGE_INTERNAL_PATH, userAgent: ua,
1168
+ });
1169
+ };
1170
+ const onFail = (reason) => {
1171
+ const n = noteChallengeFail(ip);
1172
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1173
+ res.end(JSON.stringify({ ok: false, reason: reason || 'verification_failed' }));
1174
+ reportFirewallEvent({
1175
+ action: 'captcha_failed', source: 'challenge', statusCode: 400,
1176
+ ip, matchedEntry: String(n), method: 'POST', path: CHALLENGE_INTERNAL_PATH, userAgent: ua,
1177
+ });
1178
+ };
1179
+
1180
+ if (useProxy) {
1181
+ const url = buildFirewallUrl('/firewall/verify-challenge');
1182
+ httpPostJson(url, { token: body.token, nonce: body.nonce, ip, ua, rid, clearanceTtl }, 8000, (e, r, data) => {
1183
+ if (e || !r || r.statusCode !== 200) return onFail('proxy_unreachable');
1184
+ let parsed;
1185
+ try { parsed = JSON.parse(data); } catch (_) { return onFail('proxy_bad_response'); }
1186
+ if (!parsed || !parsed.ok || !parsed.clearance) return onFail(parsed && parsed.reason);
1187
+ onPass(parsed.clearance.value, parsed.clearance.maxAge);
1188
+ });
1189
+ } else {
1190
+ const result = challenge.verifyChallengeSolution(secret, { token: body.token, nonce: body.nonce, ip });
1191
+ if (!result.ok) return onFail(result.reason);
1192
+ const cl = challenge.issueClearance(secret, { ip, ua, rid, clearanceTtl });
1193
+ onPass(cl.value, cl.maxAge);
1194
+ }
1195
+ });
1196
+ return true;
1197
+ }
1198
+
1199
+ function firewallRequestHandler(req, res) {
1200
+ // Remote disable wins over everything: when the dashboard / CLI flips the
1201
+ // toggle off, requests pass through the SDK as if the firewall weren't
1202
+ // installed. The poll loop keeps running so we re-enable within seconds.
1203
+ if (_remoteEnabled === false) {
1204
+ _stats.suppressedDisabled++;
1205
+ return false;
1206
+ }
1207
+
1208
+ const ip = resolveClientIp(req);
1209
+
1210
+ // SecureNow-internal challenge solve endpoint. Handled before any IP rule
1211
+ // check so a challenged visitor can always submit their proof-of-work.
1212
+ if (requestPath(req) === CHALLENGE_INTERNAL_PATH) {
1213
+ return handleChallengeInternal(req, res, ip);
1214
+ }
1215
+
1216
+ // Allowlist check: if active, only listed IPs are allowed through
1217
+ if (_allowlistMatcher && _allowlistMatcher.stats().total > 0) {
1218
+ if (!_allowlistMatcher.isBlocked(ip)) {
1219
+ _stats.blocked++;
1220
+ if (_options && _options.log) fwLog('[securenow] Firewall: blocked %s via HTTP (not in allowlist)', ip);
1221
+ reportFirewallEvent({
1222
+ source: 'allowlist',
1223
+ ip,
1224
+ matchedEntry: '',
1225
+ method: req.method || '',
1226
+ path: req.url || '',
1227
+ userAgent: req.headers['user-agent'] || '',
1228
+ });
1229
+ sendBlockResponse(req, res, ip);
1230
+ return true;
1231
+ }
1232
+ return false;
1233
+ }
1234
+
1235
+ // Blocklist check
1236
+ if (_matcher && _matcher.isBlocked(ip)) {
1237
+ _stats.blocked++;
1238
+ if (_options && _options.log) fwLog('[securenow] Firewall: blocked %s via HTTP', ip);
1239
+ reportFirewallEvent({
1240
+ source: 'blocklist',
1241
+ ip,
1242
+ matchedEntry: ip,
1243
+ method: req.method || '',
1244
+ path: req.url || '',
1245
+ userAgent: req.headers['user-agent'] || '',
1246
+ });
1247
+ sendBlockResponse(req, res, ip);
1248
+ return true;
1249
+ }
1250
+
1251
+ const blockRuleDecision = checkBlocklistRules(req, ip);
1252
+ if (blockRuleDecision) {
1253
+ _stats.blocked++;
1254
+ if (_options && _options.log) {
1255
+ fwLog('[securenow] Firewall: blocked %s via HTTP (rule=%s)', ip, blockRuleDecision.ruleId || 'scoped');
1256
+ }
1257
+ reportFirewallEvent({
1258
+ source: 'blocklist',
1259
+ ip,
1260
+ matchedEntry: blockRuleDecision.matchedEntry || ip,
1261
+ method: req.method || '',
1262
+ path: blockRuleDecision.path || req.url || '',
1263
+ userAgent: req.headers['user-agent'] || '',
1264
+ });
1265
+ sendBlockResponse(req, res, ip);
1266
+ return true;
1267
+ }
1268
+
1269
+ // Challenge check sits between hard-block and rate-limit: a matched-but-
1270
+ // uncleared visitor gets the interstitial (and does not consume a rate-limit
1271
+ // bucket); once they hold a valid clearance they fall through to rate-limit.
1272
+ const challengeDecision = checkChallengeRules(req, ip);
1273
+ if (challengeDecision && !hasValidClearance(req, ip)) {
1274
+ _stats.challenged = (_stats.challenged || 0) + 1;
1275
+ if (_options && _options.log) {
1276
+ fwLog('[securenow] Firewall: challenged %s via HTTP (rule=%s)', ip, challengeDecision.ruleId || 'scoped');
1277
+ }
1278
+ sendChallengeResponse(req, res, ip, challengeDecision);
1279
+ return true;
1280
+ }
1281
+
1282
+ const rateLimitDecision = checkRateLimitRules(req, ip);
1283
+ if (rateLimitDecision) {
1284
+ _stats.rateLimited++;
1285
+ if (_options && _options.log) {
1286
+ fwLog('[securenow] Firewall: rate-limited %s via HTTP (rule=%s)', ip, rateLimitDecision.ruleId || 'unknown');
1287
+ }
1288
+ reportFirewallEvent({
1289
+ action: 'rate_limited',
1290
+ source: 'rate_limit',
1291
+ statusCode: 429,
1292
+ ip,
1293
+ matchedEntry: rateLimitDecision.ruleId || '',
1294
+ method: req.method || '',
1295
+ path: rateLimitDecision.path || req.url || '',
1296
+ userAgent: req.headers['user-agent'] || '',
1297
+ });
1298
+ sendRateLimitResponse(req, res, ip, rateLimitDecision);
1299
+ return true;
1300
+ }
1301
+
1302
+ return false;
1303
+ }
1304
+
1305
+ const _origEmit = http.Server.prototype.emit;
1306
+ let _emitPatched = false;
1307
+
1308
+ function patchEmitLayer() {
1309
+ if (_emitPatched) return;
1310
+ _emitPatched = true;
1311
+
1312
+ http.Server.prototype.emit = function(event, req, res, ...rest) {
1313
+ if (event === 'request' && req && res && !res.headersSent) {
1314
+ if (firewallRequestHandler(req, res)) return true;
1315
+ }
1316
+ return _origEmit.call(this, event, req, res, ...rest);
1317
+ };
1318
+ }
1319
+
1320
+ function patchHttpLayer() {
1321
+ if (_httpPatched) return;
1322
+ _httpPatched = true;
1323
+
1324
+ patchEmitLayer();
1325
+
1326
+ http.createServer = function(...args) {
1327
+ if (typeof args[args.length - 1] === 'function') {
1328
+ args[args.length - 1] = wrapListener(args[args.length - 1]);
1329
+ }
1330
+ return _origHttpCreate.apply(this, args);
1331
+ };
1332
+ Object.assign(http.createServer, _origHttpCreate);
1333
+
1334
+ https.createServer = function(...args) {
1335
+ if (typeof args[args.length - 1] === 'function') {
1336
+ args[args.length - 1] = wrapListener(args[args.length - 1]);
1337
+ }
1338
+ return _origHttpsCreate.apply(this, args);
1339
+ };
1340
+ Object.assign(https.createServer, _origHttpsCreate);
1341
+ }
1342
+
1343
+ // Init
1344
+
1345
+ function init(options) {
1346
+ _options = options || {};
1347
+ _options.apiUrl = String(_options.apiUrl || '').trim().replace(/\/$/, '');
1348
+ _localhostFallbackTried = false;
1349
+ _useUnifiedSync = true;
1350
+ resetApiUrlFallbacks();
1351
+
1352
+ if (_options.log) fwLog('[securenow] Firewall: ENABLED');
1353
+ if (_options.log && _options.apiUrl) fwLog('[securenow] Firewall: sync endpoint=%s/api/v1/firewall/sync', _options.apiUrl);
1354
+ if (_options.log && _options.environment) fwLog('[securenow] Firewall: environment=%s', _options.environment);
1355
+
1356
+ patchHttpLayer();
1357
+ if (_options.log) fwLog('[securenow] Firewall: Layer 1 (HTTP 403) active');
1358
+
1359
+ if (_options.tcp) {
1360
+ try {
1361
+ const tcpLayer = require('./firewall-tcp');
1362
+ tcpLayer.init(() => _matcher, _options, () => _allowlistMatcher);
1363
+ _layers.push(tcpLayer);
1364
+ if (_options.log) fwLog('[securenow] Firewall: Layer 2 (TCP drop) active');
1365
+ } catch (e) {
1366
+ if (_options.log) fwWarn('[securenow] Firewall: Layer 2 (TCP drop) failed:', e.message);
1367
+ }
1368
+ } else {
1369
+ if (_options.log) fwLog('[securenow] Firewall: Layer 2 (TCP drop) disabled (set config.firewall.tcp=true)');
1370
+ }
1371
+
1372
+ if (_options.iptables) {
1373
+ try {
1374
+ const iptablesLayer = require('./firewall-iptables');
1375
+ iptablesLayer.init(_options);
1376
+ _layers.push(iptablesLayer);
1377
+ if (_options.log) fwLog('[securenow] Firewall: Layer 3 (iptables) active');
1378
+ } catch (e) {
1379
+ if (_options.log) fwWarn('[securenow] Firewall: Layer 3 (iptables) failed:', e.message);
1380
+ }
1381
+ } else {
1382
+ if (_options.log) fwLog('[securenow] Firewall: Layer 3 (iptables) disabled (set config.firewall.iptables=true)');
1383
+ }
1384
+
1385
+ if (_options.cloud) {
1386
+ try {
1387
+ const cloudLayer = require('./firewall-cloud');
1388
+ cloudLayer.init(_options);
1389
+ _layers.push(cloudLayer);
1390
+ if (_options.log) fwLog('[securenow] Firewall: Layer 4 (Cloud WAF) active (%s)', _options.cloud);
1391
+ } catch (e) {
1392
+ if (_options.log) fwWarn('[securenow] Firewall: Layer 4 (Cloud WAF) failed:', e.message);
1393
+ }
1394
+ } else {
1395
+ if (_options.log) fwLog('[securenow] Firewall: Layer 4 (Cloud WAF) disabled (set config.firewall.cloud=cloudflare|aws|gcp)');
1396
+ }
1397
+
1398
+ startSyncLoop();
1399
+ }
1400
+
1401
+ function shutdown() {
1402
+ if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
1403
+ if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
1404
+ if (_eventTimer) { clearTimeout(_eventTimer); _eventTimer = null; }
1405
+ flushFirewallEvents();
1406
+
1407
+ _circuitState = 'closed';
1408
+ _circuitOpenedAt = 0;
1409
+ _consecutiveErrors = 0;
1410
+ _pollInflight = false;
1411
+ _retryAfterUntil = 0;
1412
+ _localhostFallbackTried = false;
1413
+ _blocklistRules = [];
1414
+ _rateLimitRules = [];
1415
+ _rateLimitBuckets = new Map();
1416
+ _challengeRules = [];
1417
+ _challengeConfig = null;
1418
+ _lastChallengeVersion = null;
1419
+ _challengeFails = new Map();
1420
+ _remainingApiUrlFallbacks = [];
1421
+
1422
+ _httpAgent.destroy();
1423
+ _httpsAgent.destroy();
1424
+
1425
+ for (const layer of _layers) {
1426
+ try { if (typeof layer.shutdown === 'function') layer.shutdown(); } catch (_) {}
1427
+ }
1428
+ _layers = [];
1429
+
1430
+ if (_emitPatched) {
1431
+ http.Server.prototype.emit = _origEmit;
1432
+ _emitPatched = false;
1433
+ }
1434
+ if (_httpPatched) {
1435
+ http.createServer = _origHttpCreate;
1436
+ https.createServer = _origHttpsCreate;
1437
+ _httpPatched = false;
1438
+ }
1439
+ }
1440
+
1441
+ function getStats() {
1442
+ return {
1443
+ ..._stats,
1444
+ matcher: _matcher ? _matcher.stats() : null,
1445
+ blocklistRules: _blocklistRules.length,
1446
+ allowlistMatcher: _allowlistMatcher ? _allowlistMatcher.stats() : null,
1447
+ rateLimitRules: _rateLimitRules.length,
1448
+ rateLimitBuckets: _rateLimitBuckets.size,
1449
+ initialized: _initialized,
1450
+ circuitState: _circuitState,
1451
+ consecutiveErrors: _consecutiveErrors,
1452
+ unifiedSync: _useUnifiedSync,
1453
+ remoteEnabled: _remoteEnabled,
1454
+ appKey: _options ? _options.appKey || null : null,
1455
+ environment: _options ? _options.environment || null : null,
1456
+ };
1457
+ }
1458
+
1459
+ // Layers (TCP / iptables / cloud) read the matcher to populate kernel-level
1460
+ // rules. When the remote toggle is off, return null so they treat the policy
1461
+ // as "no IPs to block" without us mutating the cached matcher.
1462
+ function getMatcher() { return _remoteEnabled === false ? null : _matcher; }
1463
+ function getAllowlistMatcher() { return _remoteEnabled === false ? null : _allowlistMatcher; }
1464
+ function getBlocklistRules() { return _remoteEnabled === false ? [] : _blocklistRules.slice(); }
1465
+ function getRateLimitRules() { return _remoteEnabled === false ? [] : _rateLimitRules.slice(); }
1466
+ function isRemoteEnabled() { return _remoteEnabled !== false; }
1467
+
1468
+ function getChallengeRules() { return _challengeRules; }
1469
+
1470
+ module.exports = { init, shutdown, getStats, getMatcher, getAllowlistMatcher, getBlocklistRules, getRateLimitRules, getChallengeRules, isRemoteEnabled };