securenow 8.5.0 → 8.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/firewall.js CHANGED
@@ -4,27 +4,28 @@ const http = require('http');
4
4
  const https = require('https');
5
5
  const { createMatcher } = require('./cidr');
6
6
  const { resolveClientIp } = require('./resolve-ip');
7
+ const challenge = require('./challenge');
7
8
 
8
9
  let _options = null;
9
10
  let _matcher = null;
10
11
  let _syncTimer = null;
11
12
  let _pollTimer = null;
12
13
  let _lastModified = null;
13
- let _lastVersion = null;
14
- let _lastSyncEtag = null;
15
- let _lastUnifiedEtag = null;
16
- let _initialized = false;
14
+ let _lastVersion = null;
15
+ let _lastSyncEtag = null;
16
+ let _lastUnifiedEtag = null;
17
+ let _initialized = false;
17
18
  let _consecutiveErrors = 0;
18
19
  let _layers = [];
19
- let _rawIps = [];
20
- let _blocklistRules = [];
21
- let _stats = { syncs: 0, blocked: 0, rateLimited: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
22
- let _localhostFallbackTried = false;
23
- let _eventQueue = [];
24
- let _eventTimer = null;
25
- let _remainingApiUrlFallbacks = [];
26
-
27
- // Remote toggle - set by /firewall/sync when an appKey is in scope. Default
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
28
29
  // true so a missing/unreachable backend fails open (matches pre-7.3 behavior).
29
30
  // When the dashboard / CLI flips this off, the next poll suppresses
30
31
  // enforcement without restarting the host process.
@@ -32,17 +33,27 @@ let _remoteEnabled = true;
32
33
  let _lastRemoteEnabled = null;
33
34
 
34
35
  // Allowlist state
35
- let _allowlistMatcher = null;
36
- let _allowlistRawIps = [];
37
- let _lastAllowlistModified = null;
38
- let _lastAllowlistVersion = null;
39
- let _lastAllowlistSyncEtag = null;
40
-
41
- // Route/method-scoped policy state. Global IP/CIDR blocks stay in _matcher so
42
- // TCP, OS firewall, and Cloud WAF layers never over-enforce an HTTP-only rule.
43
- let _rateLimitRules = [];
44
- let _lastRateLimitVersion = null;
45
- let _rateLimitBuckets = new Map();
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();
46
57
 
47
58
  // Circuit breaker
48
59
  const CIRCUIT_OPEN_THRESHOLD = 5;
@@ -54,40 +65,40 @@ let _circuitOpenedAt = 0;
54
65
  let _pollInflight = false;
55
66
  let _retryAfterUntil = 0;
56
67
 
57
- // Firewall control-plane traffic is low-frequency and correctness-critical.
58
- // Use non-keep-alive agents so clustered apps do not reuse sockets that an
59
- // upstream load balancer has silently closed between sync cycles.
60
- const _httpAgent = new http.Agent({ keepAlive: false });
61
- const _httpsAgent = new https.Agent({ keepAlive: false });
62
- const EVENT_FLUSH_INTERVAL_MS = 2_000;
63
- const EVENT_BATCH_SIZE = 25;
64
- const EVENT_QUEUE_MAX = 1_000;
65
- const TRANSIENT_NETWORK_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND']);
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']);
66
77
 
67
78
  // Unified sync uses /firewall/sync (v2). Falls back to legacy on 404.
68
- let _useUnifiedSync = true;
69
-
70
- function originalConsoleMethod(method) {
71
- const originals = console.__securenow_original || console.__securenowOriginalConsole;
72
- return (originals && originals[method]) || console[method] || console.log;
73
- }
74
-
75
- function firewallConsole(method, ...args) {
76
- if (!_options || !_options.log) return;
77
- try {
78
- originalConsoleMethod(method).apply(console, args);
79
- } catch (_) {}
80
- }
81
-
82
- function fwLog(...args) {
83
- firewallConsole('log', ...args);
84
- }
85
-
86
- function fwWarn(...args) {
87
- firewallConsole('warn', ...args);
88
- }
89
-
90
- // Circuit Breaker
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
91
102
 
92
103
  function maybeOpenCircuit() {
93
104
  if (_circuitState === 'open') return;
@@ -95,7 +106,7 @@ function maybeOpenCircuit() {
95
106
  _circuitState = 'open';
96
107
  _circuitOpenedAt = Date.now();
97
108
  if (_options && _options.log) {
98
- fwWarn('[securenow] Firewall: circuit breaker OPEN - pausing polling for %ds', CIRCUIT_OPEN_COOLDOWN_MS / 1000);
109
+ fwWarn('[securenow] Firewall: circuit breaker OPEN - pausing polling for %ds', CIRCUIT_OPEN_COOLDOWN_MS / 1000);
99
110
  }
100
111
  }
101
112
  }
@@ -103,7 +114,7 @@ function maybeOpenCircuit() {
103
114
  function resetCircuit() {
104
115
  if (_circuitState !== 'closed') {
105
116
  _circuitState = 'closed';
106
- if (_options && _options.log) fwLog('[securenow] Firewall: circuit breaker CLOSED - API healthy');
117
+ if (_options && _options.log) fwLog('[securenow] Firewall: circuit breaker CLOSED - API healthy');
107
118
  }
108
119
  _consecutiveErrors = 0;
109
120
  }
@@ -133,255 +144,256 @@ function handleRetryAfter(res) {
133
144
  }
134
145
  }
135
146
 
136
- // HTTP helpers
147
+ // HTTP helpers
137
148
 
138
- function buildUrl(apiUrl, path) {
139
- return apiUrl.replace(/\/+$/, '') + '/api/v1' + path;
140
- }
141
-
142
- function buildFirewallUrl(path) {
143
- const query = new URLSearchParams();
144
- if (_options && _options.appKey) query.set('app', _options.appKey);
145
- if (_options && _options.environment) query.set('env', _options.environment);
146
- const suffix = query.toString() ? `?${query.toString()}` : '';
147
- return buildUrl(_options.apiUrl, path) + suffix;
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
+ }
149
160
 
150
161
  function jitter(baseMs) {
151
162
  return baseMs * (0.8 + Math.random() * 0.4);
152
163
  }
153
164
 
154
- function agentFor(url) {
155
- return url.startsWith('https') ? _httpsAgent : _httpAgent;
156
- }
157
-
158
- function isTransientNetworkError(err) {
159
- if (!err) return false;
160
- if (err.code && TRANSIENT_NETWORK_CODES.has(err.code)) return true;
161
- return /socket hang up|connection reset|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|timed out/i.test(String(err.message || ''));
162
- }
163
-
164
- function isApiReachabilityError(err) {
165
- if (isTransientNetworkError(err)) return true;
166
- const text = `${err && err.code || ''} ${err && err.message || ''}`;
167
- return /TLS|SSL|certificate|CERT_|UNABLE_TO_VERIFY|self signed/i.test(text);
168
- }
169
-
170
- function formatRequestError(err) {
171
- if (!err) return 'unknown error';
172
- const parts = [err.message || String(err)];
173
- if (err.code && !parts[0].includes(err.code)) parts.push(`code=${err.code}`);
174
- if (err.syscall) parts.push(`syscall=${err.syscall}`);
175
- return parts.join(' ');
176
- }
177
-
178
- function resetApiUrlFallbacks() {
179
- const seen = new Set([_options && _options.apiUrl].filter(Boolean));
180
- _remainingApiUrlFallbacks = [];
181
- for (const candidate of Array.isArray(_options && _options.apiUrlFallbacks) ? _options.apiUrlFallbacks : []) {
182
- const url = String(candidate || '').trim().replace(/\/$/, '');
183
- if (!url || seen.has(url)) continue;
184
- seen.add(url);
185
- _remainingApiUrlFallbacks.push(url);
186
- }
187
- }
188
-
189
- function switchToNextApiUrl(reason) {
190
- if (!_options || _remainingApiUrlFallbacks.length === 0) return false;
191
- const previous = _options.apiUrl;
192
- _options.apiUrl = _remainingApiUrlFallbacks.shift();
193
- _lastUnifiedEtag = null;
194
- _lastSyncEtag = null;
195
- _lastAllowlistSyncEtag = null;
196
- if (_options.log) {
197
- fwWarn('[securenow] Firewall: %s unreachable (%s), retrying sync via %s',
198
- previous,
199
- reason || 'network error',
200
- _options.apiUrl);
201
- }
202
- return true;
203
- }
204
-
205
- function requestOnce(method, url, body, extraHeaders, timeout, callback) {
206
- const mod = url.startsWith('https') ? https : http;
207
- const parsed = new URL(url);
208
- const payload = body == null ? null : JSON.stringify(body || {});
209
-
210
- const req = mod.request({
211
- hostname: parsed.hostname,
212
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
213
- path: parsed.pathname + parsed.search,
214
- method,
215
- headers: {
216
- 'Authorization': `Bearer ${_options.apiKey}`,
217
- 'User-Agent': 'securenow-firewall-sdk',
218
- 'Connection': 'close',
219
- ...(payload
220
- ? {
221
- 'Content-Type': 'application/json',
222
- 'Content-Length': Buffer.byteLength(payload),
223
- }
224
- : {}),
225
- ...extraHeaders,
226
- },
227
- timeout,
228
- agent: agentFor(url),
229
- }, (res) => {
230
- let data = '';
231
- res.on('data', (chunk) => { data += chunk; });
232
- res.on('end', () => { done(null, res, data); });
233
- });
234
-
235
- let finished = false;
236
- function done(...args) {
237
- if (finished) return;
238
- finished = true;
239
- callback(...args);
240
- }
241
-
242
- req.on('error', (err) => done(err));
243
- req.on('timeout', () => {
244
- const err = new Error('Request timed out');
245
- err.code = 'ETIMEDOUT';
246
- done(err);
247
- req.destroy(err);
248
- });
249
- if (payload) req.write(payload);
250
- req.end();
251
- }
252
-
253
- function requestWithRetry(method, url, body, extraHeaders, timeout, callback) {
254
- requestOnce(method, url, body, extraHeaders, timeout, (err, res, data) => {
255
- if (!err || !isTransientNetworkError(err)) return callback(err, res, data);
256
-
257
- if (_options && _options.log) {
258
- fwWarn('[securenow] Firewall: transient API socket error, retrying once:', formatRequestError(err));
259
- }
260
-
261
- const retryTimer = setTimeout(() => {
262
- requestOnce(method, url, body, extraHeaders, timeout, callback);
263
- }, 100);
264
- if (retryTimer.unref) retryTimer.unref();
265
- });
266
- }
267
-
268
- function httpGet(url, extraHeaders, timeout, callback) {
269
- requestWithRetry('GET', url, null, extraHeaders, timeout, callback);
270
- }
271
-
272
- function httpPostJson(url, body, timeout, callback) {
273
- requestWithRetry('POST', url, body, {}, timeout, callback);
274
- }
275
-
276
- function scheduleEventFlush() {
277
- if (_eventTimer || _eventQueue.length === 0) return;
278
- _eventTimer = setTimeout(() => {
279
- _eventTimer = null;
280
- flushFirewallEvents();
281
- }, EVENT_FLUSH_INTERVAL_MS);
282
- if (_eventTimer.unref) _eventTimer.unref();
283
- }
284
-
285
- function flushFirewallEvents() {
286
- if (!_options || !_options.apiKey || !_options.apiUrl || _eventQueue.length === 0) return;
287
- if (shouldSkipRequest()) {
288
- _eventQueue = [];
289
- return;
290
- }
291
-
292
- const batch = _eventQueue.splice(0, EVENT_BATCH_SIZE);
293
- const url = buildFirewallUrl('/firewall/events');
294
-
295
- httpPostJson(url, { events: batch }, 5000, (err, res) => {
296
- if (err || !res || res.statusCode >= 400) {
297
- if (_options.log) {
298
- const msg = err ? formatRequestError(err) : `API returned ${res.statusCode}`;
299
- fwWarn('[securenow] Firewall: failed to report blocked-request ledger:', msg);
300
- }
301
- }
302
- if (_eventQueue.length) scheduleEventFlush();
303
- });
304
- }
305
-
306
- function reportFirewallEvent(event) {
307
- if (!_options || !_options.apiKey || !_options.apiUrl) return;
308
-
309
- const item = {
310
- applicationKey: _options.appKey || null,
311
- environment: _options.environment || 'production',
312
- action: 'blocked',
313
- statusCode: (_options && _options.statusCode) || 403,
314
- occurredAt: new Date().toISOString(),
315
- ...event,
316
- };
317
-
318
- _eventQueue.push(item);
319
- if (_eventQueue.length > EVENT_QUEUE_MAX) {
320
- _eventQueue.splice(0, _eventQueue.length - EVENT_QUEUE_MAX);
321
- }
322
-
323
- if (_eventQueue.length >= EVENT_BATCH_SIZE) flushFirewallEvents();
324
- else scheduleEventFlush();
325
- }
326
-
327
- function normalizePrefixPattern(pattern) {
328
- const value = String(pattern || '');
329
- return value.endsWith('*') ? value.slice(0, -1) : value;
330
- }
331
-
332
- function normalizeBlocklistRules(rules) {
333
- if (!Array.isArray(rules)) return [];
334
- return rules
335
- .map((rule) => {
336
- if (!rule || !rule.ip) return null;
337
- const pathMatchMode = ['exact', 'prefix', 'regex'].includes(String(rule.pathMatchMode || '').toLowerCase())
338
- ? String(rule.pathMatchMode).toLowerCase()
339
- : 'prefix';
340
- return {
341
- ...rule,
342
- ip: String(rule.ip || '').trim(),
343
- method: String(rule.method || 'ALL').toUpperCase(),
344
- pathPattern: pathMatchMode === 'prefix'
345
- ? normalizePrefixPattern(rule.pathPattern)
346
- : String(rule.pathPattern || '').trim(),
347
- pathMatchMode,
348
- };
349
- })
350
- .filter(Boolean);
351
- }
352
-
353
- function setBlocklistData(ips, rules) {
354
- const normalizedIps = Array.isArray(ips) ? ips : [];
355
- _rawIps = normalizedIps;
356
- _blocklistRules = normalizeBlocklistRules(rules);
357
- _matcher = createMatcher(normalizedIps);
358
- _stats.syncs++;
359
- notifyLayers(normalizedIps);
360
- }
361
-
362
- // Unified Sync (v2 - single request for everything)
363
-
364
- function doUnifiedSync(callback) {
365
- const query = new URLSearchParams();
366
- if (_options.appKey) query.set('app', _options.appKey);
367
- if (_options.environment) query.set('env', _options.environment);
368
- const suffix = query.toString() ? `?${query.toString()}` : '';
369
- const url = buildUrl(_options.apiUrl, '/firewall/sync') + suffix;
370
- const headers = {};
371
- if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
372
- if (_lastVersion) headers['X-Blocklist-Version'] = _lastVersion;
373
- if (_lastAllowlistVersion) headers['X-Allowlist-Version'] = _lastAllowlistVersion;
374
- if (_lastRateLimitVersion) headers['X-Rate-Limit-Version'] = _lastRateLimitVersion;
375
- if (_lastUnifiedEtag) headers['If-None-Match'] = _lastUnifiedEtag;
376
-
377
- httpGet(url, headers, 8000, (err, res, data) => {
378
- if (err) return callback(err);
379
-
380
- _stats.versionChecks++;
381
- if (res.headers && res.headers.etag) _lastUnifiedEtag = res.headers.etag;
382
-
383
- if (res.statusCode === 304) {
384
- return callback(null, { blChanged: false, alChanged: false });
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 });
385
397
  }
386
398
 
387
399
  if (res.statusCode === 404) {
@@ -404,71 +416,87 @@ function doUnifiedSync(callback) {
404
416
 
405
417
  // Apply remote per-app toggle. Absent body.app means the backend either
406
418
  // doesn't know about appKey-scoped sync (older API) or no appKey was
407
- // sent - leave the previous value untouched (default true on first run).
419
+ // sent - leave the previous value untouched (default true on first run).
408
420
  if (body.app && typeof body.app.firewallEnabled === 'boolean') {
409
421
  const next = body.app.firewallEnabled;
410
422
  if (next !== _lastRemoteEnabled) {
411
423
  _remoteEnabled = next;
412
424
  if (_options.log) {
413
- fwLog('[securenow] Firewall: remote toggle -> %s (app=%s)',
425
+ fwLog('[securenow] Firewall: remote toggle -> %s (app=%s)',
414
426
  next ? 'ENABLED' : 'DISABLED', body.app.key || _options.appKey);
415
427
  }
416
428
  _lastRemoteEnabled = next;
417
429
  }
418
430
  }
419
431
 
420
- // Update blocklist version + data
421
- if (body.blocklist) {
422
- const newVer = body.blocklist.version;
423
- if (newVer !== _lastVersion) {
424
- _lastVersion = newVer;
425
- }
426
- }
427
-
428
- if (body.blocklistIps || body.blocklistRules) {
429
- setBlocklistData(body.blocklistIps || [], body.blocklistRules || []);
430
- blChanged = true;
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
+ }
432
444
 
433
445
  // Update allowlist version + data
434
- if (body.allowlist) {
435
- const newVer = body.allowlist.version;
436
- if (newVer !== _lastAllowlistVersion) {
437
- _lastAllowlistVersion = newVer;
438
- }
439
- }
440
-
441
- if (body.allowlistIps) {
442
- _allowlistRawIps = body.allowlistIps;
443
- _allowlistMatcher = createMatcher(body.allowlistIps);
444
- alChanged = true;
445
- }
446
-
447
- if (body.rateLimits) {
448
- const newVer = body.rateLimits.version;
449
- if (newVer !== _lastRateLimitVersion) {
450
- _lastRateLimitVersion = newVer;
451
- }
452
- }
453
-
454
- if (Array.isArray(body.rateLimitRules)) {
455
- _rateLimitRules = body.rateLimitRules;
456
- pruneRateLimitBuckets(_rateLimitRules);
457
- }
458
-
459
- callback(null, { blChanged, alChanged, rlChanged: Array.isArray(body.rateLimitRules) });
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) });
460
488
  } catch (e) {
461
489
  callback(new Error(`Failed to parse sync response: ${e.message}`));
462
490
  }
463
491
  });
