@exortek/fastify-mongo-sanitize 1.1.0 → 1.2.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/README.md CHANGED
@@ -1,24 +1,29 @@
1
1
  # @exortek/fastify-mongo-sanitize
2
2
 
3
- A comprehensive Fastify plugin designed to protect your MongoDB queries from injection attacks by sanitizing request
4
- data. This plugin provides flexible sanitization options for request bodies, parameters, and query strings.
3
+ A comprehensive Fastify plugin designed to protect your MongoDB queries from injection attacks by sanitizing request data.
4
+ Flexible options for request bodies, parameters, and query strings.
5
+ Supports JavaScript & TypeScript.
5
6
 
6
- ### Compatibility
7
+
8
+ ## Compatibility
7
9
 
8
10
  | Plugin version | Fastify version |
9
11
  |----------------|:---------------:|
10
12
  | `^1.x` | `^4.x` |
11
13
  | `^1.x` | `^5.x` |
12
14
 
13
- ### Key Features
15
+ ## Key Features
14
16
 
15
- - Automatic sanitization of potentially dangerous MongoDB operators and special characters.
16
- - Multiple operation modes (auto, manual)
17
+ - Automatic sanitization of potentially dangerous MongoDB operators and special characters
18
+ - Multiple operation modes (**auto**, **manual**)
19
+ - **Recursive** or single-level sanitization control
17
20
  - Customizable sanitization patterns and replacement strategies
18
- - Support for nested objects and arrays
19
21
  - Configurable string and array handling options
20
- - Skip routes functionality
21
- - Custom sanitizer support
22
+ - Skip routes functionality with normalization
23
+ - Allowed/denied key whitelisting/blacklisting
24
+ - **Custom sanitizer** function support
25
+ - Full TypeScript types & Fastify request augmentation
26
+ - Detailed debug and logging options
22
27
 
23
28
  ## Installation
24
29
 
@@ -32,6 +37,12 @@ OR
32
37
  yarn add @exortek/fastify-mongo-sanitize
33
38
  ```
34
39
 
40
+ OR
41
+
42
+ ```bash
43
+ pnpm add @exortek/fastify-mongo-sanitize
44
+ ```
45
+
35
46
  ## Usage
36
47
 
37
48
  Register the plugin with Fastify and specify the desired options.
@@ -42,12 +53,36 @@ const fastifyMongoSanitize = require('@exortek/fastify-mongo-sanitize');
42
53
 
43
54
  fastify.register(fastifyMongoSanitize);
44
55
 
45
- fastify.listen(3000, (err, address) => {
56
+ fastify.post('/api', async (req, reply) => {
57
+ // sanitized request.body, request.query, and request.params
58
+ return req.body;
59
+ });
60
+
61
+ fastify.listen({ port: 3000 }, (err, address) => {
46
62
  if (err) {
47
63
  fastify.log.error(err);
48
64
  process.exit(1);
49
65
  }
50
- fastify.log.info(`Server listening on ${address}`);
66
+ fastify.log.info(`Server listening at ${address}`);
67
+ });
68
+ ```
69
+
70
+ ## TypeScript Usage
71
+
72
+ ```typescript
73
+ import fastify from 'fastify';
74
+ import mongoSanitize from '@exortek/fastify-mongo-sanitize';
75
+
76
+ const app = fastify();
77
+
78
+ app.register(mongoSanitize, {
79
+ recursive: false,
80
+ debug: { enabled: true, level: 'debug' },
81
+ });
82
+
83
+ app.post('/test', async (req, reply) => {
84
+ req.sanitize?.(); // TS'de otomatik olarak görünür!
85
+ return req.body;
51
86
  });
52
87
  ```
53
88
 
@@ -61,9 +96,10 @@ options:
61
96
  | Option | Type | Default | Description |
62
97
  |-------------------|----------------|----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
63
98
  | `replaceWith` | string | `''` | The string to replace the matched patterns with. Default is an empty string. If you want to replace the matched patterns with a different string, you can set this option. |
99
+ | 'removeMatches' | boolean | `false` | Remove the matched patterns. Default is false. If you want to remove the matched patterns instead of replacing them, you can set this option to true. |
64
100
  | `sanitizeObjects` | array | `['body', 'params', 'query']` | The request properties to sanitize. Default is `['body', 'params', 'query']`. You can specify any request property that you want to sanitize. It must be an object. |
