@momsfriendlydevco/sift-sieve 2.1.0 → 2.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.
Files changed (3) hide show
  1. package/README.md +35 -0
  2. package/index.js +282 -19
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -41,3 +41,38 @@ Options are passed directly to Sift with the following exceptions:
41
41
  |--------------|-----------|---------|----------------------------------------------------------------------------------------|
42
42
  | `remapArray` | `boolean` | `true` | Whether to transform simple `{key: Array}` keys into `{key: {$in: Array}}` |
43
43
  | `count` | `boolean` | `false` | Whether to return a single Number of the matching elements rather than the found items |
44
+ | `applyWhere` | `boolean` | `false` | Whether to allow `$where` conditions and function values (at any depth) - unless exactly `true` any query containing either throws an error |
45
+ | `applyUnsafeRegex` | `boolean` | `false` | Whether to allow regular expressions prone to catastrophic backtracking (ReDoS), detected via [safe-regex2](https://github.com/fastify/safe-regex2) plus a check for alternation inside large repetitions such as `(a\|a)*` - unless exactly `true` any query containing one throws an error |
46
+ | `allowFields` | `Array<string>` | `null` | If set, only these (dotted) field paths and their sub-paths may be filtered or sorted on. Containers of allowed fields may be queried via `$elemMatch`. Does not restrict `select` |
47
+ | `literalFields` | `Array<string>` | `[]` | Field paths which only accept literal values (strings, numbers, booleans, null or arrays of these) - operators, objects and RegExps are rejected, as are sub-paths of these fields |
48
+ | `maxLimit` | `number` | `0` | If set, the maximum number of results returned, also applied when no `limit` is given. Not applied when `count` is set |
49
+ | `maxDepth` | `number` | `16` | Maximum nesting depth of the query, `0` to disable |
50
+ | `maxKeys` | `number` | `500` | Maximum total number of object keys within the query, `0` to disable |
51
+ | `maxArrayLength` | `number` | `500` | Maximum length of any array within the query, `0` to disable |
52
+ | `maxRegexLength` | `number` | `256` | Maximum length of any regular expression source, applied even if `applyUnsafeRegex` is set, `0` to disable |
53
+
54
+ Queries must be plain objects (or empty), any other type throws an error.
55
+
56
+ The following are also validated:
57
+
58
+ * `limit` / `skip` must be non-negative integers or numeric strings
59
+ * `select` / `sort` must be strings (CSV) or arrays of strings
60
+ * Field paths may never contain the `__proto__`, `constructor` or `prototype` segments
61
+
62
+ Note that `limit` / `skip` are applied before `sort`.
63
+
64
+
65
+ Errors
66
+ ------
67
+ Any invalid, unsafe or disallowed query throws a `SieveQueryError` (exported as `sieve.SieveQueryError`) which has `status` and `statusCode` set to `400` so it can be handed directly to an ExpressJS style error handler. Error messages only ever contain truncated user input and the underlying error (if any) is available as `err.cause`.
68
+
69
+
70
+ Security
71
+ --------
72
+ When feeding user input such as `req.query` or `req.body` directly into sieve:
73
+
74
+ * **Always** set `allowFields` - otherwise any field can be filtered on, letting an attacker infer hidden values (e.g. `?passwordHash[$regex]=^a` with the result count acting as an oracle) even if the field is removed via `select`
75
+ * Set `literalFields` for any field used for lookups or authentication (e.g. tokens) - otherwise `?token[$ne]=x` matches every record
76
+ * Set `maxLimit` to stop bulk extraction
77
+ * Query string values are always strings, `?age=22` will not match the number `22`
78
+ * Repeated keys (`?name=a&name=b`) become arrays which are remapped to `$in` unless `remapArray` is disabled
package/index.js CHANGED
@@ -1,52 +1,313 @@
1
1
  var _ = require('lodash');
2
+ var ret = require('ret');
3
+ var safeRegex = require('safe-regex2');
2
4
  var sift = require('sift');
3
5
 
6
+ /**
7
+ * Error thrown for any invalid, unsafe or disallowed query
8
+ * Carries `status` / `statusCode` of 400 so it can be passed directly to an ExpressJS style error handler
9
+ */
10
+ class SieveQueryError extends Error {
11
+ /**
12
+ * @param {string} message The error message, this should not contain unescaped or untruncated user input
13
+ * @param {Object} [options] Additional Error options
14
+ * @param {Error} [options.cause] The underlying error, if any
15
+ */
16
+ constructor(message, options) {
17
+ super(message, options);
18
+ this.name = 'SieveQueryError';
19
+ this.status = 400;
20
+ this.statusCode = 400;
21
+ }
22
+ }
23
+
24
+
25
+ /**
26
+ * Option keys consumed by sieve which should not be passed onto Sift
27
+ * @type {Array<string>}
28
+ */
29
+ var sieveOptions = ['remapArray', 'count', 'applyWhere', 'applyUnsafeRegex', 'allowFields', 'literalFields', 'maxLimit', 'maxDepth', 'maxKeys', 'maxArrayLength', 'maxRegexLength'];
30
+
31
+
32
+ /**
33
+ * Path segments which are never permitted within a field path as they reach into the prototype chain
34
+ * @type {Array<string>}
35
+ */
36
+ var blockedSegments = ['__proto__', 'constructor', 'prototype'];
37
+
38
+
39
+ /**
40
+ * Logical operators whose values are arrays of sub-queries
41
+ * @type {Array<string>}
42
+ */
43
+ var logicalOperators = ['$and', '$or', '$nor'];
44
+
45
+
46
+ /**
47
+ * Safely quote user input for inclusion within an error message
48
+ * @param {*} value The value to quote
49
+ * @returns {string} The JSON quoted and truncated value
50
+ */
51
+ var quote = value => JSON.stringify(_.truncate(String(value), {length: 64}));
52
+
53
+
54
+ /**
55
+ * Determine if a numeric limit setting is active
56
+ * @param {*} limit The limit setting
57
+ * @returns {boolean} True if the limit should be enforced
58
+ */
59
+ var isLimited = limit => _.isFinite(limit) && limit > 0;
60
+
61
+
62
+ /**
63
+ * Determine if a parsed regular expression token tree contains an alternation group within an unbounded (or large) repetition
64
+ * This catches overlapping alternation such as `(a|a)*` which safe-regex2 does not detect
65
+ * @param {Object} token The `ret` token to examine
66
+ * @param {boolean} [inRepeat=false] Whether the token is already within a large repetition
67
+ * @returns {boolean} True if a quantified alternation was found
68
+ */
69
+ var hasQuantifiedAlternation = (token, inRepeat = false) =>
70
+ !_.isObject(token) ? false
71
+ : token.type == ret.types.REPETITION ? hasQuantifiedAlternation(token.value, inRepeat || token.max > 10)
72
+ : token.options ? (inRepeat && token.options.length > 1) || token.options.some(option => option.some(t => hasQuantifiedAlternation(t, inRepeat)))
73
+ : token.stack ? token.stack.some(t => hasQuantifiedAlternation(t, inRepeat))
74
+ : false;
75
+
76
+
77
+ /**
78
+ * Validate a regular expression (as a string or RegExp) against the settings
79
+ * @param {string|RegExp} pattern The regular expression to examine
80
+ * @param {Object} settings The sieve settings
81
+ * @returns {void}
82
+ * @throws {SieveQueryError} If the expression is too long, unparsable or unsafe
83
+ */
84
+ var validateRegex = (pattern, settings) => {
85
+ var source = _.isRegExp(pattern) ? pattern.source : pattern;
86
+ if (isLimited(settings.maxRegexLength) && source.length > settings.maxRegexLength) throw new SieveQueryError(`Regular expression exceeds the maximum length of ${settings.maxRegexLength} (\`maxRegexLength\` option)`);
87
+ if (settings.applyUnsafeRegex === true) return;
88
+
89
+ var tokens = _.attempt(()=> ret(source));
90
+ if (_.isError(tokens)) throw new SieveQueryError('Invalid regular expression', {cause: tokens});
91
+ if (!safeRegex(source) || hasQuantifiedAlternation(tokens)) throw new SieveQueryError('Unsafe regular expression is not permitted unless the `applyUnsafeRegex` option is enabled');
92
+ };
93
+
94
+
95
+ /**
96
+ * Recursively validate the structure of a query at any depth, throwing if it contains anything not permitted by the settings
97
+ * Rejects `$where` operators and function values unless `applyWhere` is true, unsafe (ReDoS prone) regular expressions unless `applyUnsafeRegex` is true and anything exceeding the size limits
98
+ * @param {*} query The query (or sub-query) to examine
99
+ * @param {Object} settings The sieve settings
100
+ * @param {Object} [state] Running totals across the whole query
101
+ * @param {number} [state.keys=0] Number of object keys seen so far
102
+ * @param {number} [depth=1] The current nesting depth
103
+ * @returns {void}
104
+ * @throws {SieveQueryError} If the query contains a disallowed condition
105
+ */
106
+ var validateQuery = (query, settings, state = {keys: 0}, depth = 1) => {
107
+ if (isLimited(settings.maxDepth) && depth > settings.maxDepth) throw new SieveQueryError(`Query exceeds the maximum nesting depth of ${settings.maxDepth} (\`maxDepth\` option)`);
108
+
109
+ if (_.isFunction(query)) {
110
+ if (settings.applyWhere !== true) throw new SieveQueryError('Function values are not permitted unless the `applyWhere` option is enabled');
111
+ } else if (_.isRegExp(query)) {
112
+ validateRegex(query, settings);
113
+ } else if (Array.isArray(query)) {
114
+ if (isLimited(settings.maxArrayLength) && query.length > settings.maxArrayLength) throw new SieveQueryError(`Query array exceeds the maximum length of ${settings.maxArrayLength} (\`maxArrayLength\` option)`);
115
+ query.forEach(q => validateQuery(q, settings, state, depth + 1));
116
+ } else if (_.isPlainObject(query)) {
117
+ var keys = Object.keys(query);
118
+ state.keys += keys.length;
119
+ if (isLimited(settings.maxKeys) && state.keys > settings.maxKeys) throw new SieveQueryError(`Query exceeds the maximum number of keys of ${settings.maxKeys} (\`maxKeys\` option)`);
120
+
121
+ keys.forEach(k => {
122
+ if (k == '$where' && settings.applyWhere !== true) throw new SieveQueryError('$where conditions are not permitted unless the `applyWhere` option is enabled');
123
+ if (k == '$regex' && _.isString(query[k])) validateRegex(query[k], settings);
124
+ validateQuery(query[k], settings, state, depth + 1);
125
+ });
126
+ }
127
+ };
128
+
129
+
130
+ /**
131
+ * Validate a single field path against the blocked segments and (optionally) the allowed fields
132
+ * @param {string} path The dotted field path
133
+ * @param {Object} settings The sieve settings
134
+ * @param {boolean} [checkAllowed=true] Whether to also check the path against `settings.allowFields`
135
+ * @returns {void}
136
+ * @throws {SieveQueryError} If the field is not permitted
137
+ */
138
+ var validateField = (path, settings, checkAllowed = true) => {
139
+ if (path.split('.').some(segment => blockedSegments.includes(segment))) throw new SieveQueryError(`Field ${quote(path)} is not permitted`);
140
+ if (
141
+ checkAllowed
142
+ && settings.allowFields
143
+ && !settings.allowFields.some(field => path == field || path.startsWith(field + '.'))
144
+ ) throw new SieveQueryError(`Field ${quote(path)} is not permitted (\`allowFields\` option)`);
145
+ };
146
+
147
+
148
+ /**
149
+ * Determine if a value is a primitive literal or an array of primitive literals
150
+ * @param {*} value The value to examine
151
+ * @returns {boolean} True if the value contains no operators, objects or expressions
152
+ */
153
+ var isLiteral = value =>
154
+ Array.isArray(value) ? value.every(v => v === null || ['string', 'number', 'boolean'].includes(typeof v))
155
+ : value === null || ['string', 'number', 'boolean'].includes(typeof value);
156
+
157
+
158
+ /**
159
+ * Validate the value given against a field path, recursing into any sub-queries
160
+ * @param {string} path The dotted field path
161
+ * @param {*} value The value or operator object given for the field
162
+ * @param {Object} settings The sieve settings
163
+ * @returns {void}
164
+ * @throws {SieveQueryError} If the field or its value is not permitted
165
+ */
166
+ var validateFieldValue = (path, value, settings) => {
167
+ var isElemMatchContainer = _.isPlainObject(value)
168
+ && _.isEqual(Object.keys(value), ['$elemMatch'])
169
+ && settings.allowFields
170
+ && settings.allowFields.some(field => field.startsWith(path + '.'));
171
+
172
+ validateField(path, settings, !isElemMatchContainer); // Containers of allowed fields may use $elemMatch, sub-fields are checked below
173
+
174
+ (settings.literalFields || []).forEach(field => {
175
+ if (path.startsWith(field + '.')) throw new SieveQueryError(`Field ${quote(path)} is not permitted (\`literalFields\` option)`);
176
+ if (
177
+ (path == field || field.startsWith(path + '.'))
178
+ && !isLiteral(value)
179
+ ) throw new SieveQueryError(`Field ${quote(path)} only accepts literal values (\`literalFields\` option)`);
180
+ });
181
+
182
+ if (_.isPlainObject(value))
183
+ Object.keys(value).forEach(op => {
184
+ if (op == '$elemMatch' && _.isPlainObject(value[op])) {
185
+ validateFields(value[op], settings, path + '.');
186
+ } else if (op == '$not') {
187
+ validateFieldValue(path, value[op], settings);
188
+ }
189
+ });
190
+ };
191
+
192
+
193
+ /**
194
+ * Recursively validate all field paths used within a query
195
+ * @param {Object} query The query (or sub-query) to examine
196
+ * @param {Object} settings The sieve settings
197
+ * @param {string} [prefix=''] Prefix to prepend to all field paths, used within `$elemMatch`
198
+ * @returns {void}
199
+ * @throws {SieveQueryError} If any field or field value is not permitted
200
+ */
201
+ var validateFields = (query, settings, prefix = '') =>
202
+ Object.keys(query).forEach(k => {
203
+ if (logicalOperators.includes(k)) {
204
+ _.values(query[k])
205
+ .filter(_.isPlainObject)
206
+ .forEach(subQuery => validateFields(subQuery, settings, prefix));
207
+ } else if (!k.startsWith('$')) {
208
+ validateFieldValue(prefix + k, query[k], settings);
209
+ }
210
+ });
211
+
212
+
213
+ /**
214
+ * Parse a limit / skip value into a non-negative integer
215
+ * @param {*} value The raw value, which may be a number or numeric string
216
+ * @param {string} name The name of the parameter, used in error messages
217
+ * @returns {number} The parsed integer, zero if not specified
218
+ * @throws {SieveQueryError} If the value is not a non-negative integer
219
+ */
220
+ var parseCount = (value, name) =>
221
+ _.isNil(value) || value === false || value === '' ? 0
222
+ : Number.isSafeInteger(value) && value >= 0 ? value
223
+ : _.isString(value) && /^\d{1,15}$/.test(value) ? Number.parseInt(value, 10)
224
+ : (()=> { throw new SieveQueryError(`Invalid ${name}, must be a non-negative integer`) })();
225
+
226
+
227
+ /**
228
+ * Parse a select / sort value into an array of field strings
229
+ * @param {*} value The raw value, which may be a CSV string or an array of strings
230
+ * @param {string} name The name of the parameter, used in error messages
231
+ * @returns {Array<string>} The parsed fields, empty if not specified
232
+ * @throws {SieveQueryError} If the value is not a string or array of strings
233
+ */
234
+ var parseFieldList = (value, name) =>
235
+ _.isNil(value) || value === false ? []
236
+ : _.isString(value) ? value.split(/\s*,\s*/).filter(Boolean)
237
+ : Array.isArray(value) && value.every(_.isString) ? value.filter(Boolean)
238
+ : (()=> { throw new SieveQueryError(`Invalid ${name}, must be a string or array of strings`) })();
239
+
240
+
241
+ /**
242
+ * Filter, sort, page and select an array of data using a Sift / Mongo style query
243
+ * @param {Array} data The data to filter
244
+ * @param {Object} [query] The query to apply, may include the reserved `select`, `sort`, `limit` and `skip` keys
245
+ * @param {Object} [options] Options, see the README for the full list. Any unrecognised options are passed onto Sift
246
+ * @returns {Array|number} The filtered data or, if `options.count` is set, the number of matching items
247
+ * @throws {SieveQueryError} If the query is invalid, unsafe or disallowed
248
+ */
4
249
  module.exports = (data, query, options) => {
5
250
  var settings = {
6
251
  remapArray: true,
7
252
  count: false,
253
+ applyWhere: false,
254
+ applyUnsafeRegex: false,
255
+ allowFields: null,
256
+ literalFields: [],
257
+ maxLimit: 0,
258
+ maxDepth: 16,
259
+ maxKeys: 500,
260
+ maxArrayLength: 500,
261
+ maxRegexLength: 256,
8
262
  ...options,
9
263
  };
10
264
 
11
265
  if (!_.isArray(data)) throw new Error('Input to sieve must be an array');
266
+ if (query && !_.isPlainObject(query)) throw new SieveQueryError('Query to sieve must be an object');
267
+ if (!query) query = {};
12
268
 
13
- // Empty queries {{{
14
- if (!query || _.isEmpty(query)) {
15
- if (settings.count) {
16
- return data.length;
17
- } else {
18
- return data; // No query given - return everything
19
- }
20
- }
21
- // }}}
269
+ var limit = parseCount(query.limit, 'limit');
270
+ var skip = parseCount(query.skip, 'skip');
271
+ if (!settings.count && isLimited(settings.maxLimit) && (!limit || limit > settings.maxLimit)) limit = settings.maxLimit;
272
+
273
+ var sortFields = parseFieldList(query.sort, 'sort');
274
+ sortFields.forEach(field => validateField(field.replace(/^[+-]/, ''), settings));
275
+
276
+ var selectFields = parseFieldList(query.select, 'select');
277
+ selectFields.forEach(field => validateField(field.replace(/^[!-]/, ''), settings, false));
22
278
 
23
279
  var siftFilter = _.omit(query, ['select', 'sort', 'limit', 'skip']);
24
280
 
281
+ validateQuery(siftFilter, settings);
282
+ validateFields(siftFilter, settings);
283
+
25
284
  // Remap {id: ARRAY} into {id: {$in: ARRAY}} {{{
26
285
  if (settings.remapArray)
27
286
  Object.keys(siftFilter).forEach(k => {
28
- if (Array.isArray(siftFilter[k]))
287
+ if (Array.isArray(siftFilter[k]) && !k.startsWith('$'))
29
288
  siftFilter[k] = {$in: siftFilter[k]};
30
289
  });
31
290
  // }}}
32
291
 
33
- var siftQuery = sift(siftFilter, options);
292
+ var siftQuery = _.attempt(()=> sift(siftFilter, _.omit(options, sieveOptions)));
293
+ if (_.isError(siftQuery)) throw new SieveQueryError(`Invalid query: ${quote(siftQuery.message)}`, {cause: siftQuery});
34
294
 
35
295
  var count = 0; // Number of items that test positive in the output so far
36
296
  var skipped = 0;
37
297
  var res = data.filter(item => {
38
298
  // Apply limit {{{
39
- if (query.limit && count >= query.limit) { // Gone over limit?
299
+ if (limit && count >= limit) { // Gone over limit?
40
300
  return false;
41
301
  }
42
302
  // }}}
43
303
 
44
304
  // Apply Sift (filters) {{{
45
- var siftResult = siftQuery(item);
305
+ var siftResult = _.attempt(siftQuery, item);
306
+ if (_.isError(siftResult)) throw new SieveQueryError(`Invalid query: ${quote(siftResult.message)}`, {cause: siftResult});
46
307
  // }}}
47
308
 
48
309
  // Apply skip {{{
49
- if (siftResult && query.skip && ++skipped <= query.skip) {
310
+ if (siftResult && skip && ++skipped <= skip) {
50
311
  return false; // Still within skip range
51
312
  } else if (siftResult) {
52
313
  count++;
@@ -60,8 +321,8 @@ module.exports = (data, query, options) => {
60
321
  if (settings.count) return res.length;
61
322
 
62
323
  // Apply sort {{{
63
- if (query.sort && !_.isEmpty(query.sort)) {
64
- res = _.orderBy(res, ..._(_.isString(query.sort) ? query.sort.split(/\s*,\s*/) : query.sort)
324
+ if (sortFields.length) {
325
+ res = _.orderBy(res, ..._(sortFields)
65
326
  .map(o =>
66
327
  o.startsWith('-') ? [o.substr(1), 'desc']
67
328
  : o.startsWith('+') ? [o.substr(1), 'asc']
@@ -74,15 +335,15 @@ module.exports = (data, query, options) => {
74
335
  // }}}
75
336
 
76
337
  // Apply select {{{
77
- if (query.select && !_.isEmpty(query.select)) {
78
- var fields = _.isString(query.select) ? query.select.split(/\s*,\s*/) : query.select;
338
+ if (selectFields.length) {
339
+ var fields = selectFields;
79
340
 
80
341
  // Act as an exclusion selection?
81
342
  var isExclude = fields.some(field => field.startsWith('!') || field.startsWith('-'));
82
343
 
83
344
  if (isExclude) {
84
345
  fields = fields.map(field => {
85
- if (!field.startsWith('!') && !field.startsWith('-')) throw new Error('Invalid selection criteria, if using a select exclusion all fields must be exclusions');
346
+ if (!field.startsWith('!') && !field.startsWith('-')) throw new SieveQueryError('Invalid selection criteria, if using a select exclusion all fields must be exclusions');
86
347
  return field.substr(1);
87
348
  });
88
349
 
@@ -95,3 +356,5 @@ module.exports = (data, query, options) => {
95
356
 
96
357
  return res;
97
358
  };
359
+
360
+ module.exports.SieveQueryError = SieveQueryError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momsfriendlydevco/sift-sieve",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Wrapper around Sift which provides other ReST functionality",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -22,10 +22,13 @@
22
22
  "homepage": "https://github.com/MomsFriendlyDevCo/sift-sieve#readme",
23
23
  "devDependencies": {
24
24
  "chai": "^4.2.0",
25
- "mocha": "^8.2.1"
25
+ "mocha": "^8.2.1",
26
+ "qs": "6.16.1"
26
27
  },
27
28
  "dependencies": {
28
29
  "lodash": "^4.18.1",
30
+ "ret": "^0.5.1",
31
+ "safe-regex2": "^5.1.2",
29
32
  "sift": "^17.1.4"
30
33
  }
31
34
  }