464
492
  }
465
493
 
466
- // Legacy Sync (v1 - separate endpoints, kept for backward compat)
494
+ // Legacy Sync (v1 - separate endpoints, kept for backward compat)
467
495
 
468
- function legacyBlocklistSync(callback) {
469
- const url = buildFirewallUrl('/firewall/blocklist');
470
- const headers = {};
471
- if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
496
+ function legacyBlocklistSync(callback) {
497
+ const url = buildFirewallUrl('/firewall/blocklist');
498
+ const headers = {};
499
+ if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
472
500
  if (_lastSyncEtag) headers['If-None-Match'] = _lastSyncEtag;
473
501
  else if (_lastModified) headers['If-Modified-Since'] = _lastModified;
474
502
 
@@ -478,23 +506,23 @@ function legacyBlocklistSync(callback) {
478
506
  if (res.statusCode === 429) { handleRetryAfter(res); }
479
507
  if (res.statusCode !== 200) return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
480
508
 
481
- try {
482
- const body = JSON.parse(data);
483
- const ips = body.ips || [];
484
- setBlocklistData(ips, body.rules || body.blocklistRules || []);
485
- _lastModified = res.headers['last-modified'] || null;
486
- if (res.headers['etag']) _lastSyncEtag = res.headers['etag'];
487
- callback(null, true, _matcher.stats());
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());
488
516
  } catch (e) {
489
517
  callback(new Error(`Failed to parse blocklist: ${e.message}`));
490
518
  }
491
519
  });
