secure-api-guard 1.0.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "secure-api-guard",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Production-ready, battle-tested security middleware suite for Node.js and Express applications",
5
5
  "main": "src/index.js",
6
6
  "types": "src/types/index.d.ts",
@@ -3,6 +3,12 @@
3
3
  const bytes = require('bytes');
4
4
  const { ConfigurationError } = require('../utils/errors');
5
5
 
6
+ function assertFunction(fn, name) {
7
+ if (fn !== undefined && typeof fn !== 'function') {
8
+ throw new ConfigurationError(`${name} must be a function.`);
9
+ }
10
+ }
11
+
6
12
  /**
7
13
  * Validate Rate Limiter configuration options.
8
14
  *
@@ -28,13 +34,8 @@ function validateRateLimiterConfig(options = {}) {
28
34
  }
29
35
  }
30
36
 
31
- if (options.keyGenerator !== undefined && typeof options.keyGenerator !== 'function') {
32
- throw new ConfigurationError('RateLimiter: "keyGenerator" must be a function.');
33
- }
34
-
35
- if (options.skip !== undefined && typeof options.skip !== 'function') {
36
- throw new ConfigurationError('RateLimiter: "skip" must be a function.');
37
- }
37
+ assertFunction(options.keyGenerator, 'RateLimiter: "keyGenerator"');
38
+ assertFunction(options.skip, 'RateLimiter: "skip"');
38
39
  }
39
40
 
40
41
  /**
@@ -55,19 +56,12 @@ function validateApiKeyAuthConfig(options = {}) {
55
56
  }
56
57
  }
57
58
 
58
- if (options.keys !== undefined) {
59
- if (!Array.isArray(options.keys) && typeof options.keys !== 'object') {
60
- throw new ConfigurationError('ApiKeyAuth: "keys" must be an array or object.');
61
- }
59
+ if (options.keys !== undefined && !Array.isArray(options.keys) && typeof options.keys !== 'object') {
60
+ throw new ConfigurationError('ApiKeyAuth: "keys" must be an array or object.');
62
61
  }
63
62
 
64
- if (options.getKey !== undefined && typeof options.getKey !== 'function') {
65
- throw new ConfigurationError('ApiKeyAuth: "getKey" must be a function.');
66
- }
67
-
68
- if (options.verifyKey !== undefined && typeof options.verifyKey !== 'function') {
69
- throw new ConfigurationError('ApiKeyAuth: "verifyKey" must be a function.');
70
- }
63
+ assertFunction(options.getKey, 'ApiKeyAuth: "getKey"');
64
+ assertFunction(options.verifyKey, 'ApiKeyAuth: "verifyKey"');
71
65
  }
72
66
 
73
67
  /**
@@ -76,10 +70,8 @@ function validateApiKeyAuthConfig(options = {}) {
76
70
  * @param {Object} options
77
71
  */
