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.
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ rateLimiter: {
5
+ windowMs: 15 * 60 * 1000, // 15 minutes
6
+ maxRequests: 100,
7
+ statusCode: 429,
8
+ message: 'Too many requests, please try again later.',
9
+ standardHeaders: 'draft-7',
10
+ legacyHeaders: false,
11
+ store: 'memory'
12
+ },
13
+
14
+ apiKeyAuth: {
15
+ headerName: 'x-api-key',
16
+ queryParam: null, // by default, disallow API key in query strings (security best practice)
17
+ allowBearer: true, // allow Authorization: Bearer <key>
18
+ hashAlgorithm: 'sha256',
19
+ statusCode: 401,
20
+ message: 'Unauthorized: Invalid or missing API key.',
21
+ forbiddenStatusCode: 403,
22
+ forbiddenMessage: 'Forbidden: API key has expired or has been revoked.'
23
+ },
24
+
25
+ ipFilter: {
26
+ mode: 'blocklist', // 'allowlist' | 'blocklist'
27
+ allowlist: [],
28
+ blocklist: [],
29
+ trustProxy: false,
30
+ statusCode: 403,
31
+ message: 'Forbidden: Access denied for your IP address.'
32
+ },
33
+
34
+ requestSize: {
35
+ maxSize: '1mb',
36
+ statusCode: 413,
37
+ message: 'Payload Too Large: Request body exceeds maximum allowed size.'
38
+ },
39
+
40
+ securityHeaders: {
41
+ contentSecurityPolicy: {
42
+ directives: {
43
+ defaultSrc: ["'self'"],
44
+ baseUri: ["'self'"],
45
+ fontSrc: ["'self'", 'https:', 'data:'],
46
+ formAction: ["'self'"],
47
+ frameAncestors: ["'self'"],
48
+ imgSrc: ["'self'", 'data:'],
49
+ objectSrc: ["'none'"],
50
+ scriptSrc: ["'self'"],
51
+ scriptSrcAttr: ["'none'"],
52
+ styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
53
+ upgradeInsecureRequests: []
54
+ }
55
+ },
56
+ crossOriginEmbedderPolicy: false,
57
+ crossOriginOpenerPolicy: { policy: 'same-origin' },
58
+ crossOriginResourcePolicy: { policy: 'same-origin' },
59
+ dnsPrefetchControl: { allow: false },
60
+ frameguard: { action: 'deny' },
61
+ hidePoweredBy: true,
62
+ hsts: {
63
+ maxAge: 31536000, // 1 year
64
+ includeSubDomains: true,
65
+ preload: true
66
+ },
67
+ ieNoOpen: true,
68
+ noSniff: true,
69
+ originAgentCluster: true,
70
+ permittedCrossDomainPolicies: { permittedPolicies: 'none' },
71
+ referrerPolicy: { policy: 'no-referrer' },
72
+ xssFilter: true
73
+ },
74
+
75
+ requestValidator: {
76
+ statusCode: 400,
77
+ message: 'Validation failed: Invalid request parameters.'
78
+ },
79
+
80
+ threatDetector: {
81
+ action: 'reject', // 'reject' | 'log' | 'pass'
82
+ statusCode: 403,
83
+ message: 'Forbidden: Suspicious request pattern detected.',
84
+ inspectUrl: true,
85
+ inspectQuery: true,
86
+ inspectBody: true,
87
+ inspectHeaders: false,
88
+ whitelistedPaths: [],
89
+ customRules: []
90
+ },
91
+
92
+ logger: {
93
+ enabled: true,
94
+ level: 'warn',
95
+ redactFields: []
96
+ }
97
+ };
98
+
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ const bytes = require('bytes');
4
+ const { ConfigurationError } = require('../utils/errors');
5
+
6
+ /**
7
+ * Validate Rate Limiter configuration options.
8
+ *
9
+ * @param {Object} options
10
+ */
11
+ function validateRateLimiterConfig(options = {}) {
12
+ if (options.windowMs !== undefined) {
13
+ if (typeof options.windowMs !== 'number' || !Number.isFinite(options.windowMs) || options.windowMs <= 0) {
14
+ throw new ConfigurationError('RateLimiter: "windowMs" must be a positive number of milliseconds.');
15
+ }
16
+ }
17
+
18
+ const max = options.maxRequests !== undefined ? options.maxRequests : options.max;
19
+ if (max !== undefined) {
20
+ if (typeof max !== 'number' || !Number.isFinite(max) || max < 0) {
21
+ throw new ConfigurationError('RateLimiter: "maxRequests" (or "max") must be a non-negative number.');
22
+ }
23
+ }
24
+
25
+ if (options.statusCode !== undefined) {
26
+ if (typeof options.statusCode !== 'number' || options.statusCode < 100 || options.statusCode > 599) {
27
+ throw new ConfigurationError('RateLimiter: "statusCode" must be a valid HTTP status code between 100 and 599.');
28
+ }
29
+ }
30
+
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
+ }
38
+ }
39
+
40
+ /**
41
+ * Validate API Key Auth configuration options.
42
+ *
43
+ * @param {Object} options
44
+ */
45
+ function validateApiKeyAuthConfig(options = {}) {
46
+ if (options.headerName !== undefined) {
47
+ if (typeof options.headerName !== 'string' || options.headerName.trim().length === 0) {
48
+ throw new ConfigurationError('ApiKeyAuth: "headerName" must be a non-empty string.');
49
+ }
50
+ }
51
+
52
+ if (options.queryParam !== undefined && options.queryParam !== null) {
53
+ if (typeof options.queryParam !== 'string' || options.queryParam.trim().length === 0) {
54
+ throw new ConfigurationError('ApiKeyAuth: "queryParam" must be a non-empty string or null.');
55
+ }
56
+ }
57
+
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
+ }
62
+ }
63
+
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
+ }
71
+ }
72
+
73
+ /**
74
+ * Validate IP Filter configuration options.
75
+ *
76
+ * @param {Object} options
77
+ */
78
+ 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
+ }
83
+ }
84
+
85
+ if (options.allowlist !== undefined && !Array.isArray(options.allowlist)) {
86
+ throw new ConfigurationError('IpFilter: "allowlist" must be an array of IP strings or CIDR notations.');
87
+ }
88
+
89
+ if (options.blocklist !== undefined && !Array.isArray(options.blocklist)) {
90
+ throw new ConfigurationError('IpFilter: "blocklist" must be an array of IP strings or CIDR notations.');
91
+ }
92
+
93
+ if (options.getIp !== undefined && typeof options.getIp !== 'function') {
94
+ throw new ConfigurationError('IpFilter: "getIp" must be a function.');
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validate Request Size configuration options.
100
+ *
101
+ * @param {Object} options
102
+ */
103
+ function validateRequestSizeConfig(options = {}) {
104
+ if (options.maxSize !== undefined) {
105
+ if (typeof options.maxSize === 'string') {
106
+ const parsed = bytes.parse(options.maxSize);
107
+ if (parsed === null || parsed <= 0) {
108
+ throw new ConfigurationError(`RequestSize: "maxSize" string "${options.maxSize}" cannot be parsed into a valid byte limit.`);
109
+ }
110
+ } else if (typeof options.maxSize === 'number') {
111
+ if (!Number.isFinite(options.maxSize) || options.maxSize <= 0) {
112
+ throw new ConfigurationError('RequestSize: "maxSize" number must be greater than 0 bytes.');
113
+ }
114
+ } else {
115
+ throw new ConfigurationError('RequestSize: "maxSize" must be a number of bytes or a valid byte string (e.g. "1mb", "500kb").');
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Validate Threat Detector configuration options.
122
+ *
123
+ * @param {Object} options
124
+ */
125
+ 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
+ }
130
+ }
131
+
132
+ if (options.customRules !== undefined && !Array.isArray(options.customRules)) {
133
+ throw new ConfigurationError('ThreatDetector: "customRules" must be an array of rule objects or functions.');
134
+ }
135
+
136
+ if (options.whitelistedPaths !== undefined && !Array.isArray(options.whitelistedPaths)) {
137
+ throw new ConfigurationError('ThreatDetector: "whitelistedPaths" must be an array of strings or RegExps.');
138
+ }
139
+ }
140
+
141
+ module.exports = {
142
+ validateRateLimiterConfig,
143
+ validateApiKeyAuthConfig,
144
+ validateIpFilterConfig,
145
+ validateRequestSizeConfig,
146
+ validateThreatDetectorConfig
147
+ };
148
+
package/src/index.js ADDED
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ // Middlewares
4
+ const { rateLimiter, createRateLimiter, defaultKeyGenerator } = require('./middleware/rateLimiter');
5
+ const { apiKeyAuth, createApiKeyAuth, extractApiKey } = require('./middleware/apiKeyAuth');
6
+ const { ipFilter, createIpFilter } = require('./middleware/ipFilter');
7
+ const { requestSize, createRequestSize, parseContentLength } = require('./middleware/requestSize');
8
+ const { securityHeaders, createSecurityHeaders } = require('./middleware/securityHeaders');
9
+ const { requestValidator, createRequestValidator, formatIssues } = require('./middleware/requestValidator');
10
+ const { threatDetector, createThreatDetector } = require('./middleware/threatDetector');
11
+
12
+ // Security Utilities
13
+ const {
14
+ hashApiKey,
15
+ generateApiKey,
16
+ secureCompare,
17
+ isKeyExpired
18
+ } = require('./security/apiKey');
19
+
20
+ const {
21
+ parseIp,
22
+ parseRule,
23
+ matchesRule,
24
+ isIpInRules,
25
+ compileRules,
26
+ getClientIp
27
+ } = require('./security/ipAddress');
28
+
29
+ const {
30
+ DEFAULT_THREAT_PATTERNS,
31
+ scanValue
32
+ } = require('./security/detector');
33
+
34
+ // Stores
35
+ const { MemoryStore } = require('./stores/memoryStore');
36
+ const { RedisStore } = require('./stores/redisStore');
37
+
38
+ // Configuration
39
+ const defaults = require('./config/defaults');
40
+ const validateConfig = require('./config/validateConfig');
41
+
42
+ // Utilities and Errors
43
+ const {
44
+ SecurityError,
45
+ RateLimitError,
46
+ AuthenticationError,
47
+ ForbiddenError,
48
+ PayloadTooLargeError,
49
+ ValidationError,
50
+ ThreatDetectedError,
51
+ ConfigurationError
52
+ } = require('./utils/errors');
53
+
54
+ const { sendSecurityResponse } = require('./utils/response');
55
+ const {
56
+ SecurityLogger,
57
+ defaultLogger,
58
+ redact,
59
+ getRequestId,
60
+ DEFAULT_SENSITIVE_KEYS
61
+ } = require('./utils/logger');
62
+
63
+ module.exports = {
64
+ // Primary Middlewares
65
+ rateLimiter,
66
+ createRateLimiter,
67
+ defaultKeyGenerator,
68
+
69
+ apiKeyAuth,
70
+ createApiKeyAuth,
71
+ extractApiKey,
72
+
73
+ ipFilter,
74
+ createIpFilter,
75
+
76
+ requestSize,
77
+ createRequestSize,
78
+ parseContentLength,
79
+
80
+ securityHeaders,
81
+ createSecurityHeaders,
82
+
83
+ requestValidator,
84
+ createRequestValidator,
85
+ formatIssues,
86
+
87
+ threatDetector,
88
+ createThreatDetector,
89
+
90
+ // Security Helpers
91
+ generateApiKey,
92
+ hashApiKey,
93
+ secureCompare,
94
+ isKeyExpired,
95
+
96
+ parseIp,
97
+ parseRule,
98
+ matchesRule,
99
+ isIpInRules,
100
+ compileRules,
101
+ getClientIp,
102
+
103
+ DEFAULT_THREAT_PATTERNS,
104
+ scanValue,
105
+
106
+ // Stores
107
+ MemoryStore,
108
+ RedisStore,
109
+
110
+ // Configuration
111
+ defaults,
112
+ validateConfig,
113
+
114
+ // Errors
115
+ SecurityError,
116
+ RateLimitError,
117
+ AuthenticationError,
118
+ ForbiddenError,
119
+ PayloadTooLargeError,
120
+ ValidationError,
121
+ ThreatDetectedError,
122
+ ConfigurationError,
123
+
124
+ // Utilities
125
+ sendSecurityResponse,
126
+ SecurityLogger,
127
+ defaultLogger,
128
+ redact,
129
+ getRequestId,
130
+ DEFAULT_SENSITIVE_KEYS
131
+ };
132
+
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+
3
+ const defaults = require('../config/defaults');
4
+ const { validateApiKeyAuthConfig } = require('../config/validateConfig');
5
+ const { hashApiKey, secureCompare, isKeyExpired } = require('../security/apiKey');
6
+ const { AuthenticationError, ForbiddenError } = require('../utils/errors');
7
+ const { sendSecurityResponse } = require('../utils/response');
8
+ const { defaultLogger } = require('../utils/logger');
9
+
10
+ /**
11
+ * Extracts API key from request headers or query parameters.
12
+ *
13
+ * @param {import('express').Request} req
14
+ * @param {Object} options
15
+ * @returns {string|null}
16
+ */
17
+ function extractApiKey(req, options) {
18
+ const headerName = options.headerName.toLowerCase();
19
+
20
+ // 1. Direct header match
21
+ if (req.headers && req.headers[headerName]) {
22
+ const val = req.headers[headerName];
23
+ return Array.isArray(val) ? val[0].trim() : String(val).trim();
24
+ }
25
+
26
+ // 2. Authorization header (Bearer or Api-Key)
27
+ if (options.allowBearer && req.headers && req.headers.authorization) {
28
+ const authHeader = String(req.headers.authorization).trim();
29
+ const bearerMatch = authHeader.match(/^(?:Bearer|Api-Key)\s+(.+)$/i);
30
+ if (bearerMatch) {
31
+ return bearerMatch[1].trim();
32
+ }
33
+ }
34
+
35
+ // 3. Query parameter (only if explicitly enabled)
36
+ if (options.queryParam && req.query && req.query[options.queryParam]) {
37
+ const qVal = req.query[options.queryParam];
38
+ return Array.isArray(qVal) ? qVal[0].trim() : String(qVal).trim();
39
+ }
40
+
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Creates API key authentication middleware.
46
+ *
47
+ * @param {Object} [userOptions]
48
+ * @param {string} [userOptions.headerName='x-api-key'] - Header name to check.
49
+ * @param {string|null} [userOptions.queryParam=null] - Query parameter name (if allowed).
50
+ * @param {boolean} [userOptions.allowBearer=true] - Allow Authorization: Bearer <key>.
51
+ * @param {string} [userOptions.hashAlgorithm='sha256'] - Hash algorithm.
52
+ * @param {Array<string|Object>} [userOptions.keys] - List of keys or key objects.
53
+ * @param {Function} [userOptions.getKey] - Async function (hash, req) => keyRecord.
54
+ * @param {Function} [userOptions.verifyKey] - Custom async verification function (rawKey, req).
55
+ * @param {string[]} [userOptions.requiredPermissions] - Optional permissions required for this route.
56
+ * @param {number} [userOptions.statusCode=401] - Status code for authentication failure.
57
+ * @param {number} [userOptions.forbiddenStatusCode=403] - Status code for expired/forbidden key.
58
+ * @param {string} [userOptions.message] - Custom error message for invalid key.
59
+ * @param {string} [userOptions.forbiddenMessage] - Custom error message for expired/forbidden key.
60
+ * @param {Function} [userOptions.handler] - Custom response handler.
61
+ * @param {import('../utils/logger').SecurityLogger} [userOptions.logger] - Logger instance.
62
+ * @returns {import('express').RequestHandler}
63
+ */
64
+ function createApiKeyAuth(userOptions = {}) {
65
+ validateApiKeyAuthConfig(userOptions);
66
+
67
+ const options = {
68
+ ...defaults.apiKeyAuth,
69
+ ...userOptions
70
+ };
71
+
72
+ const logger = options.logger || defaultLogger;
73
+
74
+ // Pre-process keys if static list provided
75
+ // Stored format: array of { hash, id, name, expiresAt, permissions, metadata }
76
+ let precomputedKeys = [];
77
+ if (Array.isArray(options.keys)) {
78
+ precomputedKeys = options.keys.map((item, idx) => {
79
+ if (typeof item === 'string') {
80
+ // Assume raw string key provided; hash it
81
+ return {
82
+ id: `key_${idx + 1}`,
83
+ hash: hashApiKey(item, options.hashAlgorithm),
84
+ expiresAt: null,
85
+ permissions: []
86
+ };
87
+ } else if (typeof item === 'object' && item !== null) {
88
+ // If already hashed (item.hash), use it; otherwise hash item.key
89
+ const hash = item.hash || (item.key ? hashApiKey(item.key, options.hashAlgorithm) : null);
90
+ return {
91
+ id: item.id || `key_${idx + 1}`,
92
+ name: item.name,
93
+ hash,
94
+ expiresAt: item.expiresAt || null,
95
+ permissions: Array.isArray(item.permissions) ? item.permissions : [],
96
+ metadata: item.metadata || {}
97
+ };
98
+ }
99
+ return null;
100
+ }).filter(Boolean);
101
+ }
102
+
103
+ return async function apiKeyAuthMiddleware(req, res, next) {
104
+ const rawKey = extractApiKey(req, options);
105
+
106
+ if (!rawKey) {
107
+ logger.warn('API_KEY_AUTH_FAILED', 'API key missing in request', { req });
108
+ const error = new AuthenticationError(options.message);
109
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
110
+ }
111
+
112
+ try {
113
+ // 1. Custom verify function
114
+ if (typeof options.verifyKey === 'function') {
115
+ const verifyResult = await options.verifyKey(rawKey, req);
116
+ if (!verifyResult) {
117
+ logger.warn('API_KEY_AUTH_FAILED', 'API key verification rejected by verifyKey', { req });
118
+ const error = new AuthenticationError(options.message);
119
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
120
+ }
121
+
122
+ 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();
136
+ }
137
+
138
+ // Compute hash of incoming key
139
+ const incomingHash = hashApiKey(rawKey, options.hashAlgorithm);
140
+ let matchedRecord = null;
141
+
142
+ // 2. Custom getKey lookup function
143
+ if (typeof options.getKey === 'function') {
144
+ const retrieved = await options.getKey(incomingHash, req);
145
+ if (retrieved) {
146
+ const storedHash = retrieved.hash || (retrieved.key ? hashApiKey(retrieved.key, options.hashAlgorithm) : null);
147
+ if (storedHash && secureCompare(incomingHash, storedHash)) {
148
+ matchedRecord = retrieved;
149
+ }
150
+ }
151
+ } else {
152
+ // 3. Search precomputed static keys using constant-time comparison
153
+ for (const candidate of precomputedKeys) {
154
+ if (candidate.hash && secureCompare(incomingHash, candidate.hash)) {
155
+ matchedRecord = candidate;
156
+ break;
157
+ }
158
+ }
159
+ }
160
+
161
+ if (!matchedRecord) {
162
+ logger.warn('API_KEY_AUTH_FAILED', 'Invalid API key provided', { req });
163
+ const error = new AuthenticationError(options.message);
164
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
165
+ }
166
+
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();
198
+ } catch (err) {
199
+ logger.error('API_KEY_AUTH_ERROR', `Internal error during API key verification: ${err.message}`, { req });
200
+ return next(err);
201
+ }
202
+ };
203
+ }
204
+
205
+ module.exports = {
206
+ apiKeyAuth: createApiKeyAuth,
207
+ createApiKeyAuth,
208
+ extractApiKey
209
+ };
210
+
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const defaults = require('../config/defaults');
4
+ const { validateIpFilterConfig } = require('../config/validateConfig');
5
+ const { compileRules, isIpInRules, getClientIp } = require('../security/ipAddress');
6
+ const { ForbiddenError } = require('../utils/errors');
7
+ const { sendSecurityResponse } = require('../utils/response');
8
+ const { defaultLogger } = require('../utils/logger');
9
+
10
+ /**
11
+ * Creates IP filtering middleware for Express.
12
+ *
13
+ * @param {Object} [userOptions]
14
+ * @param {'allowlist'|'blocklist'} [userOptions.mode='blocklist']
15
+ * @param {string[]} [userOptions.allowlist=[]] - Array of IPs or CIDRs to allow.
16
+ * @param {string[]} [userOptions.blocklist=[]] - Array of IPs or CIDRs to block.
17
+ * @param {boolean|string[]|number} [userOptions.trustProxy=false] - Proxy trusting strategy.
18
+ * @param {Function} [userOptions.getIp] - Custom IP extraction function.
19
+ * @param {number} [userOptions.statusCode=403] - Status code on rejection.
20
+ * @param {string} [userOptions.message] - Custom error message.
21
+ * @param {Function} [userOptions.handler] - Custom response handler.
22
+ * @param {import('../utils/logger').SecurityLogger} [userOptions.logger] - Logger instance.
23
+ * @returns {import('express').RequestHandler}
24
+ */
25
+ function createIpFilter(userOptions = {}) {
26
+ validateIpFilterConfig(userOptions);
27
+
28
+ const options = {
29
+ ...defaults.ipFilter,
30
+ ...userOptions
31
+ };
32
+
33
+ const logger = options.logger || defaultLogger;
34
+
35
+ // Pre-compile IP and CIDR rules once at initialization
36
+ const compiledAllowlist = compileRules(options.allowlist);
37
+ const compiledBlocklist = compileRules(options.blocklist);
38
+
39
+ return function ipFilterMiddleware(req, res, next) {
40
+ const clientIp = getClientIp(req, options);
41
+
42
+ if (options.mode === 'allowlist') {
43
+ const allowed = isIpInRules(clientIp, compiledAllowlist);
44
+ if (!allowed) {
45
+ logger.warn('IP_ACCESS_DENIED', `IP ${clientIp} not in allowlist`, {
46
+ req,
47
+ ip: clientIp,
48
+ details: { mode: 'allowlist' }
49
+ });
50
+ const error = new ForbiddenError(options.message);
51
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
52
+ }
53
+ } else {
54
+ // Default: blocklist mode
55
+ const blocked = isIpInRules(clientIp, compiledBlocklist);
56
+ if (blocked) {
57
+ logger.warn('IP_BLOCKED', `IP ${clientIp} is blocklisted`, {
58
+ req,
59
+ ip: clientIp,
60
+ details: { mode: 'blocklist' }
61
+ });
62
+ const error = new ForbiddenError(options.message);
63
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
64
+ }
65
+ }
66
+
67
+ // Attach verified client IP to request for downstream handlers
68
+ req.clientIp = clientIp;
69
+ return next();
70
+ };
71
+ }
72
+
73
+ module.exports = {
74
+ ipFilter: createIpFilter,
75
+ createIpFilter
76
+ };
77
+