492
520
  }
493
521
 
494
- function legacyAllowlistSync(callback) {
495
- const url = buildFirewallUrl('/firewall/allowlist');
496
- const headers = {};
497
- if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
522
+ function legacyAllowlistSync(callback) {
523
+ const url = buildFirewallUrl('/firewall/allowlist');
524
+ const headers = {};
525
+ if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
498
526
  if (_lastAllowlistSyncEtag) headers['If-None-Match'] = _lastAllowlistSyncEtag;
499
527
  else if (_lastAllowlistModified) headers['If-Modified-Since'] = _lastAllowlistModified;
500
528
 
@@ -518,10 +546,10 @@ function legacyAllowlistSync(callback) {
518
546
  });
519
547
  }
520
548
 
521
- function legacyVersionCheck(callback) {
522
- const url = buildFirewallUrl('/firewall/blocklist/version');
523
- const headers = {};
524
- if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
549
+ function legacyVersionCheck(callback) {
550
+ const url = buildFirewallUrl('/firewall/blocklist/version');
551
+ const headers = {};
552
+ if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
525
553
  if (_lastVersion) headers['If-None-Match'] = _lastVersion;
526
554
 
527
555
  httpGet(url, headers, 5000, (err, res, data) => {
@@ -541,10 +569,10 @@ function legacyVersionCheck(callback) {
541
569
  });
542
570
  }
543
571
 
544
- function legacyAllowlistVersionCheck(callback) {
545
- const url = buildFirewallUrl('/firewall/allowlist/version');
546
- const headers = {};
547
- if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
572
+ function legacyAllowlistVersionCheck(callback) {
573
+ const url = buildFirewallUrl('/firewall/allowlist/version');
574
+ const headers = {};
575
+ if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
548
576
  if (_lastAllowlistVersion) headers['If-None-Match'] = _lastAllowlistVersion;
549
577
 
550
578
  httpGet(url, headers, 5000, (err, res, data) => {
@@ -583,19 +611,19 @@ function doLegacyPoll(callback) {
583
611
  callback(null, { blChanged, alChanged });
584
612
  }
585
613
 
586
- if (blChanged) {
587
- legacyBlocklistSync((err, changed, stats) => {
588
- if (err && _options.log) fwWarn('[securenow] Firewall: sync failed (using stale list):', formatRequestError(err));
589
- else if (changed && stats && _options.log) fwLog('[securenow] Firewall: re-synced %d blocked IPs', stats.total);
590
- syncDone();
591
- });
592
- }
593
- if (alChanged) {
594
- legacyAllowlistSync((err, changed, stats) => {
595
- if (err && _options.log) fwWarn('[securenow] Firewall: allowlist sync failed:', formatRequestError(err));
596
- else if (changed && stats && _options.log) fwLog('[securenow] Firewall: re-synced %d allowed IPs', stats.total);
597
- syncDone();
598
- });
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
+ });
599
627
  }
600
628
  }
601
629
 
@@ -612,7 +640,7 @@ function doLegacyPoll(callback) {
612
640
  });
613
641
  }
614
642
 
615
- // Unified poll loop
643
+ // Unified poll loop
616
644
 
617
645
  function notifyLayers(ips) {
618
646
  for (const layer of _layers) {
@@ -626,35 +654,35 @@ function pollOnce(callback) {
626
654
 
627
655
  const done = (err, result) => {
628
656
  _pollInflight = false;
629
- if (err) {
630
- if (isApiReachabilityError(err) && switchToNextApiUrl(formatRequestError(err))) {
631
- _pollInflight = false;
632
- const retryTimer = setTimeout(() => pollOnce(callback), 1000);
633
- if (retryTimer.unref) retryTimer.unref();
634
- return;
635
- }
636
- _consecutiveErrors++;
637
- _stats.errors++;
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++;
638
666
  maybeOpenCircuit();
639
- if (_options.log) fwWarn('[securenow] Firewall: poll failed:', formatRequestError(err));
667
+ if (_options.log) fwWarn('[securenow] Firewall: poll failed:', formatRequestError(err));
640
668
  return callback(err);
641
669
  }
642
670
  _consecutiveErrors = 0;
643
671
  resetCircuit();
644
672
  if (result) {
645
- if (result.blChanged && _options.log && _matcher) {
646
- const s = _matcher.stats();
647
- fwLog('[securenow] Firewall: re-synced %d global blocked IPs (%d exact + %d CIDR ranges) and %d scoped block rules',
648
- s.total, s.exact, s.cidr, _blocklistRules.length);
649
- }
650
- if (result.alChanged && _options.log && _allowlistMatcher) {
651
- const s = _allowlistMatcher.stats();
652
- fwLog('[securenow] Firewall: re-synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
653
- }
654
- if (result.rlChanged && _options.log) {
655
- fwLog('[securenow] Firewall: re-synced %d rate-limit rules', _rateLimitRules.length);
656
- }
657
- }
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
+ }
658
686
  callback(null);
659
687
  };
660
688
 
@@ -694,14 +722,14 @@ function startSyncLoop() {
694
722
  const syncFn = _useUnifiedSync ? doUnifiedSync : (cb) => doLegacyPoll(cb);
695
723
 
696
724
  syncFn((err, result) => {
697
- if (err) {
698
- const isConnErr = /ECONNREFUSED|ENOTFOUND|timed out/i.test(err.message);
699
- if (isApiReachabilityError(err) && switchToNextApiUrl(formatRequestError(err))) {
700
- const retryTimer = setTimeout(initialSync, 1000);
701
- if (retryTimer.unref) retryTimer.unref();
702
- return;
703
- }
704
- if (isConnErr && !_localhostFallbackTried && _options.apiUrl !== 'http://localhost:4000') {
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') {
705
733
  _localhostFallbackTried = true;
706
734
  const origUrl = _options.apiUrl;
707
735
  _options.apiUrl = 'http://localhost:4000';
@@ -715,7 +743,7 @@ function startSyncLoop() {
715
743
  if (retryTimer.unref) retryTimer.unref();
716
744
  return;
717
745
  }
718
- if (_options.log) fwWarn('[securenow] Firewall: initial sync failed:', formatRequestError(err));
746
+ if (_options.log) fwWarn('[securenow] Firewall: initial sync failed:', formatRequestError(err));
719
747
  if (_options.failMode === 'closed') {
720
748
  _matcher = { isBlocked: () => true, stats: () => ({ exact: 0, cidr: 0, total: 0 }) };
721
749
  }
@@ -732,21 +760,21 @@ function startSyncLoop() {
732
760
  return;
733
761
  }
734
762
 
735
- _initialized = true;
736
- if (_options.log && _matcher) {
737
- const s = _matcher.stats();
738
- fwLog('[securenow] Firewall: synced %d global blocked IPs (%d exact + %d CIDR ranges) and %d scoped block rules',
739
- s.total, s.exact, s.cidr, _blocklistRules.length);
740
- }
741
- if (_options.log && _allowlistMatcher) {
742
- const s = _allowlistMatcher.stats();
743
- if (s.total > 0) fwLog('[securenow] Firewall: synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
744
- }
745
- if (_options.log && _rateLimitRules.length > 0) {
746
- fwLog('[securenow] Firewall: synced %d rate-limit rules', _rateLimitRules.length);
747
- }
748
- });
749
- }
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
+ }
750
778
 
751
779
  initialSync();
752
780
  scheduleNextPoll();
@@ -754,28 +782,28 @@ function startSyncLoop() {
754
782
  // Safety-net full sync timer (less frequent, uses same path)
755
783
  _syncTimer = setInterval(() => {
756
784
  if (shouldSkipRequest()) return;
757
- // Force a full re-fetch by clearing versions so unified endpoint returns full data
758
- const savedBlVer = _lastVersion;
759
- const savedAlVer = _lastAllowlistVersion;
760
- const savedRlVer = _lastRateLimitVersion;
761
- const savedUnifiedEtag = _lastUnifiedEtag;
762
- _lastVersion = null;
763
- _lastAllowlistVersion = null;
764
- _lastRateLimitVersion = null;
765
- _lastUnifiedEtag = null;
766
- pollOnce((err) => {
767
- if (err) {
768
- _lastVersion = savedBlVer;
769
- _lastAllowlistVersion = savedAlVer;
770
- _lastRateLimitVersion = savedRlVer;
771
- _lastUnifiedEtag = savedUnifiedEtag;
772
- }
773
- });
774
- }, fullSyncIntervalMs);
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);
775
803
  if (_syncTimer.unref) _syncTimer.unref();
776
804
  }
777
805
 
778
- // Layer 1: HTTP Handler
806
+ // Layer 1: HTTP Handler
779
807
 
780
808
  const _origHttpCreate = http.createServer;
781
809
  const _origHttpsCreate = https.createServer;
@@ -787,7 +815,7 @@ function blockedHtml(ip) {
787
815
  <html lang="en">
788
816
  <head>
789
817
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
790
- <title>Access Blocked - Security Alert</title>
818
+ <title>Access Blocked - Security Alert</title>
791
819
  <style>
792
820
  *{margin:0;padding:0;box-sizing:border-box}
793
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}
@@ -831,147 +859,344 @@ function wrapListener(originalListener) {
831
859
  };
832
860
  }
833
861
 
834
- function sendBlockResponse(req, res, ip) {
835
- const code = (_options && _options.statusCode) || 403;
836
- const accept = req.headers['accept'] || '';
837
- if (accept.includes('text/html')) {
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')) {
838
866
  res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8' });
839
867
  res.end(blockedHtml(ip));
840
868
  } else {
841
869
  res.writeHead(code, { 'Content-Type': 'application/json' });
842
- res.end(JSON.stringify({ error: 'Forbidden', ip }));
843
- }
844
- }
845
-
846
- function sendRateLimitResponse(req, res, ip, decision) {
847
- const retryAfter = Math.max(1, decision.retryAfter || 1);
848
- const headers = {
849
- 'Retry-After': String(retryAfter),
850
- 'X-RateLimit-Limit': String(decision.limit || ''),
851
- 'X-RateLimit-Window': String(decision.windowSeconds || ''),
852
- 'X-SecureNow-Rate-Limit-Rule': decision.ruleId || '',
853
- };
854
- const accept = req.headers['accept'] || '';
855
- if (accept.includes('text/html')) {
856
- res.writeHead(429, { ...headers, 'Content-Type': 'text/html; charset=utf-8' });
857
- 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>');
858
- } else {
859
- res.writeHead(429, { ...headers, 'Content-Type': 'application/json' });
860
- res.end(JSON.stringify({
861
- error: 'Too Many Requests',
862
- ip,
863
- retryAfter,
864
- }));
865
- }
866
- }
867
-
868
- function requestPath(req) {
869
- try {
870
- return new URL(req.url || '/', 'http://localhost').pathname || '/';
871
- } catch (_) {
872
- return req.url || '/';
873
- }
874
- }
875
-
876
- function ruleIpMatches(rule, ip) {
877
- const target = String(rule.ip || '').trim();
878
- if (!target) return true;
879
- try {
880
- return createMatcher([target]).isBlocked(ip);
881
- } catch (_) {
882
- return false;
883
- }
884
- }
885
-
886
- function rulePathMatches(rule, path) {
887
- const pattern = String(rule.pathPattern || '').trim();
888
- if (!pattern) return true;
889
- const mode = rule.pathMatchMode || 'prefix';
890
- if (mode === 'exact') return path === pattern;
891
- if (mode === 'regex') {
892
- try { return new RegExp(pattern).test(path); } catch (_) { return false; }
893
- }
894
- return path.startsWith(normalizePrefixPattern(pattern));
895
- }
896
-
897
- function rateLimitKey(rule, ip) {
898
- const id = rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`;
899
- const subject = rule.keyBy === 'global' ? 'global' : ip;
900
- return `${id}|${subject}`;
901
- }
902
-
903
- function checkBlocklistRules(req, ip) {
904
- if (!_blocklistRules || _blocklistRules.length === 0) return null;
905
- const method = String(req.method || 'GET').toUpperCase();
906
- const path = requestPath(req);
907
-
908
- for (const rule of _blocklistRules) {
909
- if (!rule) continue;
910
- const ruleMethod = String(rule.method || 'ALL').toUpperCase();
911
- if (ruleMethod !== 'ALL' && ruleMethod !== method) continue;
912
- if (!ruleIpMatches(rule, ip)) continue;
913
- if (!rulePathMatches(rule, path)) continue;
914
- return {
915
- rule,
916
- ruleId: rule.id || rule._id || '',
917
- matchedEntry: rule.ip || ip,
918
- path,
919
- };
920
- }
921
-
922
- return null;
923
- }
924
-
925
- function checkRateLimitRules(req, ip) {
926
- if (!_rateLimitRules || _rateLimitRules.length === 0) return null;
927
- const method = String(req.method || 'GET').toUpperCase();
928
- const path = requestPath(req);
929
- const now = Date.now();
930
-
931
- for (const rule of _rateLimitRules) {
932
- if (!rule) continue;
933
- const ruleMethod = String(rule.method || 'ALL').toUpperCase();
934
- if (ruleMethod !== 'ALL' && ruleMethod !== method) continue;
935
- if (!ruleIpMatches(rule, ip)) continue;
936
- if (!rulePathMatches(rule, path)) continue;
937
-
938
- const limit = Math.max(1, Number(rule.limit || 1) || 1);
939
- const windowSeconds = Math.max(1, Number(rule.windowSeconds || 60) || 60);
940
- const windowMs = windowSeconds * 1000;
941
- const key = rateLimitKey(rule, ip);
942
- let bucket = _rateLimitBuckets.get(key);
943
- if (!bucket || now - bucket.windowStart >= windowMs) {
944
- bucket = { windowStart: now, count: 0 };
945
- _rateLimitBuckets.set(key, bucket);
946
- }
947
-
948
- if (bucket.count >= limit) {
949
- const retryAfter = Math.ceil((bucket.windowStart + windowMs - now) / 1000);
950
- return {
951
- rule,
952
- ruleId: rule.id || rule._id || '',
953
- limit,
954
- windowSeconds,
955
- retryAfter,
956
- path,
957
- };
958
- }
959
- bucket.count++;
960
- }
961
-
962
- return null;
963
- }
964
-
965
- function pruneRateLimitBuckets(rules) {
966
- const ids = new Set((rules || []).map((rule) => String(rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`)));
967
- for (const key of _rateLimitBuckets.keys()) {
968
- const idx = key.indexOf('|');
969
- const ruleKey = idx === -1 ? key : key.slice(0, idx);
970
- if (!ids.has(ruleKey)) _rateLimitBuckets.delete(key);
971
- }
972
- }
973
-
974
- function firewallRequestHandler(req, res) {
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) {
975
1200
  // Remote disable wins over everything: when the dashboard / CLI flips the
976
1201
  // toggle off, requests pass through the SDK as if the firewall weren't
977
1202
  // installed. The poll loop keeps running so we re-enable within seconds.
@@ -982,81 +1207,100 @@ function firewallRequestHandler(req, res) {
982
1207
 
983
1208
  const ip = resolveClientIp(req);
984
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
+
985
1216
  // Allowlist check: if active, only listed IPs are allowed through
986
- if (_allowlistMatcher && _allowlistMatcher.stats().total > 0) {
987
- if (!_allowlistMatcher.isBlocked(ip)) {
988
- _stats.blocked++;
989
- if (_options && _options.log) fwLog('[securenow] Firewall: blocked %s via HTTP (not in allowlist)', ip);
990
- reportFirewallEvent({
991
- source: 'allowlist',
992
- ip,
993
- matchedEntry: '',
994
- method: req.method || '',
995
- path: req.url || '',
996
- userAgent: req.headers['user-agent'] || '',
997
- });
998
- sendBlockResponse(req, res, ip);
999
- return true;
1000
- }
1001
- return false;
1002
- }
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
+ }
1003
1234
 
1004
1235
  // Blocklist check
1005
- if (_matcher && _matcher.isBlocked(ip)) {
1006
- _stats.blocked++;
1007
- if (_options && _options.log) fwLog('[securenow] Firewall: blocked %s via HTTP', ip);
1008
- reportFirewallEvent({
1009
- source: 'blocklist',
1010
- ip,
1011
- matchedEntry: ip,
1012
- method: req.method || '',
1013
- path: req.url || '',
1014
- userAgent: req.headers['user-agent'] || '',
1015
- });
1016
- sendBlockResponse(req, res, ip);
1017
- return true;
1018
- }
1019
-
1020
- const blockRuleDecision = checkBlocklistRules(req, ip);
1021
- if (blockRuleDecision) {
1022
- _stats.blocked++;
1023
- if (_options && _options.log) {
1024
- fwLog('[securenow] Firewall: blocked %s via HTTP (rule=%s)', ip, blockRuleDecision.ruleId || 'scoped');
1025
- }
1026
- reportFirewallEvent({
1027
- source: 'blocklist',
1028
- ip,
1029
- matchedEntry: blockRuleDecision.matchedEntry || ip,
1030
- method: req.method || '',
1031
- path: blockRuleDecision.path || req.url || '',
1032
- userAgent: req.headers['user-agent'] || '',
1033
- });
1034
- sendBlockResponse(req, res, ip);
1035
- return true;
1036
- }
1037
-
1038
- const rateLimitDecision = checkRateLimitRules(req, ip);
1039
- if (rateLimitDecision) {
1040
- _stats.rateLimited++;
1041
- if (_options && _options.log) {
1042
- fwLog('[securenow] Firewall: rate-limited %s via HTTP (rule=%s)', ip, rateLimitDecision.ruleId || 'unknown');
1043
- }
1044
- reportFirewallEvent({
1045
- action: 'rate_limited',
1046
- source: 'rate_limit',
1047
- statusCode: 429,
1048
- ip,
1049
- matchedEntry: rateLimitDecision.ruleId || '',
1050
- method: req.method || '',
1051
- path: rateLimitDecision.path || req.url || '',
1052
- userAgent: req.headers['user-agent'] || '',
1053
- });
1054
- sendRateLimitResponse(req, res, ip, rateLimitDecision);
1055
- return true;
1056
- }
1057
-
1058
- return false;
1059
- }
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
+ }
1060
1304
 