78
72
  function validateIpFilterConfig(options = {}) {
79
- if (options.mode !== undefined) {
80
- if (options.mode !== 'allowlist' && options.mode !== 'blocklist') {
81
- throw new ConfigurationError('IpFilter: "mode" must be either "allowlist" or "blocklist".');
82
- }
73
+ if (options.mode !== undefined && options.mode !== 'allowlist' && options.mode !== 'blocklist') {
74
+ throw new ConfigurationError('IpFilter: "mode" must be either "allowlist" or "blocklist".');
83
75
  }
84
76
 
85
77
  if (options.allowlist !== undefined && !Array.isArray(options.allowlist)) {
@@ -90,9 +82,7 @@ function validateIpFilterConfig(options = {}) {
90
82
  throw new ConfigurationError('IpFilter: "blocklist" must be an array of IP strings or CIDR notations.');
91
83
  }
92
84
 
93
- if (options.getIp !== undefined && typeof options.getIp !== 'function') {
94
- throw new ConfigurationError('IpFilter: "getIp" must be a function.');
95
- }
85
+ assertFunction(options.getIp, 'IpFilter: "getIp"');
96
86
  }
97
87
 
98
88
  /**
@@ -123,10 +113,8 @@ function validateRequestSizeConfig(options = {}) {
123
113
  * @param {Object} options
124
114
  */
125
115
  function validateThreatDetectorConfig(options = {}) {
126
- if (options.action !== undefined) {
127
- if (!['reject', 'log', 'pass'].includes(options.action)) {
128
- throw new ConfigurationError('ThreatDetector: "action" must be "reject", "log", or "pass".');
129
- }
116
+ if (options.action !== undefined && !['reject', 'log', 'pass'].includes(options.action)) {
117
+ throw new ConfigurationError('ThreatDetector: "action" must be "reject", "log", or "pass".');
130
118
  }
131
119
 
132
120
  if (options.customRules !== undefined && !Array.isArray(options.customRules)) {
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ const {
19
19
 
20
20
  const {
21
21
  parseIp,
22
+ formatIp,
22
23
  parseRule,
23
24
  matchesRule,
24
25
  isIpInRules,
@@ -94,6 +95,7 @@ module.exports = {
94
95
  isKeyExpired,
95
96
 
96
97
  parseIp,
98
+ formatIp,
97
99
  parseRule,
98
100
  matchesRule,
99
101
  isIpInRules,
@@ -100,6 +100,37 @@ function createApiKeyAuth(userOptions = {}) {
100
100
  }).filter(Boolean);
101
101
  }
102
102
 
103
+ function authorizeKey(keyRecord, req, res, next) {
104
+ if (isKeyExpired(keyRecord)) {
105
+ logger.warn('API_KEY_EXPIRED', `API key ${keyRecord.id || ''} has expired`, { req });
106
+ const error = new ForbiddenError(options.forbiddenMessage);
107
+ return sendSecurityResponse(req, res, error, options.forbiddenStatusCode, options.handler);
108
+ }
109
+
110
+ if (options.requiredPermissions && options.requiredPermissions.length > 0) {
111
+ const keyPermissions = Array.isArray(keyRecord.permissions) ? keyRecord.permissions : [];
112
+ const hasAll = options.requiredPermissions.every((perm) => keyPermissions.includes(perm));
113
+
114
+ if (!hasAll) {
115
+ logger.warn('API_KEY_INSUFFICIENT_PERMISSIONS', `API key ${keyRecord.id} lacks required permissions`, {
116
+ req,
117
+ details: { required: options.requiredPermissions, actual: keyPermissions }
118
+ });
119
+ const error = new ForbiddenError('Forbidden: Insufficient permissions for this API resource.');
120
+ return sendSecurityResponse(req, res, error, options.forbiddenStatusCode, options.handler);
121
+ }
122
+ }
123
+
124
+ req.apiKey = {
125
+ id: keyRecord.id || 'custom',
126
+ name: keyRecord.name,
127
+ permissions: keyRecord.permissions || [],
128
+ metadata: keyRecord.metadata || {}
129
+ };
130
+
131
+ return next();
132
+ }
133
+
103
134
  return async function apiKeyAuthMiddleware(req, res, next) {
104
135
  const rawKey = extractApiKey(req, options);
105
136
 
@@ -120,26 +151,13 @@ function createApiKeyAuth(userOptions = {}) {
120
151
  }
121
152
 
122
153
  const keyInfo = typeof verifyResult === 'object' ? verifyResult : {};
123
- if (isKeyExpired(keyInfo)) {
124
- logger.warn('API_KEY_EXPIRED', `API key ${keyInfo.id || ''} has expired`, { req });
125
- const error = new ForbiddenError(options.forbiddenMessage);
126
- return sendSecurityResponse(req, res, error, options.forbiddenStatusCode, options.handler);
127
- }
128
-
129
- req.apiKey = {
130
- id: keyInfo.id || 'custom',
131
- name: keyInfo.name,
132
- permissions: keyInfo.permissions || [],
133
- metadata: keyInfo.metadata || {}
134
- };
135
- return next();
154
+ return authorizeKey(keyInfo, req, res, next);
136
155
  }
137
156
 
138
- // Compute hash of incoming key
157
+ // 2. Hash lookup
139
158
  const incomingHash = hashApiKey(rawKey, options.hashAlgorithm);
140
159
  let matchedRecord = null;
141
160
 
142
- // 2. Custom getKey lookup function
143
161
  if (typeof options.getKey === 'function') {
144
162
  const retrieved = await options.getKey(incomingHash, req);
145
163
  if (retrieved) {
@@ -149,7 +167,6 @@ function createApiKeyAuth(userOptions = {}) {
149
167
  }
150
168
  }
151
169
  } else {
152
- // 3. Search precomputed static keys using constant-time comparison
153
170
  for (const candidate of precomputedKeys) {
154
171
  if (candidate.hash && secureCompare(incomingHash, candidate.hash)) {
155
172
  matchedRecord = candidate;
@@ -164,37 +181,7 @@ function createApiKeyAuth(userOptions = {}) {
164
181
  return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
165
182
  }
166
183
 
167
- // Check expiration
168
- if (isKeyExpired(matchedRecord)) {
169
- logger.warn('API_KEY_EXPIRED', `API key ${matchedRecord.id || ''} has expired`, { req });
170
- const error = new ForbiddenError(options.forbiddenMessage);
171
- return sendSecurityResponse(req, res, error, options.forbiddenStatusCode, options.handler);
172
- }
173
-
174
- // Check permissions if required
175
- if (options.requiredPermissions && options.requiredPermissions.length > 0) {
176
- const keyPermissions = Array.isArray(matchedRecord.permissions) ? matchedRecord.permissions : [];
177
- const hasAllPermissions = options.requiredPermissions.every((perm) => keyPermissions.includes(perm));
178
-
179
- if (!hasAllPermissions) {
180
- logger.warn('API_KEY_INSUFFICIENT_PERMISSIONS', `API key ${matchedRecord.id} lacks required permissions`, {
181
- req,
182
- details: { required: options.requiredPermissions, actual: keyPermissions }
183
- });
184
- const error = new ForbiddenError('Forbidden: Insufficient permissions for this API resource.');
185
- return sendSecurityResponse(req, res, error, options.forbiddenStatusCode, options.handler);
186
- }
187
- }
188
-
189
- // Attach sanitized key record (NEVER the plaintext key or raw hash)
190
- req.apiKey = {
191
- id: matchedRecord.id,
192
- name: matchedRecord.name,
193
- permissions: matchedRecord.permissions || [],
194
- metadata: matchedRecord.metadata || {}
195
- };
196
-
197
- return next();
184
+ return authorizeKey(matchedRecord, req, res, next);
198
185
  } catch (err) {
199
186
  logger.error('API_KEY_AUTH_ERROR', `Internal error during API key verification: ${err.message}`, { req });
200
187
  return next(err);
@@ -3,26 +3,13 @@
3
3
  const rateLimit = require('express-rate-limit');
4
4
  const defaults = require('../config/defaults');
5
5
  const { validateRateLimiterConfig } = require('../config/validateConfig');
6
+ const { formatIp } = require('../security/ipAddress');
6
7
  const { MemoryStore } = require('../stores/memoryStore');
7
8
  const { RedisStore } = require('../stores/redisStore');
8
9
  const { RateLimitError } = require('../utils/errors');
9
10
  const { sendSecurityResponse } = require('../utils/response');
10
11
  const { defaultLogger } = require('../utils/logger');
11
12
 
12
- /**
13
- * Normalizes an IP address to avoid IPv4-mapped IPv6 duplicates.
14
- *
15
- * @param {string} ip
16
- * @returns {string}
17
- */
18
- function normalizeIp(ip) {
19
- if (!ip) return '127.0.0.1';
20
- if (ip.startsWith('::ffff:')) {
21
- return ip.substring(7);
22
- }
23
- return ip;
24
- }
25
-
26
13
  /**
27
14
  * Default key generator based on client IP.
28
15
  *
@@ -31,7 +18,7 @@ function normalizeIp(ip) {
31
18
  */
32
19
  function defaultKeyGenerator(req) {
33
20
  const rawIp = req.ip || (req.socket && req.socket.remoteAddress) || '127.0.0.1';
34
- return normalizeIp(rawIp);
21
+ return formatIp(rawIp);
35
22
  }
36
23
 
37
24
  /**
@@ -71,6 +71,16 @@ function createRequestSize(userOptions = {}) {
71
71
 
72
72
  const logger = options.logger || defaultLogger;
73
73
 
74
+ function rejectOversized(req, res, details) {
75
+ logger.warn('REQUEST_SIZE_EXCEEDED', `Payload exceeded size limit (${maxBytes} bytes)`, {
76
+ req,
77
+ details
78
+ });
79
+ res.setHeader('Connection', 'close');
80
+ const error = new PayloadTooLargeError(options.message, details);
81
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
82
+ }
83
+
74
84
  return function requestSizeMiddleware(req, res, next) {
75
85
  // 1. Inspect Content-Length header
76
86
  const { length: contentLength, error: headerError } = parseContentLength(req);
@@ -82,32 +92,18 @@ function createRequestSize(userOptions = {}) {
82
92
  }
83
93
 
84
94
  if (contentLength !== null && contentLength > maxBytes) {
85
- logger.warn('REQUEST_SIZE_EXCEEDED', `Content-Length (${contentLength}) exceeds limit (${maxBytes})`, {
86
- req,
87
- details: { contentLength, maxBytes }
88
- });
89
- res.setHeader('Connection', 'close');
90
- const error = new PayloadTooLargeError(options.message, { contentLength, maxBytes });
91
- return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
95
+ return rejectOversized(req, res, { contentLength, maxBytes });
92
96
  }
93
97
 
94
98
  // 2. Check if body is already parsed by upstream body parser
95
99
  if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
96
100
  try {
97
- let estimatedSize = 0;
98
- if (Buffer.isBuffer(req.body)) {
99
- estimatedSize = req.body.length;
100
- } else {
101
- estimatedSize = Buffer.byteLength(JSON.stringify(req.body), 'utf8');
102
- }
101
+ const estimatedSize = Buffer.isBuffer(req.body)
102
+ ? req.body.length
103
+ : Buffer.byteLength(JSON.stringify(req.body), 'utf8');
103
104
 
104
105
  if (estimatedSize > maxBytes) {
105
- logger.warn('REQUEST_SIZE_EXCEEDED', `Parsed body size (${estimatedSize}) exceeds limit (${maxBytes})`, {
106
- req,
107
- details: { estimatedSize, maxBytes }
108
- });
109
- const error = new PayloadTooLargeError(options.message, { estimatedSize, maxBytes });
110
- return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
106
+ return rejectOversized(req, res, { estimatedSize, maxBytes });
111
107
  }
112
108
  } catch {
113
109
  // Ignore serialization failure; proceed to streaming check
@@ -130,14 +126,7 @@ function createRequestSize(userOptions = {}) {
130
126
  if (typeof req.pause === 'function') {
131
127
  req.pause();
132
128
  }
133
- logger.warn('REQUEST_SIZE_EXCEEDED', `Streamed body (${receivedBytes}) exceeded limit (${maxBytes})`, {
134
- req,
135
- details: { receivedBytes, maxBytes }
136
- });
137
-
138
- res.setHeader('Connection', 'close');
139
- const error = new PayloadTooLargeError(options.message, { receivedBytes, maxBytes });
140
- sendSecurityResponse(req, res, error, options.statusCode, options.handler);
129
+ rejectOversized(req, res, { receivedBytes, maxBytes });
141
130
  return false;
142
131
  }
143
132
  }
@@ -58,20 +58,15 @@ async function validateTarget(schema, data, target) {
58
58
  }
59
59
 
60
60
  // 2. Zod schema: safeParseAsync or safeParse
61
- if (typeof schema.safeParseAsync === 'function') {
62
- const result = await schema.safeParseAsync(data);
63
- if (result.success) {
64
- return { success: true, data: result.data };
65
- }
66
- return { success: false, issues: formatIssues(result.error.issues, target) };
67
- }
68
-
69
- if (typeof schema.safeParse === 'function') {
70
- const result = schema.safeParse(data);
71
- if (result.success) {
72
- return { success: true, data: result.data };
73
- }
74
- return { success: false, issues: formatIssues(result.error.issues, target) };
61
+ const parseFn = typeof schema.safeParseAsync === 'function'
62
+ ? schema.safeParseAsync.bind(schema)
63
+ : (typeof schema.safeParse === 'function' ? schema.safeParse.bind(schema) : null);
64
+
65
+ if (parseFn) {
66
+ const result = await parseFn(data);
67
+ return result.success
68
+ ? { success: true, data: result.data }
69
+ : { success: false, issues: formatIssues(result.error.issues, target) };
75
70
  }
76
71
 
77
72
  // 3. Fallback for schema with parse() method
@@ -46,13 +46,11 @@ function createThreatDetector(userOptions = {}) {
46
46
 
47
47
  // Check whitelisted paths
48
48
  if (options.whitelistedPaths && options.whitelistedPaths.length > 0) {
49
- for (const whitePath of options.whitelistedPaths) {
50
- if (typeof whitePath === 'string' && (currentPath === whitePath || currentPath.startsWith(whitePath))) {
51
- return next();
52
- }
53
- if (whitePath instanceof RegExp && whitePath.test(currentPath)) {
54
- return next();
55
- }
49
+ const isWhitelisted = options.whitelistedPaths.some((p) =>
50
+ typeof p === 'string' ? (currentPath === p || currentPath.startsWith(p)) : (p instanceof RegExp && p.test(currentPath))
51
+ );
52
+ if (isWhitelisted) {
53
+ return next();
56
54
  }
57
55
  }
58
56
 
@@ -78,11 +78,7 @@ function isKeyExpired(keyRecord) {
78
78
  }
79
79
 
80
80
  const expiresTime = new Date(keyRecord.expiresAt).getTime();
81
- if (isNaN(expiresTime)) {
82
- return false;
83
- }
84
-
85
- return Date.now() > expiresTime;
81
+ return !Number.isNaN(expiresTime) && Date.now() > expiresTime;
86
82
  }
87
83
 
88
84
  module.exports = {
@@ -45,6 +45,36 @@ const DEFAULT_THREAT_PATTERNS = [
45
45
  }
46
46
  ];
47
47
 
48
+ /**
49
+ * Evaluates a threat rule against a string value.
50
+ *
51
+ * @param {Object} rule
52
+ * @param {string} value
53
+ * @param {string} path
54
+ * @param {string} [snippetPrefix]
55
+ * @returns {Object|null}
56
+ */
57
+ function matchRule(rule, value, path, snippetPrefix) {
58
+ let matched = false;
59
+ if (typeof rule.pattern === 'function') {
60
+ matched = Boolean(rule.pattern(value, path));
61
+ } else if (rule.pattern instanceof RegExp) {
62
+ matched = rule.pattern.test(value);
63
+ }
64
+
65
+ if (matched) {
66
+ const raw = snippetPrefix ? `${snippetPrefix}: ${value}` : value;
67
+ return {
68
+ ruleId: rule.id || 'CUSTOM_RULE',
69
+ ruleName: rule.name || 'Custom Threat Rule',
70
+ path,
71
+ snippet: raw.length > 80 ? `${raw.substring(0, 80)}...` : raw,
72
+ severity: rule.severity || 'medium'
73
+ };
74
+ }
75
+ return null;
76
+ }
77
+
48
78
  /**
49
79
  * Recursively inspects values in strings, arrays, or objects for threat signatures.
50
80
  *
@@ -58,39 +88,15 @@ const DEFAULT_THREAT_PATTERNS = [
58
88
  function scanValue(value, rules, currentPath = '', whitelistedKeys = [], seen = new Set()) {
59
89
  const matches = [];
60
90
 
61
- if (value === null || value === undefined) {
62
- return matches;
63
- }
64
-
65
- if (seen.has(value)) {
91
+ if (value === null || value === undefined || seen.has(value)) {
66
92
  return matches;
67
93
  }
68
94
 
69
95
  // Handle strings
70
96
  if (typeof value === 'string') {
71
97
  for (const rule of rules) {
72
- if (typeof rule.pattern === 'function') {
73
- const customMatch = rule.pattern(value, currentPath);
74
- if (customMatch) {
75
- matches.push({
76
- ruleId: rule.id || 'CUSTOM_RULE',
77
- ruleName: rule.name || 'Custom Threat Rule',
78
- path: currentPath,
79
- snippet: value.length > 80 ? `${value.substring(0, 80)}...` : value,
80
- severity: rule.severity || 'medium'
81
- });
82
- }
83
- } else if (rule.pattern instanceof RegExp) {
84
- if (rule.pattern.test(value)) {
85
- matches.push({
86
- ruleId: rule.id,
87
- ruleName: rule.name,
88
- path: currentPath,
89
- snippet: value.length > 80 ? `${value.substring(0, 80)}...` : value,
90
- severity: rule.severity
91
- });
92
- }
93
- }
98
+ const match = matchRule(rule, value, currentPath);
99
+ if (match) matches.push(match);
94
100
  }
95
101
  return matches;
96
102
  }
@@ -112,16 +118,10 @@ function scanValue(value, rules, currentPath = '', whitelistedKeys = [], seen =
112
118
  // Get all own property names including non-enumerable or __proto__
113
119
  const propNames = Object.getOwnPropertyNames(value);
114
120
  for (const propName of propNames) {
121
+ const keyPath = currentPath ? `${currentPath}.${propName}` : propName;
115
122
  for (const rule of rules) {
116
- if (rule.pattern instanceof RegExp && rule.pattern.test(propName)) {
117
- matches.push({
118
- ruleId: rule.id,
119
- ruleName: rule.name,
120
- path: currentPath ? `${currentPath}.${propName}` : propName,
121
- snippet: `Key name: ${propName}`,
122
- severity: rule.severity
123
- });
124
- }
123
+ const match = matchRule(rule, propName, keyPath, 'Key name');
124
+ if (match) matches.push(match);
125
125
  }
126
126
  }
127
127
 
@@ -142,11 +142,9 @@ function scanValue(value, rules, currentPath = '', whitelistedKeys = [], seen =
142
142
  }
143
143
 
144
144
  for (const [key, val] of Object.entries(value)) {
145
- // Check if key is whitelisted
146
145
  if (whitelistedKeys.includes(key.toLowerCase())) {
147
146
  continue;
148
147
  }
149
-
150
148
  const childPath = currentPath ? `${currentPath}.${key}` : key;
151
149
  matches.push(...scanValue(val, rules, childPath, whitelistedKeys, seen));
152
150
  }
@@ -132,6 +132,18 @@ function compileRules(ruleStrings = []) {
132
132
  .filter((r) => r !== null);
133
133
  }
134
134
 
135
+ /**
136
+ * Formats an IP string into its canonical parsed representation or original string.
137
+ *
138
+ * @param {string} ip
139
+ * @returns {string}
140
+ */
141
+ function formatIp(ip) {
142
+ if (!ip) return '127.0.0.1';
143
+ const parsed = parseIp(ip);
144
+ return parsed ? parsed.toString() : ip;
145
+ }
146
+
135
147
  /**
136
148
  * Safely extracts client IP according to proxy configuration.
137
149
  * Never blindly trusts X-Forwarded-For unless explicitly configured.
@@ -144,7 +156,7 @@ function compileRules(ruleStrings = []) {
144
156
  function getClientIp(req, options = {}) {
145
157
  const trustProxy = options.trustProxy !== undefined ? options.trustProxy : false;
146
158
 
147
- // Custom function
159
+ // Custom extractor function
148
160
  if (typeof options.getIp === 'function') {
149
161
  return options.getIp(req);
150
162
  }
@@ -154,20 +166,14 @@ function getClientIp(req, options = {}) {
154
166
  '127.0.0.1';
155
167
 
156
168
  if (!trustProxy) {
157
- // When trustProxy is disabled, ONLY use direct socket address
158
- const parsed = parseIp(directIp);
159
- return parsed ? parsed.toString() : directIp;
169
+ return formatIp(directIp);
160
170
  }
161
171
 
162
- // If trustProxy is boolean true, rely on express req.ip if available
163
- if (trustProxy === true) {
164
- if (req.ip) {
165
- const parsed = parseIp(req.ip);
166
- return parsed ? parsed.toString() : req.ip;
167
- }
172
+ // If trustProxy is boolean true, prefer express req.ip if set
173
+ if (trustProxy === true && req.ip) {
174
+ return formatIp(req.ip);
168
175
  }
169
176
 
170
- // If X-Forwarded-For is present and proxy is trusted
171
177
  const xForwardedFor = req.headers && req.headers['x-forwarded-for'];
172
178
  if (xForwardedFor) {
173
179
  const rawList = Array.isArray(xForwardedFor) ? xForwardedFor.join(',') : xForwardedFor;
@@ -175,46 +181,35 @@ function getClientIp(req, options = {}) {
175
181
 
176
182
  if (ips.length > 0) {
177
183
  if (typeof trustProxy === 'number') {
178
- // Trust specified number of hops from the right
179
184
  const index = Math.max(0, ips.length - trustProxy);
180
- const targetIp = ips[index] || directIp;
181
- const parsed = parseIp(targetIp);
182
- return parsed ? parsed.toString() : targetIp;
185
+ return formatIp(ips[index] || directIp);
183
186
  }
184
187
 
185
188
  if (Array.isArray(trustProxy)) {
186
- // Trust only if directIp is in the trusted proxy list
187
189
  const trustedRules = compileRules(trustProxy);
188
190
  if (isIpInRules(directIp, trustedRules)) {
189
- // Find first untrusted IP walking backwards from right to left
191
+ // Walk backwards to find first untrusted IP
190
192
  for (let i = ips.length - 1; i >= 0; i--) {
191
193
  if (!isIpInRules(ips[i], trustedRules)) {
192
- const parsed = parseIp(ips[i]);
193
- return parsed ? parsed.toString() : ips[i];
194
+ return formatIp(ips[i]);
194
195
  }
195
196
  }
196
- // All are trusted; return the leftmost
197
- const parsed = parseIp(ips[0]);
198
- return parsed ? parsed.toString() : ips[0];
199
- } else {
200
- // Direct connection is not a trusted proxy! Reject X-Forwarded-For header spoofing
201
- const parsed = parseIp(directIp);
202
- return parsed ? parsed.toString() : directIp;
197
+ return formatIp(ips[0]);
203
198
  }
199
+ // Direct socket is not a trusted proxy: ignore spoofed header
200
+ return formatIp(directIp);
204
201
  }
205
202
 
206
- // Boolean true fallback: take the first valid IP
207
- const parsed = parseIp(ips[0]);
208
- return parsed ? parsed.toString() : ips[0];
203
+ return formatIp(ips[0]);
209
204
  }
210
205
  }
211
206
 
212
- const parsed = parseIp(directIp);
213
- return parsed ? parsed.toString() : directIp;
207
+ return formatIp(directIp);
214
208
  }
215
209
 
216
210
  module.exports = {
217
211
  parseIp,
212
+ formatIp,
218
213
  parseRule,
219
214
  matchesRule,
220
215
  isIpInRules,
@@ -39,6 +39,7 @@ class RedisStore {
39
39
  this.windowMs = options.windowMs || 60000;
40
40
  this.passOnConnectionError = options.passOnConnectionError !== false;
41
41
  this.logger = options.logger || defaultLogger;
42
+ this._evalMode = null;
42
43
  }
43
44
 
44
45
  /**
@@ -70,24 +71,30 @@ class RedisStore {
70
71
  async _evalLua(key, windowMs) {
71
72
  const fullKey = this._prefixKey(key);
72
73
 
73
- // Node-redis v4+ client.eval syntax
74
- if (typeof this.client.eval === 'function') {
75
- try {
76
- // Attempt node-redis v4 object parameters
77
- const res = await this.client.eval(LUA_INCR_SCRIPT, {
78
- keys: [fullKey],
79
- arguments: [String(windowMs)]
80
- });
81
- return res;
82
- } catch (err) {
83
- // If node-redis object parameter syntax failed, try ioredis positional syntax:
84
- // eval(script, numkeys, key1, ..., arg1, ...)
85
- const res = await this.client.eval(LUA_INCR_SCRIPT, 1, fullKey, String(windowMs));
86
- return res;
87
- }
74
+ if (typeof this.client.eval !== 'function') {
75
+ throw new Error('Provided Redis client does not support eval command.');
76
+ }
77
+
78
+ if (this._evalMode === 'node-redis') {
79
+ return this.client.eval(LUA_INCR_SCRIPT, { keys: [fullKey], arguments: [String(windowMs)] });
80
+ }
81
+ if (this._evalMode === 'ioredis') {
82
+ return this.client.eval(LUA_INCR_SCRIPT, 1, fullKey, String(windowMs));
88
83
  }
89
84
 
90
- throw new Error('Provided Redis client does not support eval command.');
85
+ // Auto-detect client signature on first execution
86
+ try {
87
+ const res = await this.client.eval(LUA_INCR_SCRIPT, {
88
+ keys: [fullKey],
89
+ arguments: [String(windowMs)]
90
+ });
91
+ this._evalMode = 'node-redis';
92
+ return res;
93
+ } catch {
94
+ const res = await this.client.eval(LUA_INCR_SCRIPT, 1, fullKey, String(windowMs));
95
+ this._evalMode = 'ioredis';
96
+ return res;
97
+ }
91
98
  }
92
99
 
93
100
  /**
@@ -213,6 +213,7 @@ export interface IpFilterOptions {
213
213
  export function ipFilter(options?: IpFilterOptions): RequestHandler;
214
214
  export function createIpFilter(options?: IpFilterOptions): RequestHandler;
215
215
  export function parseIp(ipString: string): any;
216
+ export function formatIp(ipString: string): string;
216
217
  export function parseRule(ruleString: string): any;
217
218
  export function matchesRule(clientIp: any, rule: any): boolean;
218
219
  export function isIpInRules(clientIpStr: string, compiledRules: any[]): boolean;
@@ -182,22 +182,8 @@ class SecurityLogger {
182
182
 
183
183
  const formatted = `[secure-api-guard] [${event.timestamp}] [${event.level}] [${event.eventType}] ${event.message} (reqId=${event.requestId}, ip=${event.ip})`;
184
184
 
185
- switch (level) {
186
- case 'error':
187
- console.error(formatted, event.details);
188
- break;
189
- case 'warn':
190
- console.warn(formatted, event.details);
191
- break;
192
- case 'info':
193
- console.info(formatted, event.details);
194
- break;
195
- case 'debug':
196
- console.debug(formatted, event.details);
197
- break;
198
- default:
199
- console.log(formatted, event.details);
200
- }
185
+ const logFn = typeof console[level] === 'function' ? console[level] : console.log;
186
+ logFn(formatted, event.details);
201
187
  }
202
188
 
203
189
  debug(eventType, message, context) {