securenow 7.7.8 → 7.7.9
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 +146 -15
- package/package.json +1 -1
package/firewall.js
CHANGED
|
@@ -17,7 +17,7 @@ let _initialized = false;
|
|
|
17
17
|
let _consecutiveErrors = 0;
|
|
18
18
|
let _layers = [];
|
|
19
19
|
let _rawIps = [];
|
|
20
|
-
let _stats = { syncs: 0, blocked: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
|
|
20
|
+
let _stats = { syncs: 0, blocked: 0, rateLimited: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
|
|
21
21
|
let _localhostFallbackTried = false;
|
|
22
22
|
let _eventQueue = [];
|
|
23
23
|
let _eventTimer = null;
|
|
@@ -40,6 +40,7 @@ let _lastAllowlistSyncEtag = null;
|
|
|
40
40
|
// Enforcement is intentionally added in the next SDK phase.
|
|
41
41
|
let _rateLimitRules = [];
|
|
42
42
|
let _lastRateLimitVersion = null;
|
|
43
|
+
let _rateLimitBuckets = new Map();
|
|
43
44
|
|
|
44
45
|
// Circuit breaker
|
|
45
46
|
const CIRCUIT_OPEN_THRESHOLD = 5;
|
|
@@ -385,6 +386,7 @@ function doUnifiedSync(callback) {
|
|
|
385
386
|
|
|
386
387
|
if (Array.isArray(body.rateLimitRules)) {
|
|
387
388
|
_rateLimitRules = body.rateLimitRules;
|
|
389
|
+
pruneRateLimitBuckets(_rateLimitRules);
|
|
388
390
|
}
|
|
389
391
|
|
|
390
392
|
callback(null, { blChanged, alChanged, rlChanged: Array.isArray(body.rateLimitRules) });
|
|
@@ -752,19 +754,125 @@ function wrapListener(originalListener) {
|
|
|
752
754
|
};
|
|
753
755
|
}
|
|
754
756
|
|
|
755
|
-
function sendBlockResponse(req, res, ip) {
|
|
756
|
-
const code = (_options && _options.statusCode) || 403;
|
|
757
|
-
const accept = req.headers['accept'] || '';
|
|
758
|
-
if (accept.includes('text/html')) {
|
|
757
|
+
function sendBlockResponse(req, res, ip) {
|
|
758
|
+
const code = (_options && _options.statusCode) || 403;
|
|
759
|
+
const accept = req.headers['accept'] || '';
|
|
760
|
+
if (accept.includes('text/html')) {
|
|
759
761
|
res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
760
762
|
res.end(blockedHtml(ip));
|
|
761
763
|
} else {
|
|
762
764
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
763
|
-
res.end(JSON.stringify({ error: 'Forbidden', ip }));
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
function
|
|
765
|
+
res.end(JSON.stringify({ error: 'Forbidden', ip }));
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function sendRateLimitResponse(req, res, ip, decision) {
|
|
770
|
+
const retryAfter = Math.max(1, decision.retryAfter || 1);
|
|
771
|
+
const headers = {
|
|
772
|
+
'Retry-After': String(retryAfter),
|
|
773
|
+
'X-RateLimit-Limit': String(decision.limit || ''),
|
|
774
|
+
'X-RateLimit-Window': String(decision.windowSeconds || ''),
|
|
775
|
+
'X-SecureNow-Rate-Limit-Rule': decision.ruleId || '',
|
|
776
|
+
};
|
|
777
|
+
const accept = req.headers['accept'] || '';
|
|
778
|
+
if (accept.includes('text/html')) {
|
|
779
|
+
res.writeHead(429, { ...headers, 'Content-Type': 'text/html; charset=utf-8' });
|
|
780
|
+
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>');
|
|
781
|
+
} else {
|
|
782
|
+
res.writeHead(429, { ...headers, 'Content-Type': 'application/json' });
|
|
783
|
+
res.end(JSON.stringify({
|
|
784
|
+
error: 'Too Many Requests',
|
|
785
|
+
ip,
|
|
786
|
+
retryAfter,
|
|
787
|
+
}));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function requestPath(req) {
|
|
792
|
+
try {
|
|
793
|
+
return new URL(req.url || '/', 'http://localhost').pathname || '/';
|
|
794
|
+
} catch (_) {
|
|
795
|
+
return req.url || '/';
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function ruleIpMatches(rule, ip) {
|
|
800
|
+
const target = String(rule.ip || '').trim();
|
|
801
|
+
if (!target) return true;
|
|
802
|
+
try {
|
|
803
|
+
return createMatcher([target]).isBlocked(ip);
|
|
804
|
+
} catch (_) {
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function rulePathMatches(rule, path) {
|
|
810
|
+
const pattern = String(rule.pathPattern || '').trim();
|
|
811
|
+
if (!pattern) return true;
|
|
812
|
+
const mode = rule.pathMatchMode || 'prefix';
|
|
813
|
+
if (mode === 'exact') return path === pattern;
|
|
814
|
+
if (mode === 'regex') {
|
|
815
|
+
try { return new RegExp(pattern).test(path); } catch (_) { return false; }
|
|
816
|
+
}
|
|
817
|
+
return path.startsWith(pattern);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function rateLimitKey(rule, ip) {
|
|
821
|
+
const id = rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`;
|
|
822
|
+
const subject = rule.keyBy === 'global' ? 'global' : ip;
|
|
823
|
+
return `${id}|${subject}`;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function checkRateLimitRules(req, ip) {
|
|
827
|
+
if (!_rateLimitRules || _rateLimitRules.length === 0) return null;
|
|
828
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
829
|
+
const path = requestPath(req);
|
|
830
|
+
const now = Date.now();
|
|
831
|
+
|
|
832
|
+
for (const rule of _rateLimitRules) {
|
|
833
|
+
if (!rule) continue;
|
|
834
|
+
const ruleMethod = String(rule.method || 'ALL').toUpperCase();
|
|
835
|
+
if (ruleMethod !== 'ALL' && ruleMethod !== method) continue;
|
|
836
|
+
if (!ruleIpMatches(rule, ip)) continue;
|
|
837
|
+
if (!rulePathMatches(rule, path)) continue;
|
|
838
|
+
|
|
839
|
+
const limit = Math.max(1, Number(rule.limit || 1) || 1);
|
|
840
|
+
const windowSeconds = Math.max(1, Number(rule.windowSeconds || 60) || 60);
|
|
841
|
+
const windowMs = windowSeconds * 1000;
|
|
842
|
+
const key = rateLimitKey(rule, ip);
|
|
843
|
+
let bucket = _rateLimitBuckets.get(key);
|
|
844
|
+
if (!bucket || now - bucket.windowStart >= windowMs) {
|
|
845
|
+
bucket = { windowStart: now, count: 0 };
|
|
846
|
+
_rateLimitBuckets.set(key, bucket);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (bucket.count >= limit) {
|
|
850
|
+
const retryAfter = Math.ceil((bucket.windowStart + windowMs - now) / 1000);
|
|
851
|
+
return {
|
|
852
|
+
rule,
|
|
853
|
+
ruleId: rule.id || rule._id || '',
|
|
854
|
+
limit,
|
|
855
|
+
windowSeconds,
|
|
856
|
+
retryAfter,
|
|
857
|
+
path,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
bucket.count++;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function pruneRateLimitBuckets(rules) {
|
|
867
|
+
const ids = new Set((rules || []).map((rule) => String(rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`)));
|
|
868
|
+
for (const key of _rateLimitBuckets.keys()) {
|
|
869
|
+
const idx = key.indexOf('|');
|
|
870
|
+
const ruleKey = idx === -1 ? key : key.slice(0, idx);
|
|
871
|
+
if (!ids.has(ruleKey)) _rateLimitBuckets.delete(key);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function firewallRequestHandler(req, res) {
|
|
768
876
|
// Remote disable wins over everything: when the dashboard / CLI flips the
|
|
769
877
|
// toggle off, requests pass through the SDK as if the firewall weren't
|
|
770
878
|
// installed. The poll loop keeps running so we re-enable within seconds.
|
|
@@ -809,9 +917,29 @@ function firewallRequestHandler(req, res) {
|
|
|
809
917
|
sendBlockResponse(req, res, ip);
|
|
810
918
|
return true;
|
|
811
919
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
920
|
+
|
|
921
|
+
const rateLimitDecision = checkRateLimitRules(req, ip);
|
|
922
|
+
if (rateLimitDecision) {
|
|
923
|
+
_stats.rateLimited++;
|
|
924
|
+
if (_options && _options.log) {
|
|
925
|
+
fwLog('[securenow] Firewall: rate-limited %s via HTTP (rule=%s)', ip, rateLimitDecision.ruleId || 'unknown');
|
|
926
|
+
}
|
|
927
|
+
reportFirewallEvent({
|
|
928
|
+
action: 'rate_limited',
|
|
929
|
+
source: 'rate_limit',
|
|
930
|
+
statusCode: 429,
|
|
931
|
+
ip,
|
|
932
|
+
matchedEntry: rateLimitDecision.ruleId || '',
|
|
933
|
+
method: req.method || '',
|
|
934
|
+
path: rateLimitDecision.path || req.url || '',
|
|
935
|
+
userAgent: req.headers['user-agent'] || '',
|
|
936
|
+
});
|
|
937
|
+
sendRateLimitResponse(req, res, ip, rateLimitDecision);
|
|
938
|
+
return true;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
815
943
|
|
|
816
944
|
const _origEmit = http.Server.prototype.emit;
|
|
817
945
|
let _emitPatched = false;
|
|
@@ -913,8 +1041,10 @@ function shutdown() {
|
|
|
913
1041
|
_circuitState = 'closed';
|
|
914
1042
|
_circuitOpenedAt = 0;
|
|
915
1043
|
_consecutiveErrors = 0;
|
|
916
|
-
_pollInflight = false;
|
|
917
|
-
_retryAfterUntil = 0;
|
|
1044
|
+
_pollInflight = false;
|
|
1045
|
+
_retryAfterUntil = 0;
|
|
1046
|
+
_rateLimitRules = [];
|
|
1047
|
+
_rateLimitBuckets = new Map();
|
|
918
1048
|
|
|
919
1049
|
_httpAgent.destroy();
|
|
920
1050
|
_httpsAgent.destroy();
|
|
@@ -941,6 +1071,7 @@ function getStats() {
|
|
|
941
1071
|
matcher: _matcher ? _matcher.stats() : null,
|
|
942
1072
|
allowlistMatcher: _allowlistMatcher ? _allowlistMatcher.stats() : null,
|
|
943
1073
|
rateLimitRules: _rateLimitRules.length,
|
|
1074
|
+
rateLimitBuckets: _rateLimitBuckets.size,
|
|
944
1075
|
initialized: _initialized,
|
|
945
1076
|
circuitState: _circuitState,
|
|
946
1077
|
consecutiveErrors: _consecutiveErrors,
|
package/package.json
CHANGED