1061
1305
  const _origEmit = http.Server.prototype.emit;
1062
1306
  let _emitPatched = false;
@@ -1096,18 +1340,18 @@ function patchHttpLayer() {
1096
1340
  Object.assign(https.createServer, _origHttpsCreate);
1097
1341
  }
1098
1342
 
1099
- // Init
1343
+ // Init
1100
1344
 
1101
- function init(options) {
1102
- _options = options || {};
1103
- _options.apiUrl = String(_options.apiUrl || '').trim().replace(/\/$/, '');
1104
- _localhostFallbackTried = false;
1105
- _useUnifiedSync = true;
1106
- resetApiUrlFallbacks();
1107
-
1108
- if (_options.log) fwLog('[securenow] Firewall: ENABLED');
1109
- if (_options.log && _options.apiUrl) fwLog('[securenow] Firewall: sync endpoint=%s/api/v1/firewall/sync', _options.apiUrl);
1110
- if (_options.log && _options.environment) fwLog('[securenow] Firewall: environment=%s', _options.environment);
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);
1111
1355
 
1112
1356
  patchHttpLayer();
1113
1357
  if (_options.log) fwLog('[securenow] Firewall: Layer 1 (HTTP 403) active');
@@ -1122,7 +1366,7 @@ function init(options) {
1122
1366
  if (_options.log) fwWarn('[securenow] Firewall: Layer 2 (TCP drop) failed:', e.message);
1123
1367
  }
