securenow 8.5.0 → 8.7.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/challenge.js +273 -0
- package/cli/challenges.js +253 -0
- package/cli/security.js +80 -8
- package/cli.js +42 -4
- package/firewall.js +952 -702
- package/nextjs-auto-capture.js +274 -195
- package/nextjs-middleware.js +268 -185
- package/nextjs-wrapper.js +234 -155
- package/nextjs.js +768 -685
- package/nuxt-server-plugin.mjs +506 -426
- package/package.json +5 -1
- package/tracing.js +844 -758
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 (
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|