secure-api-guard 0.0.0-stage → 1.0.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/LICENSE +22 -0
- package/README.md +549 -2
- package/package.json +64 -4
- package/src/config/defaults.js +98 -0
- package/src/config/validateConfig.js +148 -0
- package/src/index.js +132 -0
- package/src/middleware/apiKeyAuth.js +210 -0
- package/src/middleware/ipFilter.js +77 -0
- package/src/middleware/rateLimiter.js +129 -0
- package/src/middleware/requestSize.js +157 -0
- package/src/middleware/requestValidator.js +168 -0
- package/src/middleware/securityHeaders.js +59 -0
- package/src/middleware/threatDetector.js +132 -0
- package/src/security/apiKey.js +94 -0
- package/src/security/detector.js +161 -0
- package/src/security/ipAddress.js +224 -0
- package/src/stores/memoryStore.js +135 -0
- package/src/stores/redisStore.js +222 -0
- package/src/types/index.d.ts +313 -0
- package/src/utils/errors.js +88 -0
- package/src/utils/logger.js +229 -0
- package/src/utils/response.js +58 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hash an API key using the specified algorithm (default: sha256).
|
|
7
|
+
*
|
|
8
|
+
* @param {string} rawKey - Plaintext API key.
|
|
9
|
+
* @param {string} [algorithm='sha256'] - Hashing algorithm.
|
|
10
|
+
* @returns {string} Hex-encoded hash.
|
|
11
|
+
*/
|
|
12
|
+
function hashApiKey(rawKey, algorithm = 'sha256') {
|
|
13
|
+
if (typeof rawKey !== 'string' || !rawKey) {
|
|
14
|
+
throw new Error('API key must be a non-empty string.');
|
|
15
|
+
}
|
|
16
|
+
return crypto.createHash(algorithm).update(rawKey, 'utf8').digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate a cryptographically secure random API key.
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} [options]
|
|
23
|
+
* @param {string} [options.prefix='sag_'] - Key prefix (e.g. 'sag_live_', 'sag_test_').
|
|
24
|
+
* @param {number} [options.bytes=32] - Number of random bytes.
|
|
25
|
+
* @param {string} [options.algorithm='sha256'] - Hash algorithm for returned hash.
|
|
26
|
+
* @returns {{ key: string, hash: string, prefix: string }}
|
|
27
|
+
*/
|
|
28
|
+
function generateApiKey(options = {}) {
|
|
29
|
+
const prefix = options.prefix || 'sag_';
|
|
30
|
+
const byteLength = options.bytes && options.bytes >= 16 ? options.bytes : 32;
|
|
31
|
+
const algorithm = options.algorithm || 'sha256';
|
|
32
|
+
|
|
33
|
+
const randomPart = crypto.randomBytes(byteLength).toString('base64url');
|
|
34
|
+
const key = `${prefix}${randomPart}`;
|
|
35
|
+
const hash = hashApiKey(key, algorithm);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
key,
|
|
39
|
+
hash,
|
|
40
|
+
prefix
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Constant-time comparison between two hashes to prevent timing attacks.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} a - First hash hex string.
|
|
48
|
+
* @param {string} b - Second hash hex string.
|
|
49
|
+
* @returns {boolean} True if hashes match.
|
|
50
|
+
*/
|
|
51
|
+
function secureCompare(a, b) {
|
|
52
|
+
if (typeof a !== 'string' || typeof b !== 'string') {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
57
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
58
|
+
|
|
59
|
+
if (bufA.length !== bufB.length) {
|
|
60
|
+
// Constant time dummy comparison to avoid leaking length differences
|
|
61
|
+
crypto.timingSafeEqual(bufA, bufA);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if a key record is expired.
|
|
70
|
+
*
|
|
71
|
+
* @param {Object} keyRecord
|
|
72
|
+
* @param {Date|string|number} [keyRecord.expiresAt]
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
function isKeyExpired(keyRecord) {
|
|
76
|
+
if (!keyRecord || !keyRecord.expiresAt) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const expiresTime = new Date(keyRecord.expiresAt).getTime();
|
|
81
|
+
if (isNaN(expiresTime)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Date.now() > expiresTime;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
hashApiKey,
|
|
90
|
+
generateApiKey,
|
|
91
|
+
secureCompare,
|
|
92
|
+
isKeyExpired
|
|
93
|
+
};
|
|
94
|
+
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* High-confidence threat detection signatures.
|
|
5
|
+
*
|
|
6
|
+
* NOTE: Pattern matching is an auxiliary defense-in-depth heuristic, NOT a replacement
|
|
7
|
+
* for parameterized SQL queries, context-aware output encoding, or rigorous input validation.
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_THREAT_PATTERNS = [
|
|
10
|
+
{
|
|
11
|
+
id: 'PATH_TRAVERSAL',
|
|
12
|
+
name: 'Directory / Path Traversal Attack',
|
|
13
|
+
pattern: /(?:\.\.[/\\]|[/\\]\.\.|\.\.%2f|%2e%2e%2f|%2e%2e\/|\.\.%5c|%2e%2e%5c)/i,
|
|
14
|
+
severity: 'high'
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'NULL_BYTE_INJECTION',
|
|
18
|
+
name: 'Null Byte Injection',
|
|
19
|
+
pattern: /(?:\x00|%00|\\0)/i,
|
|
20
|
+
severity: 'high'
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'COMMAND_INJECTION',
|
|
24
|
+
name: 'Suspicious Shell Command Injection',
|
|
25
|
+
pattern: /(?:;\s*(?:cat|chmod|curl|wget|rm|bash|sh|whoami|nc|netcat|powershell)\b|\|\s*(?:bash|sh|curl|wget|powershell)\b|`\s*(?:cat|curl|wget|rm|bash|sh|whoami)\s*`|\$\(\s*(?:cat|curl|wget|rm|bash|sh|whoami)\s*\))/i,
|
|
26
|
+
severity: 'high'
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'SCRIPT_TAG_INJECTION',
|
|
30
|
+
name: 'Suspicious Inline Script / JavaScript Scheme',
|
|
31
|
+
pattern: /(?:<\s*script\b[^>]*>|javascript\s*:\s*(?:alert|document|window|location|eval|prompt)\b|data\s*:\s*text\/html)/i,
|
|
32
|
+
severity: 'medium'
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'PROTOTYPE_POLLUTION',
|
|
36
|
+
name: 'Prototype Pollution Attempt',
|
|
37
|
+
pattern: /(?:__proto__|constructor\s*\[\s*['"]?prototype['"]?\s*\]|prototype\s*\.\s*constructor)/i,
|
|
38
|
+
severity: 'high'
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'SSTI_INJECTION',
|
|
42
|
+
name: 'Suspicious Template Injection (SSTI)',
|
|
43
|
+
pattern: /(?:\{\{\s*(?:constructor|process|require|mainModule|__proto__)\s*\}\}|<%\s*=\s*(?:process|require)\s*%>)/i,
|
|
44
|
+
severity: 'high'
|
|
45
|
+
}
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Recursively inspects values in strings, arrays, or objects for threat signatures.
|
|
50
|
+
*
|
|
51
|
+
* @param {any} value - Value to inspect.
|
|
52
|
+
* @param {Array<Object>} rules - Compiled threat rules.
|
|
53
|
+
* @param {string} [currentPath=''] - Current object path for error reporting.
|
|
54
|
+
* @param {string[]} [whitelistedKeys=[]] - Keys to skip inspection.
|
|
55
|
+
* @param {Set<any>} [seen] - Visited objects to prevent infinite loops.
|
|
56
|
+
* @returns {Array<{ ruleId: string, ruleName: string, path: string, snippet: string, severity: string }>}
|
|
57
|
+
*/
|
|
58
|
+
function scanValue(value, rules, currentPath = '', whitelistedKeys = [], seen = new Set()) {
|
|
59
|
+
const matches = [];
|
|
60
|
+
|
|
61
|
+
if (value === null || value === undefined) {
|
|
62
|
+
return matches;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (seen.has(value)) {
|
|
66
|
+
return matches;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Handle strings
|
|
70
|
+
if (typeof value === 'string') {
|
|
71
|
+
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
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return matches;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Handle Arrays
|
|
99
|
+
if (Array.isArray(value)) {
|
|
100
|
+
seen.add(value);
|
|
101
|
+
for (let i = 0; i < value.length; i++) {
|
|
102
|
+
const arrayPath = currentPath ? `${currentPath}[${i}]` : `[${i}]`;
|
|
103
|
+
matches.push(...scanValue(value[i], rules, arrayPath, whitelistedKeys, seen));
|
|
104
|
+
}
|
|
105
|
+
return matches;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Handle Objects
|
|
109
|
+
if (typeof value === 'object') {
|
|
110
|
+
seen.add(value);
|
|
111
|
+
|
|
112
|
+
// Get all own property names including non-enumerable or __proto__
|
|
113
|
+
const propNames = Object.getOwnPropertyNames(value);
|
|
114
|
+
for (const propName of propNames) {
|
|
115
|
+
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
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check if prototype was directly manipulated
|
|
129
|
+
try {
|
|
130
|
+
const proto = Object.getPrototypeOf(value);
|
|
131
|
+
if (proto && proto !== Object.prototype && proto.constructor !== Object && typeof proto === 'object') {
|
|
132
|
+
matches.push({
|
|
133
|
+
ruleId: 'PROTOTYPE_POLLUTION',
|
|
134
|
+
ruleName: 'Prototype Pollution Attempt',
|
|
135
|
+
path: currentPath ? `${currentPath}.__proto__` : '__proto__',
|
|
136
|
+
snippet: 'Object has non-standard prototype',
|
|
137
|
+
severity: 'high'
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// Ignore prototype check failures on odd object types
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const [key, val] of Object.entries(value)) {
|
|
145
|
+
// Check if key is whitelisted
|
|
146
|
+
if (whitelistedKeys.includes(key.toLowerCase())) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const childPath = currentPath ? `${currentPath}.${key}` : key;
|
|
151
|
+
matches.push(...scanValue(val, rules, childPath, whitelistedKeys, seen));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return matches;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
DEFAULT_THREAT_PATTERNS,
|
|
160
|
+
scanValue
|
|
161
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ipaddr = require('ipaddr.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes an IP string into a standard ipaddr.IPv4 or ipaddr.IPv6 object.
|
|
7
|
+
* Converts IPv4-mapped IPv6 (e.g. ::ffff:192.168.1.1) to pure IPv4.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} ipString
|
|
10
|
+
* @returns {ipaddr.IPv4|ipaddr.IPv6|null}
|
|
11
|
+
*/
|
|
12
|
+
function parseIp(ipString) {
|
|
13
|
+
if (typeof ipString !== 'string') {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const clean = ipString.trim();
|
|
18
|
+
if (!clean) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
let parsed = ipaddr.parse(clean);
|
|
24
|
+
// Convert IPv4-mapped IPv6 to pure IPv4
|
|
25
|
+
if (parsed.kind() === 'ipv6' && parsed.isIPv4MappedAddress()) {
|
|
26
|
+
parsed = parsed.toIPv4Address();
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parses an IP rule which can be a single IP or CIDR range.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} ruleString
|
|
38
|
+
* @returns {{ type: 'cidr'|'ip', parsed: ipaddr.IPv4|ipaddr.IPv6|Array<any> } | null}
|
|
39
|
+
*/
|
|
40
|
+
function parseRule(ruleString) {
|
|
41
|
+
if (typeof ruleString !== 'string') {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const trimmed = ruleString.trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check if CIDR notation
|
|
51
|
+
if (trimmed.includes('/')) {
|
|
52
|
+
try {
|
|
53
|
+
const cidr = ipaddr.parseCIDR(trimmed);
|
|
54
|
+
return { type: 'cidr', parsed: cidr, raw: trimmed };
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Single IP
|
|
61
|
+
const parsedIp = parseIp(trimmed);
|
|
62
|
+
if (parsedIp) {
|
|
63
|
+
return { type: 'ip', parsed: parsedIp, raw: trimmed };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Checks if a given IP address matches a compiled rule.
|
|
71
|
+
*
|
|
72
|
+
* @param {ipaddr.IPv4|ipaddr.IPv6} clientIp
|
|
73
|
+
* @param {Object} rule
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
function matchesRule(clientIp, rule) {
|
|
77
|
+
if (!clientIp || !rule) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (rule.type === 'ip') {
|
|
82
|
+
return clientIp.toString() === rule.parsed.toString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (rule.type === 'cidr') {
|
|
86
|
+
try {
|
|
87
|
+
return clientIp.match(rule.parsed);
|
|
88
|
+
} catch {
|
|
89
|
+
// Kind mismatch (e.g. matching IPv6 client against IPv4 CIDR)
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Checks if a client IP matches any rule in an array of rules.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} clientIpStr
|
|
101
|
+
* @param {Array<Object>} compiledRules
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
function isIpInRules(clientIpStr, compiledRules) {
|
|
105
|
+
const parsedClient = parseIp(clientIpStr);
|
|
106
|
+
if (!parsedClient) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const rule of compiledRules) {
|
|
111
|
+
if (matchesRule(parsedClient, rule)) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Compiles a list of IP and CIDR rule strings into parsed rule objects.
|
|
121
|
+
*
|
|
122
|
+
* @param {string[]} ruleStrings
|
|
123
|
+
* @returns {Array<Object>}
|
|
124
|
+
*/
|
|
125
|
+
function compileRules(ruleStrings = []) {
|
|
126
|
+
if (!Array.isArray(ruleStrings)) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return ruleStrings
|
|
131
|
+
.map((r) => parseRule(r))
|
|
132
|
+
.filter((r) => r !== null);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Safely extracts client IP according to proxy configuration.
|
|
137
|
+
* Never blindly trusts X-Forwarded-For unless explicitly configured.
|
|
138
|
+
*
|
|
139
|
+
* @param {import('express').Request} req
|
|
140
|
+
* @param {Object} [options]
|
|
141
|
+
* @param {boolean|string[]|number|Function} [options.trustProxy=false]
|
|
142
|
+
* @returns {string} Extracted client IP
|
|
143
|
+
*/
|
|
144
|
+
function getClientIp(req, options = {}) {
|
|
145
|
+
const trustProxy = options.trustProxy !== undefined ? options.trustProxy : false;
|
|
146
|
+
|
|
147
|
+
// Custom function
|
|
148
|
+
if (typeof options.getIp === 'function') {
|
|
149
|
+
return options.getIp(req);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const directIp = (req.socket && req.socket.remoteAddress) ||
|
|
153
|
+
(req.connection && req.connection.remoteAddress) ||
|
|
154
|
+
'127.0.0.1';
|
|
155
|
+
|
|
156
|
+
if (!trustProxy) {
|
|
157
|
+
// When trustProxy is disabled, ONLY use direct socket address
|
|
158
|
+
const parsed = parseIp(directIp);
|
|
159
|
+
return parsed ? parsed.toString() : directIp;
|
|
160
|
+
}
|
|
161
|
+
|
|
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
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// If X-Forwarded-For is present and proxy is trusted
|
|
171
|
+
const xForwardedFor = req.headers && req.headers['x-forwarded-for'];
|
|
172
|
+
if (xForwardedFor) {
|
|
173
|
+
const rawList = Array.isArray(xForwardedFor) ? xForwardedFor.join(',') : xForwardedFor;
|
|
174
|
+
const ips = rawList.split(',').map((s) => s.trim()).filter(Boolean);
|
|
175
|
+
|
|
176
|
+
if (ips.length > 0) {
|
|
177
|
+
if (typeof trustProxy === 'number') {
|
|
178
|
+
// Trust specified number of hops from the right
|
|
179
|
+
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;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (Array.isArray(trustProxy)) {
|
|
186
|
+
// Trust only if directIp is in the trusted proxy list
|
|
187
|
+
const trustedRules = compileRules(trustProxy);
|
|
188
|
+
if (isIpInRules(directIp, trustedRules)) {
|
|
189
|
+
// Find first untrusted IP walking backwards from right to left
|
|
190
|
+
for (let i = ips.length - 1; i >= 0; i--) {
|
|
191
|
+
if (!isIpInRules(ips[i], trustedRules)) {
|
|
192
|
+
const parsed = parseIp(ips[i]);
|
|
193
|
+
return parsed ? parsed.toString() : ips[i];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
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;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Boolean true fallback: take the first valid IP
|
|
207
|
+
const parsed = parseIp(ips[0]);
|
|
208
|
+
return parsed ? parsed.toString() : ips[0];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const parsed = parseIp(directIp);
|
|
213
|
+
return parsed ? parsed.toString() : directIp;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
parseIp,
|
|
218
|
+
parseRule,
|
|
219
|
+
matchesRule,
|
|
220
|
+
isIpInRules,
|
|
221
|
+
compileRules,
|
|
222
|
+
getClientIp
|
|
223
|
+
};
|
|
224
|
+
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory store for rate limiting with automatic background cleanup.
|
|
5
|
+
*/
|
|
6
|
+
class MemoryStore {
|
|
7
|
+
/**
|
|
8
|
+
* @param {Object} [options]
|
|
9
|
+
* @param {number} [options.windowMs=60000] - Window duration in milliseconds.
|
|
10
|
+
* @param {number} [options.cleanupIntervalMs=60000] - How often expired keys are purged.
|
|
11
|
+
*/
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.windowMs = options.windowMs || 60000;
|
|
14
|
+
this.hits = new Map();
|
|
15
|
+
|
|
16
|
+
const cleanupMs = options.cleanupIntervalMs || Math.min(this.windowMs, 60000);
|
|
17
|
+
this.interval = setInterval(() => this.cleanup(), cleanupMs);
|
|
18
|
+
// Unref so the cleanup interval does not block Node process exit
|
|
19
|
+
if (this.interval.unref) {
|
|
20
|
+
this.interval.unref();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Called by express-rate-limit with middleware options.
|
|
26
|
+
* @param {Object} options
|
|
27
|
+
*/
|
|
28
|
+
init(options) {
|
|
29
|
+
if (options && options.windowMs) {
|
|
30
|
+
this.windowMs = options.windowMs;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Purge expired rate limit entries.
|
|
36
|
+
*/
|
|
37
|
+
cleanup() {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
for (const [key, record] of this.hits.entries()) {
|
|
40
|
+
if (record.resetTime <= now) {
|
|
41
|
+
this.hits.delete(key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Increment hits for a key.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} key
|
|
50
|
+
* @returns {Promise<{ totalHits: number, resetTime: Date }>}
|
|
51
|
+
*/
|
|
52
|
+
async increment(key) {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
let record = this.hits.get(key);
|
|
55
|
+
|
|
56
|
+
if (!record || record.resetTime <= now) {
|
|
57
|
+
record = {
|
|
58
|
+
totalHits: 1,
|
|
59
|
+
resetTime: now + this.windowMs
|
|
60
|
+
};
|
|
61
|
+
this.hits.set(key, record);
|
|
62
|
+
} else {
|
|
63
|
+
record.totalHits += 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
totalHits: record.totalHits,
|
|
68
|
+
resetTime: new Date(record.resetTime)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decrement hits for a key.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} key
|
|
76
|
+
* @returns {Promise<void>}
|
|
77
|
+
*/
|
|
78
|
+
async decrement(key) {
|
|
79
|
+
const record = this.hits.get(key);
|
|
80
|
+
if (record && record.totalHits > 0) {
|
|
81
|
+
record.totalHits -= 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reset hits for a single key.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} key
|
|
89
|
+
* @returns {Promise<void>}
|
|
90
|
+
*/
|
|
91
|
+
async resetKey(key) {
|
|
92
|
+
this.hits.delete(key);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reset all keys in memory.
|
|
97
|
+
*
|
|
98
|
+
* @returns {Promise<void>}
|
|
99
|
+
*/
|
|
100
|
+
async resetAll() {
|
|
101
|
+
this.hits.clear();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Retrieve current hits for a key without incrementing.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} key
|
|
108
|
+
* @returns {Promise<{ totalHits: number, resetTime: Date } | undefined>}
|
|
109
|
+
*/
|
|
110
|
+
async get(key) {
|
|
111
|
+
const record = this.hits.get(key);
|
|
112
|
+
if (!record || record.resetTime <= Date.now()) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
totalHits: record.totalHits,
|
|
117
|
+
resetTime: new Date(record.resetTime)
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Clean up timer on teardown.
|
|
123
|
+
*/
|
|
124
|
+
shutdown() {
|
|
125
|
+
if (this.interval) {
|
|
126
|
+
clearInterval(this.interval);
|
|
127
|
+
}
|
|
128
|
+
this.hits.clear();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
MemoryStore
|
|
134
|
+
};
|
|
135
|
+
|