1124
1368
  } else {
1125
- if (_options.log) fwLog('[securenow] Firewall: Layer 2 (TCP drop) disabled (set config.firewall.tcp=true)');
1369
+ if (_options.log) fwLog('[securenow] Firewall: Layer 2 (TCP drop) disabled (set config.firewall.tcp=true)');
1126
1370
  }
1127
1371
 
1128
1372
  if (_options.iptables) {
@@ -1135,7 +1379,7 @@ function init(options) {
1135
1379
  if (_options.log) fwWarn('[securenow] Firewall: Layer 3 (iptables) failed:', e.message);
1136
1380
  }
1137
1381
  } else {
1138
- if (_options.log) fwLog('[securenow] Firewall: Layer 3 (iptables) disabled (set config.firewall.iptables=true)');
1382
+ if (_options.log) fwLog('[securenow] Firewall: Layer 3 (iptables) disabled (set config.firewall.iptables=true)');
1139
1383
  }
1140
1384
 
1141
1385
  if (_options.cloud) {
@@ -1148,28 +1392,32 @@ function init(options) {
1148
1392
  if (_options.log) fwWarn('[securenow] Firewall: Layer 4 (Cloud WAF) failed:', e.message);
1149
1393
  }
1150
1394
  } else {
1151
- if (_options.log) fwLog('[securenow] Firewall: Layer 4 (Cloud WAF) disabled (set config.firewall.cloud=cloudflare|aws|gcp)');
1395
+ if (_options.log) fwLog('[securenow] Firewall: Layer 4 (Cloud WAF) disabled (set config.firewall.cloud=cloudflare|aws|gcp)');
1152
1396
  }
