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 +1 -1
- package/src/config/validateConfig.js +17 -29
- package/src/index.js +2 -0
- package/src/middleware/apiKeyAuth.js +34 -47
- package/src/middleware/rateLimiter.js +2 -15
- package/src/middleware/requestSize.js +16 -27
- package/src/middleware/requestValidator.js +9 -14
- package/src/middleware/threatDetector.js +5 -7
- package/src/security/apiKey.js +1 -5
- package/src/security/detector.js +36 -38
- package/src/security/ipAddress.js +26 -31
- package/src/stores/redisStore.js +23 -16
- package/src/types/index.d.ts +1 -0
- package/src/utils/logger.js +2 -16
package/package.json
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
package/src/security/apiKey.js
CHANGED
|
@@ -78,11 +78,7 @@ function isKeyExpired(keyRecord) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
const expiresTime = new Date(keyRecord.expiresAt).getTime();
|
|
81
|
-
|
|
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 = {
|
package/src/security/detector.js
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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,
|
|
163
|
-
if (trustProxy === true) {
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
193
|
-
return parsed ? parsed.toString() : ips[i];
|
|
194
|
+
return formatIp(ips[i]);
|
|
194
195
|
}
|
|
195
196
|
}
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/stores/redisStore.js
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/types/index.d.ts
CHANGED
|
@@ -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;
|
package/src/utils/logger.js
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
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) {
|