@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 +99 -18
- package/index.js +187 -165
- package/package.json +6 -6
- package/types/index.d.ts +19 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
+
## Key Features
|
|
14
16
|
|
|
15
|
-
- Automatic sanitization of potentially dangerous MongoDB operators and special characters
|
|
16
|
-
- Multiple operation modes (auto
|
|
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
|
-
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
##
|
|
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
|
-
|
|
159
|
+
fastify.register(fastifyMongoSanitize, { mode: 'manual' });
|
|
103
160
|
|
|
104
|
-
fastify.
|
|
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 ©
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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))
|
|
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) =>
|
|
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))
|
|
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
|
-
|
|
78
|
+
const { arrayOptions, debug } = options;
|
|
79
|
+
const originalLength = arr.length;
|
|
145
80
|
|
|
146
|
-
|
|
147
|
-
|
|
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))
|
|
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
|
-
|
|
165
|
-
if (allowedKeys?.length && !allowedKeys.includes(key)) return acc;
|
|
128
|
+
log(debug, 'trace', 'OBJECT', `Sanitizing object with ${originalKeys.length} keys`);
|
|
166
129
|
|
|
167
|
-
|
|
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 (
|
|
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)
|
|
162
|
+
if (removeEmpty && !sanitizedKey) {
|
|
163
|
+
log(debug, 'debug', 'OBJECT', `Empty key removed after sanitization`);
|
|
164
|
+
return acc;
|
|
165
|
+
}
|
|
179
166
|
|
|
180
|
-
if (
|
|
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 =
|
|
181
|
+
const sanitizedValue =
|
|
182
|
+
!options.recursive && (isPlainObject(value) || isArray(value)) ? value : sanitizeValue(value, options, true);
|
|
183
183
|
|
|
184
|
-
if (removeEmpty && !sanitizedValue)
|
|
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 (
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
45
|
+
"packageManager": "yarn@4.9.2",
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"fastify": "npm:fastify@5.
|
|
48
|
-
"fastify4": "npm:fastify@4.
|
|
49
|
-
"prettier": "^3.
|
|
50
|
-
"tsd": "^0.
|
|
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
|
-
|
|
5
|
+
removeMatches?: boolean;
|
|
6
|
+
removeKeyMatches?: boolean;
|
|
7
|
+
removeValueMatches?: boolean;
|
|
8
|
+
sanitizeObjects?: string[];
|
|
6
9
|
mode?: 'auto' | 'manual';
|
|
7
10
|
skipRoutes?: string[];
|
|
8
|
-
customSanitizer?: (
|
|
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;
|