secure-api-guard 0.0.0-stage → 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/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 +136 -0
- package/src/index.js +134 -0
- package/src/middleware/apiKeyAuth.js +197 -0
- package/src/middleware/ipFilter.js +77 -0
- package/src/middleware/rateLimiter.js +116 -0
- package/src/middleware/requestSize.js +146 -0
- package/src/middleware/requestValidator.js +163 -0
- package/src/middleware/securityHeaders.js +59 -0
- package/src/middleware/threatDetector.js +130 -0
- package/src/security/apiKey.js +90 -0
- package/src/security/detector.js +159 -0
- package/src/security/ipAddress.js +219 -0
- package/src/stores/memoryStore.js +135 -0
- package/src/stores/redisStore.js +229 -0
- package/src/types/index.d.ts +314 -0
- package/src/utils/errors.js +88 -0
- package/src/utils/logger.js +215 -0
- package/src/utils/response.js +58 -0
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
return !Number.isNaN(expiresTime) && Date.now() > expiresTime;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
hashApiKey,
|
|
86
|
+
generateApiKey,
|
|
87
|
+
secureCompare,
|
|
88
|
+
isKeyExpired
|
|
89
|
+
};
|
|
90
|
+
|
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
* 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
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Recursively inspects values in strings, arrays, or objects for threat signatures.
|
|
80
|
+
*
|
|
81
|
+
* @param {any} value - Value to inspect.
|
|
82
|
+
* @param {Array<Object>} rules - Compiled threat rules.
|
|
83
|
+
* @param {string} [currentPath=''] - Current object path for error reporting.
|
|
84
|
+
* @param {string[]} [whitelistedKeys=[]] - Keys to skip inspection.
|
|
85
|
+
* @param {Set<any>} [seen] - Visited objects to prevent infinite loops.
|
|
86
|
+
* @returns {Array<{ ruleId: string, ruleName: string, path: string, snippet: string, severity: string }>}
|
|
87
|
+
*/
|
|
88
|
+
function scanValue(value, rules, currentPath = '', whitelistedKeys = [], seen = new Set()) {
|
|
89
|
+
const matches = [];
|
|
90
|
+
|
|
91
|
+
if (value === null || value === undefined || seen.has(value)) {
|
|
92
|
+
return matches;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Handle strings
|
|
96
|
+
if (typeof value === 'string') {
|
|
97
|
+
for (const rule of rules) {
|
|
98
|
+
const match = matchRule(rule, value, currentPath);
|
|
99
|
+
if (match) matches.push(match);
|
|
100
|
+
}
|
|
101
|
+
return matches;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Handle Arrays
|
|
105
|
+
if (Array.isArray(value)) {
|
|
106
|
+
seen.add(value);
|
|
107
|
+
for (let i = 0; i < value.length; i++) {
|
|
108
|
+
const arrayPath = currentPath ? `${currentPath}[${i}]` : `[${i}]`;
|
|
109
|
+
matches.push(...scanValue(value[i], rules, arrayPath, whitelistedKeys, seen));
|
|
110
|
+
}
|
|
111
|
+
return matches;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Handle Objects
|
|
115
|
+
if (typeof value === 'object') {
|
|
116
|
+
seen.add(value);
|
|
117
|
+
|
|
118
|
+
// Get all own property names including non-enumerable or __proto__
|
|
119
|
+
const propNames = Object.getOwnPropertyNames(value);
|
|
120
|
+
for (const propName of propNames) {
|
|
121
|
+
const keyPath = currentPath ? `${currentPath}.${propName}` : propName;
|
|
122
|
+
for (const rule of rules) {
|
|
123
|
+
const match = matchRule(rule, propName, keyPath, 'Key name');
|
|
124
|
+
if (match) matches.push(match);
|
|
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
|
+
if (whitelistedKeys.includes(key.toLowerCase())) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const childPath = currentPath ? `${currentPath}.${key}` : key;
|
|
149
|
+
matches.push(...scanValue(val, rules, childPath, whitelistedKeys, seen));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return matches;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
DEFAULT_THREAT_PATTERNS,
|
|
158
|
+
scanValue
|
|
159
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
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
|
+
* 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
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Safely extracts client IP according to proxy configuration.
|
|
149
|
+
* Never blindly trusts X-Forwarded-For unless explicitly configured.
|
|
150
|
+
*
|
|
151
|
+
* @param {import('express').Request} req
|
|
152
|
+
* @param {Object} [options]
|
|
153
|
+
* @param {boolean|string[]|number|Function} [options.trustProxy=false]
|
|
154
|
+
* @returns {string} Extracted client IP
|
|
155
|
+
*/
|
|
156
|
+
function getClientIp(req, options = {}) {
|
|
157
|
+
const trustProxy = options.trustProxy !== undefined ? options.trustProxy : false;
|
|
158
|
+
|
|
159
|
+
// Custom extractor function
|
|
160
|
+
if (typeof options.getIp === 'function') {
|
|
161
|
+
return options.getIp(req);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const directIp = (req.socket && req.socket.remoteAddress) ||
|
|
165
|
+
(req.connection && req.connection.remoteAddress) ||
|
|
166
|
+
'127.0.0.1';
|
|
167
|
+
|
|
168
|
+
if (!trustProxy) {
|
|
169
|
+
return formatIp(directIp);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// If trustProxy is boolean true, prefer express req.ip if set
|
|
173
|
+
if (trustProxy === true && req.ip) {
|
|
174
|
+
return formatIp(req.ip);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const xForwardedFor = req.headers && req.headers['x-forwarded-for'];
|
|
178
|
+
if (xForwardedFor) {
|
|
179
|
+
const rawList = Array.isArray(xForwardedFor) ? xForwardedFor.join(',') : xForwardedFor;
|
|
180
|
+
const ips = rawList.split(',').map((s) => s.trim()).filter(Boolean);
|
|
181
|
+
|
|
182
|
+
if (ips.length > 0) {
|
|
183
|
+
if (typeof trustProxy === 'number') {
|
|
184
|
+
const index = Math.max(0, ips.length - trustProxy);
|
|
185
|
+
return formatIp(ips[index] || directIp);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (Array.isArray(trustProxy)) {
|
|
189
|
+
const trustedRules = compileRules(trustProxy);
|
|
190
|
+
if (isIpInRules(directIp, trustedRules)) {
|
|
191
|
+
// Walk backwards to find first untrusted IP
|
|
192
|
+
for (let i = ips.length - 1; i >= 0; i--) {
|
|
193
|
+
if (!isIpInRules(ips[i], trustedRules)) {
|
|
194
|
+
return formatIp(ips[i]);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return formatIp(ips[0]);
|
|
198
|
+
}
|
|
199
|
+
// Direct socket is not a trusted proxy: ignore spoofed header
|
|
200
|
+
return formatIp(directIp);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return formatIp(ips[0]);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return formatIp(directIp);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
parseIp,
|
|
212
|
+
formatIp,
|
|
213
|
+
parseRule,
|
|
214
|
+
matchesRule,
|
|
215
|
+
isIpInRules,
|
|
216
|
+
compileRules,
|
|
217
|
+
getClientIp
|
|
218
|
+
};
|
|
219
|
+
|
|
@@ -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
|
+
|