65
101
  | `mode` | string | `'auto'` | The mode of operation. Default is 'auto'. You can set this option to 'auto', 'manual'. If you set it to 'auto', the plugin will automatically sanitize the request objects. If you set it to 'manual', you can sanitize the request objects manually using the request.sanitize() method. |
66
- | `skipRoutes` | array | `[]` | An array of routes to skip. Default is an empty array. If you want to skip certain routes from sanitization, you can specify the routes here. The routes must be in the format `/path`. For example, `['/health', '/metrics']`. |
102
+ | `skipRoutes` | array | `[]` | An array of routes to skip. All entries and incoming request paths are normalized (leading/trailing slashes removed, query and fragment ignored). For example, adding `'/health'` will skip `/health`, `/health/`, and `/health?ping=1`. | |
67
103
  | `customSanitizer` | function\|null | `null` | A custom sanitizer function. Default is null. If you want to use a custom sanitizer function, you can specify it here. The function must accept two arguments: the original data and the options object. It must return the sanitized data. |
68
104
  | `recursive` | boolean | `true` | Enable recursive sanitization. Default is true. If you want to recursively sanitize the nested objects, you can set this option to true. |
69
105
  | `removeEmpty` | boolean | `false` | Remove empty values. Default is false. If you want to remove empty values after sanitization, you can set this option to true. |
@@ -72,6 +108,22 @@ options:
72
108
  | `deniedKeys` | array\|null | `null` | An array of denied keys. Default is null. If you want to deny certain keys in the object, you can specify the keys here. The keys must be strings. If a key is in the deniedKeys array, it will be removed. |
73
109
  | `stringOptions` | object | `{ trim: false,lowercase: false,maxLength: null }` | An object that controls string sanitization behavior. Default is an empty object. You can specify the following options: `trim`, `lowercase`, `maxLength`. |
74
110
  | `arrayOptions` | object | `{ filterNull: false, distinct: false}` | An object that controls array sanitization behavior. Default is an empty object. You can specify the following options: `filterNull`, `distinct`. |
111
+ | `debug` | object | `{ enabled: false, level: 'info' }` | Logging/debug options. |
112
+
113
+ > **Note on skipRoutes matching:**
114
+ > All skipRoutes entries and request URLs are normalized before matching. This means:
115
+ > - Trailing and leading slashes (`/path`, `/path/`, `///path//`) are treated as the same.
116
+ > - Query strings and fragments are ignored (`/foo?bar=1`, `/foo#anchor` → `/foo`).
117
+ >
118
+ > For example, if you set `skipRoutes: ['/api/users']`, then all of the following will be skipped:
119
+ > - `/api/users`
120
+ > - `/api/users/`
121
+ > - `/api/users?role=admin`
122
+ > - `/api/users#tab`
123
+ >
124
+ > **Fastify's default behavior:**
125
+ > Fastify treats `/foo` and `/foo/` as different routes. This plugin normalizes skipRoutes for skipping purposes only.
126
+ > Make sure you have defined both routes in Fastify if you want both to respond.
75
127
 
76
128
  ## String Options
77
129
 
@@ -96,24 +148,50 @@ The `arrayOptions` object controls array sanitization behavior:
96
148
  }
97
149
  ```
98
150
 
99
- ## Example Configuration
151
+ ## Operation Modes
152
+
153
+ ### Mode: `auto`
154
+ Sanitization is performed automatically on every request for the configured properties `(body, params, query)`.
155
+
156
+ ### Mode: `manual`
100
157
 
101
158
  ```javascript
102
- const fastify = require('fastify')();
159
+ fastify.register(fastifyMongoSanitize, { mode: 'manual' });
103
160
 