1153
1397
 
1154
1398
  startSyncLoop();
1155
1399
  }
1156
1400
 
1157
- function shutdown() {
1158
- if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
1159
- if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
1160
- if (_eventTimer) { clearTimeout(_eventTimer); _eventTimer = null; }
1161
- flushFirewallEvents();
1162
-
1163
- _circuitState = 'closed';
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';
1164
1408
  _circuitOpenedAt = 0;
1165
1409
  _consecutiveErrors = 0;
1166
- _pollInflight = false;
1167
- _retryAfterUntil = 0;
1168
- _localhostFallbackTried = false;
1169
- _blocklistRules = [];
1170
- _rateLimitRules = [];
1171
- _rateLimitBuckets = new Map();
1172
- _remainingApiUrlFallbacks = [];
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 = [];
1173
1421
 
1174
1422
  _httpAgent.destroy();
1175
1423
  _httpsAgent.destroy();
@@ -1192,29 +1440,31 @@ function shutdown() {
1192
1440
 
1193
1441
  function getStats() {
1194
1442
  return {
1195
- ..._stats,
1196
- matcher: _matcher ? _matcher.stats() : null,
1197
- blocklistRules: _blocklistRules.length,
1198
- allowlistMatcher: _allowlistMatcher ? _allowlistMatcher.stats() : null,
1199
- rateLimitRules: _rateLimitRules.length,
1200
- rateLimitBuckets: _rateLimitBuckets.size,
1201
- initialized: _initialized,
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,
1202
1450
  circuitState: _circuitState,
1203
1451
  consecutiveErrors: _consecutiveErrors,
1204
1452
  unifiedSync: _useUnifiedSync,
1205
- remoteEnabled: _remoteEnabled,
1206
- appKey: _options ? _options.appKey || null : null,
1207
- environment: _options ? _options.environment || null : null,
1208
- };
1209
- }
1453
+ remoteEnabled: _remoteEnabled,
1454
+ appKey: _options ? _options.appKey || null : null,
1455
+ environment: _options ? _options.environment || null : null,
1456
+ };
1457
+ }
1210
1458
 
1211
1459
  // Layers (TCP / iptables / cloud) read the matcher to populate kernel-level
1212
1460
  // rules. When the remote toggle is off, return null so they treat the policy
1213
1461
  // as "no IPs to block" without us mutating the cached matcher.
1214
- function getMatcher() { return _remoteEnabled === false ? null : _matcher; }
1215
- function getAllowlistMatcher() { return _remoteEnabled === false ? null : _allowlistMatcher; }
1216
- function getBlocklistRules() { return _remoteEnabled === false ? [] : _blocklistRules.slice(); }
1217
- function getRateLimitRules() { return _remoteEnabled === false ? [] : _rateLimitRules.slice(); }
1218
- function isRemoteEnabled() { return _remoteEnabled !== false; }
1219
-
1220
- module.exports = { init, shutdown, getStats, getMatcher, getAllowlistMatcher, getBlocklistRules, getRateLimitRules, isRemoteEnabled };
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 };