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.
@@ -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,136 @@
1
+ 'use strict';
2
+
3
+ const bytes = require('bytes');
4
+ const { ConfigurationError } = require('../utils/errors');
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
+
12
+ /**
13
+ * Validate Rate Limiter configuration options.
14
+ *
15
+ * @param {Object} options
16
+ */
17
+ function validateRateLimiterConfig(options = {}) {
18
+ if (options.windowMs !== undefined) {
19
+ if (typeof options.windowMs !== 'number' || !Number.isFinite(options.windowMs) || options.windowMs <= 0) {
20
+ throw new ConfigurationError('RateLimiter: "windowMs" must be a positive number of milliseconds.');
21
+ }
22
+ }
23
+
24
+ const max = options.maxRequests !== undefined ? options.maxRequests : options.max;
25
+ if (max !== undefined) {
26
+ if (typeof max !== 'number' || !Number.isFinite(max) || max < 0) {
27
+ throw new ConfigurationError('RateLimiter: "maxRequests" (or "max") must be a non-negative number.');
28
+ }
29
+ }
30
+
31
+ if (options.statusCode !== undefined) {
32
+ if (typeof options.statusCode !== 'number' || options.statusCode < 100 || options.statusCode > 599) {
33
+ throw new ConfigurationError('RateLimiter: "statusCode" must be a valid HTTP status code between 100 and 599.');
34
+ }
35
+ }
36
+
37
+ assertFunction(options.keyGenerator, 'RateLimiter: "keyGenerator"');
38
+ assertFunction(options.skip, 'RateLimiter: "skip"');
39
+ }
40
+
41
+ /**
42
+ * Validate API Key Auth configuration options.
43
+ *
44
+ * @param {Object} options
45
+ */
46
+ function validateApiKeyAuthConfig(options = {}) {
47
+ if (options.headerName !== undefined) {
48
+ if (typeof options.headerName !== 'string' || options.headerName.trim().length === 0) {
49
+ throw new ConfigurationError('ApiKeyAuth: "headerName" must be a non-empty string.');
50
+ }
51
+ }
52
+
53
+ if (options.queryParam !== undefined && options.queryParam !== null) {
54
+ if (typeof options.queryParam !== 'string' || options.queryParam.trim().length === 0) {
55
+ throw new ConfigurationError('ApiKeyAuth: "queryParam" must be a non-empty string or null.');
56
+ }
57
+ }
58
+
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.');
61
+ }
62
+
63
+ assertFunction(options.getKey, 'ApiKeyAuth: "getKey"');
64
+ assertFunction(options.verifyKey, 'ApiKeyAuth: "verifyKey"');
65
+ }
66
+
67
+ /**
68
+ * Validate IP Filter configuration options.
69
+ *
70
+ * @param {Object} options
71
+ */
72
+ function validateIpFilterConfig(options = {}) {
73
+ if (options.mode !== undefined && options.mode !== 'allowlist' && options.mode !== 'blocklist') {
74
+ throw new ConfigurationError('IpFilter: "mode" must be either "allowlist" or "blocklist".');
75
+ }
76
+
77
+ if (options.allowlist !== undefined && !Array.isArray(options.allowlist)) {
78
+ throw new ConfigurationError('IpFilter: "allowlist" must be an array of IP strings or CIDR notations.');
79
+ }
80
+
81
+ if (options.blocklist !== undefined && !Array.isArray(options.blocklist)) {
82
+ throw new ConfigurationError('IpFilter: "blocklist" must be an array of IP strings or CIDR notations.');
83
+ }
84
+
85
+ assertFunction(options.getIp, 'IpFilter: "getIp"');
86
+ }
87
+
88
+ /**
89
+ * Validate Request Size configuration options.
90
+ *
91
+ * @param {Object} options
92
+ */
93
+ function validateRequestSizeConfig(options = {}) {
94
+ if (options.maxSize !== undefined) {
95
+ if (typeof options.maxSize === 'string') {
96
+ const parsed = bytes.parse(options.maxSize);
97
+ if (parsed === null || parsed <= 0) {
98
+ throw new ConfigurationError(`RequestSize: "maxSize" string "${options.maxSize}" cannot be parsed into a valid byte limit.`);
99
+ }
100
+ } else if (typeof options.maxSize === 'number') {
101
+ if (!Number.isFinite(options.maxSize) || options.maxSize <= 0) {
102
+ throw new ConfigurationError('RequestSize: "maxSize" number must be greater than 0 bytes.');
103
+ }
104
+ } else {
105
+ throw new ConfigurationError('RequestSize: "maxSize" must be a number of bytes or a valid byte string (e.g. "1mb", "500kb").');
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Validate Threat Detector configuration options.
112
+ *
113
+ * @param {Object} options
114
+ */
115
+ function validateThreatDetectorConfig(options = {}) {
116
+ if (options.action !== undefined && !['reject', 'log', 'pass'].includes(options.action)) {
117
+ throw new ConfigurationError('ThreatDetector: "action" must be "reject", "log", or "pass".');
118
+ }
119
+
120
+ if (options.customRules !== undefined && !Array.isArray(options.customRules)) {
121
+ throw new ConfigurationError('ThreatDetector: "customRules" must be an array of rule objects or functions.');
122
+ }
123
+
124
+ if (options.whitelistedPaths !== undefined && !Array.isArray(options.whitelistedPaths)) {
125
+ throw new ConfigurationError('ThreatDetector: "whitelistedPaths" must be an array of strings or RegExps.');
126
+ }
127
+ }
128
+
129
+ module.exports = {
130
+ validateRateLimiterConfig,
131
+ validateApiKeyAuthConfig,
132
+ validateIpFilterConfig,
133
+ validateRequestSizeConfig,
134
+ validateThreatDetectorConfig
135
+ };
136
+
package/src/index.js ADDED
@@ -0,0 +1,134 @@
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
+ formatIp,
23
+ parseRule,
24
+ matchesRule,
25
+ isIpInRules,
26
+ compileRules,
27
+ getClientIp
28
+ } = require('./security/ipAddress');
29
+
30
+ const {
31
+ DEFAULT_THREAT_PATTERNS,
32
+ scanValue
33
+ } = require('./security/detector');
34
+
35
+ // Stores
36
+ const { MemoryStore } = require('./stores/memoryStore');
37
+ const { RedisStore } = require('./stores/redisStore');
38
+
39
+ // Configuration
40
+ const defaults = require('./config/defaults');
41
+ const validateConfig = require('./config/validateConfig');
42
+
43
+ // Utilities and Errors
44
+ const {
45
+ SecurityError,
46
+ RateLimitError,
47
+ AuthenticationError,
48
+ ForbiddenError,
49
+ PayloadTooLargeError,
50
+ ValidationError,
51
+ ThreatDetectedError,
52
+ ConfigurationError
53
+ } = require('./utils/errors');
54
+
55
+ const { sendSecurityResponse } = require('./utils/response');
56
+ const {
57
+ SecurityLogger,
58
+ defaultLogger,
59
+ redact,
60
+ getRequestId,
61
+ DEFAULT_SENSITIVE_KEYS
62
+ } = require('./utils/logger');
63
+
64
+ module.exports = {
65
+ // Primary Middlewares
66
+ rateLimiter,
67
+ createRateLimiter,
68
+ defaultKeyGenerator,
69
+
70
+ apiKeyAuth,
71
+ createApiKeyAuth,
72
+ extractApiKey,
73
+
74
+ ipFilter,
75
+ createIpFilter,
76
+
77
+ requestSize,
78
+ createRequestSize,
79
+ parseContentLength,
80
+
81
+ securityHeaders,
82
+ createSecurityHeaders,
83
+
84
+ requestValidator,
85
+ createRequestValidator,
86
+ formatIssues,
87
+
88
+ threatDetector,
89
+ createThreatDetector,
90
+
91
+ // Security Helpers
92
+ generateApiKey,
93
+ hashApiKey,
94
+ secureCompare,
95
+ isKeyExpired,
96
+
97
+ parseIp,
98
+ formatIp,
99
+ parseRule,
100
+ matchesRule,
101
+ isIpInRules,
102
+ compileRules,
103
+ getClientIp,
104
+
105
+ DEFAULT_THREAT_PATTERNS,
106
+ scanValue,
107
+
108
+ // Stores
109
+ MemoryStore,
110
+ RedisStore,
111
+
112
+ // Configuration
113
+ defaults,
114
+ validateConfig,
115
+
116
+ // Errors
117
+ SecurityError,
118
+ RateLimitError,
119
+ AuthenticationError,
120
+ ForbiddenError,
121
+ PayloadTooLargeError,
122
+ ValidationError,
123
+ ThreatDetectedError,
124
+ ConfigurationError,
125
+
126
+ // Utilities
127
+ sendSecurityResponse,
128
+ SecurityLogger,
129
+ defaultLogger,
130
+ redact,
131
+ getRequestId,
132
+ DEFAULT_SENSITIVE_KEYS
133
+ };
134
+
@@ -0,0 +1,197 @@
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
+ 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
+
134
+ return async function apiKeyAuthMiddleware(req, res, next) {
135
+ const rawKey = extractApiKey(req, options);
136
+
137
+ if (!rawKey) {
138
+ logger.warn('API_KEY_AUTH_FAILED', 'API key missing in request', { req });
139
+ const error = new AuthenticationError(options.message);
140
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
141
+ }
142
+
143
+ try {
144
+ // 1. Custom verify function
145
+ if (typeof options.verifyKey === 'function') {
146
+ const verifyResult = await options.verifyKey(rawKey, req);
147
+ if (!verifyResult) {
148
+ logger.warn('API_KEY_AUTH_FAILED', 'API key verification rejected by verifyKey', { req });
149
+ const error = new AuthenticationError(options.message);
150
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
151
+ }
152
+
153
+ const keyInfo = typeof verifyResult === 'object' ? verifyResult : {};
154
+ return authorizeKey(keyInfo, req, res, next);
155
+ }
156
+
157
+ // 2. Hash lookup
158
+ const incomingHash = hashApiKey(rawKey, options.hashAlgorithm);
159
+ let matchedRecord = null;
160
+
161
+ if (typeof options.getKey === 'function') {
162
+ const retrieved = await options.getKey(incomingHash, req);
163
+ if (retrieved) {
164
+ const storedHash = retrieved.hash || (retrieved.key ? hashApiKey(retrieved.key, options.hashAlgorithm) : null);
165
+ if (storedHash && secureCompare(incomingHash, storedHash)) {
166
+ matchedRecord = retrieved;
167
+ }
168
+ }
169
+ } else {
170
+ for (const candidate of precomputedKeys) {
171
+ if (candidate.hash && secureCompare(incomingHash, candidate.hash)) {
172
+ matchedRecord = candidate;
173
+ break;
174
+ }
175
+ }
176
+ }
177
+
178
+ if (!matchedRecord) {
179
+ logger.warn('API_KEY_AUTH_FAILED', 'Invalid API key provided', { req });
180
+ const error = new AuthenticationError(options.message);
181
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
182
+ }
183
+
184
+ return authorizeKey(matchedRecord, req, res, next);
185
+ } catch (err) {
186
+ logger.error('API_KEY_AUTH_ERROR', `Internal error during API key verification: ${err.message}`, { req });
187
+ return next(err);
188
+ }
189
+ };
190
+ }
191
+
192
+ module.exports = {
193
+ apiKeyAuth: createApiKeyAuth,
194
+ createApiKeyAuth,
195
+ extractApiKey
196
+ };
197
+
@@ -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
+