104
- fastify.register(require('fastify-mongo-sanitize'), {
161
+ fastify.post('/api', async (req, reply) => {
162
+ req.sanitize(); // Manual trigger!
163
+ // ...
164
+ });
165
+ ```
166
+
167
+ ## Recursive Option
168
+ By default, `recursive` is `true`—all nested arrays and objects are sanitized.
169
+ To only sanitize the first level (top-level keys/values), set:
170
+
171
+ ## Example Full Configuration
172
+
173
+ ```javascript
174
+ fastify.register(require('@exortek/fastify-mongo-sanitize'), {
105
175
  replaceWith: '_',
106
176
  mode: 'manual',
107
177
  skipRoutes: ['/health', '/metrics'],
108
178
  recursive: true,
109
179
  removeEmpty: true,
180
+ removeMatches: true, // Remove dangerous patterns completely
110
181
  stringOptions: {
111
182
  trim: true,
112
- maxLength: 100
183
+ maxLength: 100,
113
184
  },
114
185
  arrayOptions: {
115
186
  filterNull: true,
116
- distinct: true
187
+ distinct: true,
188
+ },
189
+ debug: {
190
+ enabled: true,
191
+ level: 'debug',
192
+ logPatternMatches: true,
193
+ logSanitizedValues: true,
194
+ logSkippedRoutes: true,
117
195
  }
118
196
  });
119
197
  ```
@@ -127,8 +205,11 @@ fastify.register(require('fastify-mongo-sanitize'), {
127
205
  - String length limiting (`maxLength`) only applies to string values, not keys
128
206
  - Array options are applied after all other sanitization steps
129
207
 
208
+ > removeEmpty: Removes all falsy values ('', 0, false, null, undefined).
209
+ > Adjust this behavior if you need to preserve values like 0 or false.
210
+
130
211
  ## License
131
212
 
132
213
  **[MIT](https://github.com/ExorTek/fastify-mongo-sanitize/blob/master/LICENSE)**<br>
133
214
 
134
- Copyright © 2024 ExorTek
215
+ Copyright © 2025 ExorTek
package/index.js CHANGED
@@ -1,113 +1,20 @@
1
1
  'use strict';
2
2
 
3
3
  const fp = require('fastify-plugin');
4
-
5
- /**
6
- * Collection of regular expression patterns used for sanitization
7
- * @constant {RegExp[]}
8
- */
9
- const PATTERNS = Object.freeze([
10
- /[\$]/g, // Finds all '$' (dollar) characters in the text.
11
- /\./g, // Finds all '.' (dot) characters in the text.
12
- /[\\\/{}.(*+?|[\]^)]/g, // Finds special characters (\, /, {, }, (, ., *, +, ?, |, [, ], ^, )) that need to be escaped.
13
- /[\u0000-\u001F\u007F-\u009F]/g, // Finds ASCII control characters (0x00-0x1F and 0x7F-0x9F range).
14
- /\{\s*\$|\$?\{(.|\r?\n)*\}/g, // Finds placeholders or variables in the format `${...}` or `{ $... }`.
15
- ]);
16
-
17
- /**
18
- * Default configuration options for the plugin
19
- * @constant {Object}
20
- */
21
- const DEFAULT_OPTIONS = Object.freeze({
22
- replaceWith: '', // The string to replace the matched patterns with. Default is an empty string. If you want to replace the matched patterns with a different string, you can set this option.
23
- removeMatches: false, // Remove the matched patterns. Default is false. If you want to remove the matched patterns instead of replacing them, you can set this option to true.
24
- sanitizeObjects: ['body', 'params', 'query'], // The request properties to sanitize. Default is ['body', 'params', 'query']. You can specify any request property that you want to sanitize. It must be an object.
25
- mode: 'auto', // The mode of operation. Default is 'auto'. You can set this option to 'auto', 'manual'. If you set it to 'auto', the plugin will automatically sanitize the request objects. If you set it to 'manual', you can sanitize the request objects manually using the request.sanitize() method.
26
- skipRoutes: [], // An array of routes to skip. Default is an empty array. If you want to skip certain routes from sanitization, you can specify the routes here. The routes must be in the format '/path'. For example, ['/health', '/metrics'].
27
- customSanitizer: null, // A custom sanitizer function. Default is null. If you want to use a custom sanitizer function, you can specify it here. The function must accept two arguments: the original data and the options object. It must return the sanitized data.
28
- recursive: true, // Enable recursive sanitization. Default is true. If you want to recursively sanitize the nested objects, you can set this option to true.
29
- removeEmpty: false, // Remove empty values. Default is false. If you want to remove empty values after sanitization, you can set this option to true.
30
- patterns: PATTERNS, // An array of patterns to match. Default is an array of patterns that match illegal characters and sequences. You can specify your own patterns if you want to match different characters or sequences. Each pattern must be a regular expression.
31
- allowedKeys: [], // An array of allowed keys. Default is array. If you want to allow only certain keys in the object, you can specify the keys here. The keys must be strings. If a key is not in the allowedKeys array, it will be removed.
32
- deniedKeys: [], // An array of denied keys. Default is array. If you want to deny certain keys in the object, you can specify the keys here. The keys must be strings. If a key is in the deniedKeys array, it will be removed.
33
- stringOptions: {
34
- // String sanitization options.
35
- trim: false, // Trim whitespace. Default is false. If you want to trim leading and trailing whitespace from the string, you can set this option to true.
36
- lowercase: false, // Convert to lowercase. Default is false. If you want to convert the string to lowercase, you can set this option to true.
37
- maxLength: null, // Maximum length. Default is null. If you want to limit the maximum length of the string, you can set this option to a number. If the string length exceeds the maximum length, it will be truncated.
38
- },
39
- arrayOptions: {
40
- // Array sanitization options.
41
- filterNull: false, // Filter null values. Default is false. If you want to remove null values from the array, you can set this option to true.
42
- distinct: false, // Remove duplicate values. Default is false. If you want to remove duplicate values from the array, you can set this option to true.
43
- },
44
- });
45
-
46
- /**
47
- * Checks if value is a valid email address
48
- * @param {string} val - Value to check
49
- * @returns {boolean} True if value is a valid email address
50
- */
51
- const isEmail = (val) => /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/i.test(val);
52
-
53
- /**
54
- * Checks if value is a string
55
- * @param {*} value - Value to check
56
- * @returns {boolean} True if value is string
57
- */
58
- const isString = (value) => typeof value === 'string';
59
-
60
- /**
61
- * Checks if value is a plain object
62
- * @param {*} obj - Value to check
63
- * @returns {boolean} True if value is plain object
64
- */
65
- const isPlainObject = (obj) => !!obj && Object.prototype.toString.call(obj) === '[object Object]';
66
-
67
- /**
68
- * Checks if value is an array
69
- * @param {*} value - Value to check
70
- * @returns {boolean} True if value is array
71
- */
72
- const isArray = (value) => Array.isArray(value);
73
-
74
- /**
75
- * Checks if value is a primitive (null, number, or boolean)
76
- * @param {*} value - Value to check
77
- * @returns {boolean} True if value is primitive
78
- */
79
- const isPrimitive = (value) => value === null || ['number', 'boolean'].includes(typeof value);
80
-
81
- /**
82
- * Checks if value is a Date object
83
- * @param {*} value - Value to check
84
- * @returns {boolean} True if value is Date
85
- */
86
- const isDate = (value) => value instanceof Date;
87
-
88
- /**
89
- * Checks if value is a function
90
- * @param {*} value - Value to check
91
- * @returns {boolean} True if value is function
92
- */
93
- const isFunction = (value) => typeof value === 'function';
94
-
95
- /**
96
- * Error class for FastifyMongoSanitize
97
- */
98
- class FastifyMongoSanitizeError extends Error {
99
- /**
100
- * Creates a new FastifyMongoSanitizeError
101
- * @param {string} message - Error message
102
- * @param {string} [type='generic'] - Error type
103
- */
104
- constructor(message, type = 'generic') {
105
- super(message);
106
- this.name = 'FastifyMongoSanitizeError';
107
- this.type = type;
108
- Error.captureStackTrace(this, this.constructor);
109
- }
110
- }
4
+ const {
5
+ isString,
6
+ isArray,
7
+ isPlainObject,
8
+ isPrimitive,
9
+ isDate,
10
+ isEmail,
11
+ cleanUrl,
12
+ startTiming,
13
+ log,
14
+ validateOptions,
15
+ } = require('./helpers');
16
+ const FastifyMongoSanitizeError = require('./FastifyMongoSanitizeError');
17
+ const { DEFAULT_OPTIONS } = require('./constants');
111
18
 
112
19
  /**
113
20
  * Sanitizes a string value according to provided options
@@ -117,16 +24,40 @@ class FastifyMongoSanitizeError extends Error {
117
24
  * @returns {string} Sanitized string
118
25
  */
119
26
  const sanitizeString = (str, options, isValue = false) => {
120
- if (!isString(str) || isEmail(str)) return str;
27
+ if (!isString(str) || isEmail(str)) {
28
+ log(options.debug, 'trace', 'STRING', `Skipping sanitization (not string or is email): ${typeof str}`);
29
+ return str;
30
+ }
121
31
 
122
- const { replaceWith, patterns, stringOptions } = options;
32
+ const { replaceWith, patterns, stringOptions, debug } = options;
33
+ const originalStr = str;
34
+ let matchedPatterns = [];
123
35
 
124
- let result = patterns.reduce((acc, pattern) => acc.replace(pattern, replaceWith), str);
36
+ let result = patterns.reduce((acc, pattern, index) => {
37
+ const matches = acc.match(pattern);
38
+ if (matches) {
39
+ matchedPatterns.push({ patternIndex: index, matches: matches.length });
40
+ log(debug, 'debug', 'STRING', `Pattern ${index} matched ${matches.length} times in string`);
41
+ }
42
+ return acc.replace(pattern, replaceWith);
43
+ }, str);
125
44
 
126
45
  if (stringOptions.trim) result = result.trim();
127
46
  if (stringOptions.lowercase) result = result.toLowerCase();
128
47
  if (stringOptions.maxLength && isValue) result = result.slice(0, stringOptions.maxLength);
129
48
 
49
+ if (debug.logSanitizedValues && originalStr !== result) {
50
+ log(debug, 'debug', 'STRING', 'String sanitized', {
51
+ original: originalStr,
52
+ sanitized: result,
53
+ matchedPatterns,
54
+ });
55
+ }
56
+
57
+ if (debug.logPatternMatches && matchedPatterns.length > 0) {
58
+ log(debug, 'info', 'PATTERN', `Patterns matched in string`, matchedPatterns);
59
+ }
60
+
130
61
  return result;
131
62
  };
132
63
 
@@ -138,13 +69,41 @@ const sanitizeString = (str, options, isValue = false) => {
138
69
  * @throws {FastifyMongoSanitizeError} If input is not an array
139
70
  */
140
71
  const sanitizeArray = (arr, options) => {
141
- if (!isArray(arr)) throw new FastifyMongoSanitizeError('Input must be an array', 'type_error');
72
+ if (!isArray(arr)) {
73
+ const error = new FastifyMongoSanitizeError('Input must be an array', 'type_error');
74
+ log(options.debug, 'error', 'ARRAY', `Sanitization failed: ${error.message}`);
75
+ throw error;
76
+ }
142
77
 
143
- const { arrayOptions } = options;
144
- let result = arr.map((item) => sanitizeValue(item, options));
78
+ const { arrayOptions, debug } = options;
79
+ const originalLength = arr.length;
145
80
 
146
- if (arrayOptions.filterNull) result = result.filter(Boolean);
147
- if (arrayOptions.distinct) result = [...new Set(result)];
81
+ log(debug, 'trace', 'ARRAY', `Sanitizing array with ${originalLength} items`);
82
+
83
+ let result = arr.map((item, index) => {
84
+ log(debug, 'trace', 'ARRAY', `Sanitizing item ${index}`);
85
+ return !options.recursive && (isPlainObject(item) || isArray(item)) ? item : sanitizeValue(item, options);
86
+ });
87
+
88
+ if (arrayOptions.filterNull) {
89
+ const beforeFilter = result.length;
90
+ result = result.filter(Boolean);
91
+ const filtered = beforeFilter - result.length;
92
+ if (filtered > 0) {
93
+ log(debug, 'debug', 'ARRAY', `Filtered ${filtered} null/falsy values`);
94
+ }
95
+ }
96
+
97
+ if (arrayOptions.distinct) {
98
+ const beforeDistinct = result.length;
99
+ result = [...new Set(result)];
100
+ const duplicates = beforeDistinct - result.length;
101
+ if (duplicates > 0) {
102
+ log(debug, 'debug', 'ARRAY', `Removed ${duplicates} duplicate values`);
103
+ }
104
+ }
105
+
106
+ log(debug, 'trace', 'ARRAY', `Array sanitization completed: ${originalLength} -> ${result.length} items`);
148
107
 
149
108
  return result;
150
109
  };
@@ -157,35 +116,84 @@ const sanitizeArray = (arr, options) => {
157
116
  * @throws {FastifyMongoSanitizeError} If input is not an object
158
117
  */
159
118
  const sanitizeObject = (obj, options) => {
160
- if (!isPlainObject(obj)) throw new FastifyMongoSanitizeError('Input must be an object', 'type_error');
119
+ if (!isPlainObject(obj)) {
120
+ const error = new FastifyMongoSanitizeError('Input must be an object', 'type_error');
121
+ log(options.debug, 'error', 'OBJECT', `Sanitization failed: ${error.message}`);
122
+ throw error;
123
+ }
161
124
 
162
- const { removeEmpty, allowedKeys, deniedKeys, removeMatches, patterns } = options;
125
+ const { removeEmpty, allowedKeys, deniedKeys, removeMatches, patterns, debug } = options;
126
+ const originalKeys = Object.keys(obj);
163
127
 
164
- return Object.entries(obj).reduce((acc, [key, value]) => {
165
- if (allowedKeys?.length && !allowedKeys.includes(key)) return acc;
128
+ log(debug, 'trace', 'OBJECT', `Sanitizing object with ${originalKeys.length} keys`);
166
129
 
167
- if (deniedKeys?.length && deniedKeys.includes(key)) return acc;
130
+ const result = Object.entries(obj).reduce((acc, [key, value]) => {
131
+ if (allowedKeys && allowedKeys.length && !allowedKeys.includes(key)) {
132
+ log(debug, 'debug', 'OBJECT', `Key '${key}' not in allowedKeys, removing`);
133
+ return acc;
134
+ }
135
+
136
+ if (deniedKeys && deniedKeys.length && deniedKeys.includes(key)) {
137
+ log(debug, 'debug', 'OBJECT', `Key '${key}' in deniedKeys, removing`);
138
+ return acc;
139
+ }
168
140
 
169
141
  const sanitizedKey = sanitizeString(key, options, false);
170
142
 
171
143
  if (isString(value) && isEmail(value)) {
144
+ log(debug, 'trace', 'OBJECT', `Preserving email value for key '${key}'`);
172
145
  acc[sanitizedKey] = value;
173
146
  return acc;
174
147
  }
175
148
 
176
- if (removeMatches && patterns.some((pattern) => pattern.test(key))) return acc;
149
+ if (
150
+ removeMatches &&
151
+ patterns.some((pattern) => {
152
+ const matches = pattern.test(key);
153
+ if (matches) {
154
+ log(debug, 'debug', 'OBJECT', `Key '${key}' matches removal pattern`);
155
+ }
156
+ return matches;
157
+ })
158
+ ) {
159
+ return acc;
160
+ }
177
161
 
178
- if (removeEmpty && !sanitizedKey) return acc;
162
+ if (removeEmpty && !sanitizedKey) {
163
+ log(debug, 'debug', 'OBJECT', `Empty key removed after sanitization`);
164
+ return acc;
165
+ }
179
166
 
180
- if (removeMatches && isString(value) && patterns.some((pattern) => pattern.test(value))) return acc;
167
+ if (
168
+ removeMatches &&
169
+ isString(value) &&
170
+ patterns.some((pattern) => {
171
+ const matches = pattern.test(value);
172
+ if (matches) {
173
+ log(debug, 'debug', 'OBJECT', `Value for key '${key}' matches removal pattern`);
174
+ }
175
+ return matches;
176
+ })
177
+ ) {
178
+ return acc;
179
+ }
181
180
 
182
- const sanitizedValue = sanitizeValue(value, options, true);
181
+ const sanitizedValue =
182
+ !options.recursive && (isPlainObject(value) || isArray(value)) ? value : sanitizeValue(value, options, true);
183
183
 
184
- if (removeEmpty && !sanitizedValue) return acc;
184
+ if (removeEmpty && !sanitizedValue) {
185
+ log(debug, 'debug', 'OBJECT', `Empty value removed for key '${key}'`);
186
+ return acc;
187
+ }
185
188
 
186
189
  acc[sanitizedKey] = sanitizedValue;
187
190
  return acc;
188
191
  }, {});
192
+
193
+ const finalKeys = Object.keys(result);
194
+ log(debug, 'trace', 'OBJECT', `Object sanitization completed: ${originalKeys.length} -> ${finalKeys.length} keys`);
195
+
196
+ return result;
189
197
  };
190
198
 
191
199
  /**
@@ -196,58 +204,48 @@ const sanitizeObject = (obj, options) => {
196
204
  * @returns {*} Sanitized value
197
205
  */
198
206
  const sanitizeValue = (value, options, isValue) => {
199
- if (!value || isPrimitive(value) || isDate(value)) return value;
200
- if (isPlainObject(value)) return sanitizeObject(value, options);
201
- if (isArray(value)) return sanitizeArray(value, options);
207
+ if (value == null || isPrimitive(value) || isDate(value)) return value;
202
208
  if (isString(value)) return sanitizeString(value, options, isValue);
209
+ if (isArray(value)) return sanitizeArray(value, options);
210
+ if (isPlainObject(value)) return sanitizeObject(value, options);
203
211
  return value;
204
212
  };
205
213
 
206
- /**
207
- * Validates plugin options
208
- * @param {Object} options - Options to validate
209
- * @throws {FastifyMongoSanitizeError} If any option is invalid
210
- */
211
- const validateOptions = (options) => {
212
- const validators = {
213
- replaceWith: isString,
214
- removeMatches: isPrimitive,
215
- sanitizeObjects: isArray,
216
- mode: (value) => ['auto', 'manual'].includes(value),
217
- skipRoutes: isArray,
218
- customSanitizer: (value) => value === null || isFunction(value),
219
- recursive: isPrimitive,
220
- removeEmpty: isPrimitive,
221
- patterns: isArray,
222
- allowedKeys: (value) => value === null || isArray(value),
223
- deniedKeys: (value) => value === null || isArray(value),
224
- stringOptions: isPlainObject,
225
- arrayOptions: isPlainObject,
226
- };
227
-
228
- for (const [key, validate] of Object.entries(validators)) {
229
- if (!validate(options[key])) {
230
- throw new FastifyMongoSanitizeError(`Invalid configuration: ${key}`, 'type_error');
231
- }
232
- }
233
- };
234
-
235
214
  /**
236
215
  * Handles request sanitization
237
216
  * @param {Object} request - Fastify request object
238
217
  * @param {Object} options - Sanitization options
239
218
  */
240
219
  const handleRequest = (request, options) => {
241
- const { sanitizeObjects, customSanitizer } = options;
220
+ const { sanitizeObjects, customSanitizer, debug } = options;
221
+ const endTiming = startTiming(debug, 'Request Sanitization');
222
+
223
+ log(debug, 'info', 'REQUEST', `Sanitizing request: ${request.method} ${request.url}`);
242
224
 
243
225
  for (const sanitizeObject of sanitizeObjects) {
244
226
  if (request[sanitizeObject]) {
227
+ log(debug, 'debug', 'REQUEST', `Sanitizing ${sanitizeObject}`, request[sanitizeObject]);
228
+
245
229
  const originalRequest = Object.assign({}, request[sanitizeObject]);
246
- request[sanitizeObject] = customSanitizer
247
- ? customSanitizer(originalRequest)
248
- : sanitizeValue(originalRequest, options);
230
+
231
+ if (customSanitizer) {
232
+ log(debug, 'debug', 'REQUEST', `Using custom sanitizer for ${sanitizeObject}`);
233
+ request[sanitizeObject] = customSanitizer(originalRequest);
234
+ } else {
235
+ request[sanitizeObject] = sanitizeValue(originalRequest, options);
236
+ }
237
+
238
+ if (debug.logSanitizedValues) {
239
+ log(debug, 'debug', 'REQUEST', `${sanitizeObject} sanitized`, {
240
+ before: originalRequest,
241
+ after: request[sanitizeObject],
242
+ });
243
+ }
249
244
  }
250
245
  }
246
+
247
+ endTiming();
248
+ log(debug, 'info', 'REQUEST', `Request sanitization completed`);
251
249
  };
252
250
 
253
251
  /**
@@ -259,24 +257,48 @@ const handleRequest = (request, options) => {
259
257
  const fastifyMongoSanitize = (fastify, options, done) => {
260
258
  const opt = { ...DEFAULT_OPTIONS, ...options };
261
259
 
260
+ log(opt.debug, 'info', 'PLUGIN', 'Initializing fastify-mongo-sanitize plugin', {
261
+ mode: opt.mode,
262
+ sanitizeObjects: opt.sanitizeObjects,
263
+ skipRoutes: opt.skipRoutes,
264
+ debugLevel: opt.debug.level,
265
+ });
266
+
262
267
  validateOptions(opt);
263
268
 
264
- const skipRoutes = new Set(opt.skipRoutes);
269
+ const skipRoutes = new Set((opt.skipRoutes || []).map(cleanUrl));
270
+ log(opt.debug, 'debug', 'PLUGIN', `Skip routes configured: ${skipRoutes.size} routes`);
265
271
 
266
272
  if (opt.mode === 'manual') {
267
- fastify.decorateRequest('sanitize', function (options) {
268
- handleRequest(this, { ...opt, ...options });
273
+ log(opt.debug, 'info', 'PLUGIN', 'Manual mode enabled - decorating request with sanitize method');
274
+
275
+ fastify.decorateRequest('sanitize', function (options = {}) {
276
+ const mergedOptions = { ...opt, ...options };
277
+ log(mergedOptions.debug, 'info', 'MANUAL', 'Manual sanitization triggered');
278
+ handleRequest(this, mergedOptions);
269
279
  });
270
280
  }
271
281
 
272
282
  if (opt.mode === 'auto') {
283
+ log(opt.debug, 'info', 'PLUGIN', 'Auto mode enabled - adding preHandler hook');
284
+
273
285
  fastify.addHook('preHandler', (request, reply, done) => {
274
- if (skipRoutes.has(request.url)) return done();
286
+ if (skipRoutes.size) {
287
+ const url = cleanUrl(request.url);
288
+ if (skipRoutes.has(url)) {
289
+ if (opt.debug.logSkippedRoutes) {
290
+ log(opt.debug, 'info', 'SKIP', `Route skipped: ${request.method} ${request.url}`);
291
+ }
292
+ return done();
293
+ }
294
+ }
295
+
275
296
  handleRequest(request, opt);
276
297
  done();
277
298
  });
278
299
  }
279
300
 
301
+ log(opt.debug, 'info', 'PLUGIN', 'Plugin initialization completed');
280
302
  done();
281
303
  };
282
304
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exortek/fastify-mongo-sanitize",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "MongoDB query sanitizer for Fastify",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -42,12 +42,12 @@
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "packageManager": "yarn@4.5.1",
45
+ "packageManager": "yarn@4.9.2",
46
46
  "devDependencies": {
47
- "fastify": "npm:fastify@5.1.0",
48
- "fastify4": "npm:fastify@4.28.1",
49
- "prettier": "^3.3.3",
50
- "tsd": "^0.31.2"
47
+ "fastify": "npm:fastify@5.3.3",
48
+ "fastify4": "npm:fastify@4.29.1",
49
+ "prettier": "^3.5.3",
50
+ "tsd": "^0.32.0"
51
51
  },
52
52
  "dependencies": {
53
53
  "fastify-plugin": "^5.0.1"
package/types/index.d.ts CHANGED
@@ -2,10 +2,13 @@ import type { FastifyPluginCallback } from 'fastify';
2
2
 
3
3
  export interface FastifyMongoSanitizeOptions {
4
4
  replaceWith?: string;
5
- sanitizeObjects?: ('body' | 'params' | 'query')[];
5
+ removeMatches?: boolean;
6
+ removeKeyMatches?: boolean;
7
+ removeValueMatches?: boolean;
8
+ sanitizeObjects?: string[];
6
9
  mode?: 'auto' | 'manual';
7
10
  skipRoutes?: string[];
8
- customSanitizer?: ((data: any) => any) | null;
11
+ customSanitizer?: (original: any, options: FastifyMongoSanitizeOptions) => any;
9
12
  recursive?: boolean;
10
13
  removeEmpty?: boolean;
11
14
  patterns?: RegExp[];
@@ -20,6 +23,13 @@ export interface FastifyMongoSanitizeOptions {
20
23
  filterNull?: boolean;
21
24
  distinct?: boolean;
22
25
  };
26
+ debug?: {
27
+ enabled?: boolean;
28
+ level?: 'silent' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
29
+ logPatternMatches?: boolean;
30
+ logSanitizedValues?: boolean;
31
+ logSkippedRoutes?: boolean;
32
+ };
23
33
  }
24
34
 
25
35
  declare class FastifyMongoSanitizeError extends Error {
@@ -28,6 +38,13 @@ declare class FastifyMongoSanitizeError extends Error {
28
38
  type: string;
29
39
  }
30
40
 
41
+ import 'fastify';
42
+ declare module 'fastify' {
43
+ interface FastifyRequest {
44
+ sanitize?(options?: FastifyMongoSanitizeOptions): void;
45
+ }
46
+ }
47
+
31
48
  declare const fastifyMongoSanitize: FastifyPluginCallback<FastifyMongoSanitizeOptions>;
32
49
 
33
50
  export default fastifyMongoSanitize;