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.
Files changed (2) hide show
  1. package/firewall.js +146 -15
  2. 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 firewallRequestHandler(req, res) {
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
- return false;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "7.7.8",
3
+ "version": "7.7.9",
4
4
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
5
5
  "type": "commonjs",
6
6
  "main": "register.js",