hppx 0.1.8 → 0.2.2

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/dist/index.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  var DEFAULT_SOURCES = ["query", "body", "params"];
3
3
  var DEFAULT_STRATEGY = "keepLast";
4
4
  var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
5
+ var FORBIDDEN_KEY_CHARS = /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/;
5
6
  function isPlainObject(value) {
6
7
  if (value === null || typeof value !== "object") return false;
7
8
  const proto = Object.getPrototypeOf(value);
@@ -10,47 +11,65 @@ function isPlainObject(value) {
10
11
  function sanitizeKey(key, maxKeyLength) {
11
12
  if (typeof key !== "string") return null;
12
13
  if (DANGEROUS_KEYS.has(key)) return null;
13
- if (key.includes("\0")) return null;
14
+ if (FORBIDDEN_KEY_CHARS.test(key)) return null;
14
15
  const maxLen = maxKeyLength ?? 200;
15
16
  if (key.length > maxLen) return null;
16
17
  if (key.length > 1 && /^[.\[\]]+$/.test(key)) return null;
17
18
  return key;
18
19
  }
20
+ var PATH_SEGMENT_CACHE_LIMIT = 500;
19
21
  var pathSegmentCache = /* @__PURE__ */ new Map();
20
22
  function parsePathSegments(key) {
21
23
  const cached = pathSegmentCache.get(key);
22
24
  if (cached) return cached;
23
25
  const dotted = key.replace(/\]/g, "").replace(/\[/g, ".");
24
26
  const result = dotted.split(".").filter((s) => s.length > 0);
25
- if (pathSegmentCache.size < 500) {
26
- pathSegmentCache.set(key, result);
27
+ if (pathSegmentCache.size >= PATH_SEGMENT_CACHE_LIMIT) {
28
+ pathSegmentCache.clear();
27
29
  }
30
+ pathSegmentCache.set(key, result);
28
31
  return result;
29
32
  }
30
- function expandObjectPaths(obj, maxKeyLength) {
31
- const result = {};
32
- for (const rawKey of Object.keys(obj)) {
33
- const safeKey = sanitizeKey(rawKey, maxKeyLength);
34
- if (!safeKey) continue;
35
- const value = obj[rawKey];
36
- const expandedValue = isPlainObject(value) ? expandObjectPaths(value, maxKeyLength) : value;
37
- if (safeKey.includes(".") || safeKey.includes("[")) {
38
- const segments = parsePathSegments(safeKey);
39
- if (segments.length > 0) {
40
- setIn(result, segments, expandedValue);
41
- continue;
33
+ function __resetPathSegmentCache() {
34
+ pathSegmentCache.clear();
35
+ }
36
+ function expandObjectPaths(obj, maxKeyLength, maxDepth = 20, currentDepth = 0, seen) {
37
+ if (currentDepth > maxDepth) {
38
+ throw new Error(`Maximum object depth (${maxDepth}) exceeded`);
39
+ }
40
+ const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
41
+ if (seenSet.has(obj)) return {};
42
+ seenSet.add(obj);
43
+ try {
44
+ const result = {};
45
+ for (const rawKey of Object.keys(obj)) {
46
+ const safeKey = sanitizeKey(rawKey, maxKeyLength);
47
+ if (!safeKey) continue;
48
+ const value = obj[rawKey];
49
+ const expandedValue = isPlainObject(value) ? expandObjectPaths(
50
+ value,
51
+ maxKeyLength,
52
+ maxDepth,
53
+ currentDepth + 1,
54
+ seenSet
55
+ ) : value;
56
+ if (safeKey.includes(".") || safeKey.includes("[")) {
57
+ const segments = parsePathSegments(safeKey);
58
+ if (segments.length > 0) {
59
+ setIn(result, segments, expandedValue);
60
+ continue;
61
+ }
42
62
  }
63
+ result[safeKey] = expandedValue;
43
64
  }
44
- result[safeKey] = expandedValue;
65
+ return result;
66
+ } finally {
67
+ seenSet.delete(obj);
45
68
  }
46
- return result;
47
69
  }
48
- function setReqPropertySafe(target, key, value) {
70
+ function setReqPropertySafe(target, key, value, onFailure) {
49
71
  try {
50
72
  const desc = Object.getOwnPropertyDescriptor(target, key);
51
- if (desc && desc.configurable === false && desc.writable === false) {
52
- return;
53
- }
54
73
  if (!desc || desc.configurable !== false) {
55
74
  Object.defineProperty(target, key, {
56
75
  value,
@@ -58,28 +77,79 @@ function setReqPropertySafe(target, key, value) {
58
77
  configurable: true,
59
78
  enumerable: true
60
79
  });
61
- return;
80
+ return true;
81
+ }
82
+ if (desc.writable) {
83
+ target[key] = value;
84
+ return true;
85
+ }
86
+ try {
87
+ target[key] = value;
88
+ if (target[key] === value) return true;
89
+ } catch (_assignErr) {
90
+ }
91
+ if (onFailure) {
92
+ onFailure(
93
+ `[hppx] Could not write sanitized value to req.${key}: property is non-configurable and non-writable. The original (potentially polluted) value remains on req.${key}.`
94
+ );
95
+ }
96
+ return false;
97
+ } catch (_definePropErr) {
98
+ try {
99
+ target[key] = value;
100
+ if (target[key] === value) return true;
101
+ } catch (_assignErr) {
102
+ }
103
+ {
104
+ if (onFailure) {
105
+ onFailure(`[hppx] Could not write sanitized value to req.${key}: defineProperty failed.`);
106
+ }
107
+ return false;
62
108
  }
63
- } catch (_) {
64
- }
65
- try {
66
- target[key] = value;
67
- } catch (_) {
68
109
  }
69
110
  }
70
- function safeDeepClone(input, maxKeyLength, maxArrayLength) {
111
+ function safeDeepClone(input, maxKeyLength, maxArrayLength, maxDepth = 20, currentDepth = 0, seen) {
71
112
  if (Array.isArray(input)) {
72
- const limit = maxArrayLength ?? 1e3;
73
- const limited = input.slice(0, limit);
74
- return limited.map((v) => safeDeepClone(v, maxKeyLength, maxArrayLength));
113
+ if (currentDepth > maxDepth) {
114
+ throw new Error(`Maximum object depth (${maxDepth}) exceeded`);
115
+ }
116
+ const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
117
+ if (seenSet.has(input)) return [];
118
+ seenSet.add(input);
119
+ try {
120
+ const limit = maxArrayLength ?? 1e3;
121
+ const limited = input.slice(0, limit);
122
+ return limited.map(
123
+ (v) => safeDeepClone(v, maxKeyLength, maxArrayLength, maxDepth, currentDepth + 1, seenSet)
124
+ );
125
+ } finally {
126
+ seenSet.delete(input);
127
+ }
75
128
  }
76
129
  if (isPlainObject(input)) {
77
- const out = {};
78
- for (const k of Object.keys(input)) {
79
- if (!sanitizeKey(k, maxKeyLength)) continue;
80
- out[k] = safeDeepClone(input[k], maxKeyLength, maxArrayLength);
130
+ if (currentDepth > maxDepth) {
131
+ throw new Error(`Maximum object depth (${maxDepth}) exceeded`);
132
+ }
133
+ const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
134
+ if (seenSet.has(input)) return {};
135
+ seenSet.add(input);
136
+ try {
137
+ const out = {};
138
+ for (const k of Object.keys(input)) {
139
+ if (!sanitizeKey(k, maxKeyLength)) continue;
140
+ out[k] = safeDeepClone(
141
+ input[k],
142
+ maxKeyLength,
143
+ maxArrayLength,
144
+ maxDepth,
145
+ currentDepth + 1,
146
+ seenSet
147
+ );
148
+ }
149
+ return out;
150
+ } finally {
151
+ seenSet.delete(input);
81
152
  }
82
- return out;
83
153
  }
84
154
  return input;
85
155
  }
@@ -95,8 +165,15 @@ function mergeValues(values, strategy) {
95
165
  else acc.push(v);
96
166
  return acc;
97
167
  }, []);
98
- default:
99
- return values[values.length - 1];
168
+ /* istanbul ignore next -- exhaustiveness check unreachable from outside:
169
+ validateSanitizeOptions rejects every non-listed strategy at construction
170
+ time, so the only way to reach this branch is a programmer error (a new
171
+ MergeStrategy union member added without updating this switch). Failing
172
+ loudly here is preferable to a silent fallback. */
173
+ default: {
174
+ const _exhaustive = strategy;
175
+ throw new Error(`Unknown mergeStrategy: ${_exhaustive}`);
176
+ }
100
177
  }
101
178
  }
102
179
  function isUrlEncodedContentType(req) {
@@ -120,6 +197,7 @@ function normalizeWhitelist(whitelist) {
120
197
  if (typeof whitelist === "string") return [whitelist];
121
198
  return whitelist.filter((w) => typeof w === "string");
122
199
  }
200
+ var WHITELIST_PATH_CACHE_LIMIT = 1e3;
123
201
  function buildWhitelistHelpers(whitelist) {
124
202
  const exact = new Set(whitelist);
125
203
  const prefixes = whitelist.filter((w) => w.length > 0);
@@ -145,9 +223,10 @@ function buildWhitelistHelpers(whitelist) {
145
223
  }
146
224
  }
147
225
  }
148
- if (pathCache.size < 1e3) {
149
- pathCache.set(full, result);
226
+ if (pathCache.size >= WHITELIST_PATH_CACHE_LIMIT) {
227
+ pathCache.clear();
150
228
  }
229
+ pathCache.set(full, result);
151
230
  return result;
152
231
  }
153
232
  };
@@ -195,20 +274,18 @@ function moveWhitelistedFromPolluted(reqPart, polluted, isWhitelisted) {
195
274
  function detectAndReduce(input, opts) {
196
275
  let keyCount = 0;
197
276
  const polluted = {};
198
- const pollutedKeys = [];
199
- function processNode(node, path = [], depth = 0) {
200
- if (node === null || node === void 0) return opts.preserveNull ? node : node;
277
+ const pollutedKeysSet = /* @__PURE__ */ new Set();
278
+ const cloned = safeDeepClone(input, opts.maxKeyLength, opts.maxArrayLength, opts.maxDepth);
279
+ function processNode(node, path = [], depth = 0, inArray = false) {
280
+ if (node === null) return opts.preserveNull ? null : void 0;
281
+ if (node === void 0) return node;
201
282
  if (Array.isArray(node)) {
202
- const limit = opts.maxArrayLength ?? 1e3;
203
- const limitedNode = node.slice(0, limit);
204
- const mapped = limitedNode.map((v) => processNode(v, path, depth));
205
- if (opts.mergeStrategy === "combine") {
206
- return mergeValues(mapped, "combine");
283
+ const mapped = node.map((v) => processNode(v, path, depth, true));
284
+ if (!inArray) {
285
+ setIn(polluted, path, node);
286
+ pollutedKeysSet.add(path.join("."));
207
287
  }
208
- setIn(polluted, path, safeDeepClone(limitedNode, opts.maxKeyLength, opts.maxArrayLength));
209
- pollutedKeys.push(path.join("."));
210
- const reduced = mergeValues(mapped, opts.mergeStrategy);
211
- return reduced;
288
+ return mergeValues(mapped, opts.mergeStrategy);
212
289
  }
213
290
  if (isPlainObject(node)) {
214
291
  if (depth > opts.maxDepth)
@@ -223,7 +300,7 @@ function detectAndReduce(input, opts) {
223
300
  if (!safeKey) continue;
224
301
  const child = node[rawKey];
225
302
  const childPath = path.concat([safeKey]);
226
- let value = processNode(child, childPath, depth + 1);
303
+ let value = processNode(child, childPath, depth + 1, false);
227
304
  if (typeof value === "string" && opts.trimValues) value = value.trim();
228
305
  out[safeKey] = value;
229
306
  }
@@ -231,13 +308,14 @@ function detectAndReduce(input, opts) {
231
308
  }
232
309
  return node;
233
310
  }
234
- const cloned = safeDeepClone(input, opts.maxKeyLength, opts.maxArrayLength);
235
- const cleaned = processNode(cloned, [], 0);
236
- return { cleaned, pollutedTree: polluted, pollutedKeys };
311
+ const cleaned = processNode(cloned, [], 0, false);
312
+ return { cleaned, pollutedTree: polluted, pollutedKeys: Array.from(pollutedKeysSet) };
237
313
  }
238
314
  function sanitize(input, options = {}) {
315
+ validateSanitizeOptions(options);
239
316
  const maxKeyLength = options.maxKeyLength ?? 200;
240
- const expandedInput = isPlainObject(input) ? expandObjectPaths(input, maxKeyLength) : input;
317
+ const maxDepthVal = options.maxDepth ?? 20;
318
+ const expandedInput = isPlainObject(input) ? expandObjectPaths(input, maxKeyLength, maxDepthVal) : input;
241
319
  const whitelist = normalizeWhitelist(options.whitelist);
242
320
  const { isWhitelistedPath } = buildWhitelistHelpers(whitelist);
243
321
  const {
@@ -260,7 +338,7 @@ function sanitize(input, options = {}) {
260
338
  moveWhitelistedFromPolluted(cleaned, pollutedTree, isWhitelistedPath);
261
339
  return cleaned;
262
340
  }
263
- function validateOptions(options) {
341
+ function validateSanitizeOptions(options) {
264
342
  if (options.maxDepth !== void 0 && (typeof options.maxDepth !== "number" || options.maxDepth < 1 || options.maxDepth > 100)) {
265
343
  throw new TypeError("maxDepth must be a number between 1 and 100");
266
344
  }
@@ -276,10 +354,34 @@ function validateOptions(options) {
276
354
  if (options.mergeStrategy !== void 0 && !["keepFirst", "keepLast", "combine"].includes(options.mergeStrategy)) {
277
355
  throw new TypeError("mergeStrategy must be 'keepFirst', 'keepLast', or 'combine'");
278
356
  }
357
+ if (options.trimValues !== void 0 && typeof options.trimValues !== "boolean") {
358
+ throw new TypeError("trimValues must be a boolean");
359
+ }
360
+ if (options.preserveNull !== void 0 && typeof options.preserveNull !== "boolean") {
361
+ throw new TypeError("preserveNull must be a boolean");
362
+ }
363
+ if (options.whitelist !== void 0) {
364
+ if (typeof options.whitelist !== "string" && !Array.isArray(options.whitelist)) {
365
+ throw new TypeError("whitelist must be a string or an array of strings");
366
+ }
367
+ if (Array.isArray(options.whitelist)) {
368
+ for (const entry of options.whitelist) {
369
+ if (typeof entry !== "string") {
370
+ throw new TypeError("whitelist must be a string or an array of strings");
371
+ }
372
+ }
373
+ }
374
+ }
375
+ }
376
+ function validateOptions(options) {
377
+ validateSanitizeOptions(options);
279
378
  if (options.sources !== void 0 && !Array.isArray(options.sources)) {
280
379
  throw new TypeError("sources must be an array");
281
380
  }
282
381
  if (options.sources !== void 0) {
382
+ if (options.sources.length === 0) {
383
+ throw new TypeError("sources must contain at least one of 'query', 'body', 'params'");
384
+ }
283
385
  for (const source of options.sources) {
284
386
  if (!["query", "body", "params"].includes(source)) {
285
387
  throw new TypeError("sources must only contain 'query', 'body', or 'params'");
@@ -289,8 +391,27 @@ function validateOptions(options) {
289
391
  if (options.checkBodyContentType !== void 0 && !["urlencoded", "any", "none"].includes(options.checkBodyContentType)) {
290
392
  throw new TypeError("checkBodyContentType must be 'urlencoded', 'any', or 'none'");
291
393
  }
292
- if (options.excludePaths !== void 0 && !Array.isArray(options.excludePaths)) {
293
- throw new TypeError("excludePaths must be an array");
394
+ if (options.excludePaths !== void 0) {
395
+ if (!Array.isArray(options.excludePaths)) {
396
+ throw new TypeError("excludePaths must be an array");
397
+ }
398
+ for (const entry of options.excludePaths) {
399
+ if (typeof entry !== "string") {
400
+ throw new TypeError("excludePaths must contain only strings");
401
+ }
402
+ }
403
+ }
404
+ if (options.logger !== void 0 && typeof options.logger !== "function") {
405
+ throw new TypeError("logger must be a function");
406
+ }
407
+ if (options.onPollutionDetected !== void 0 && typeof options.onPollutionDetected !== "function") {
408
+ throw new TypeError("onPollutionDetected must be a function");
409
+ }
410
+ if (options.strict !== void 0 && typeof options.strict !== "boolean") {
411
+ throw new TypeError("strict must be a boolean");
412
+ }
413
+ if (options.logPollution !== void 0 && typeof options.logPollution !== "boolean") {
414
+ throw new TypeError("logPollution must be a boolean");
294
415
  }
295
416
  }
296
417
  function hppx(options = {}) {
@@ -316,9 +437,39 @@ function hppx(options = {}) {
316
437
  const { isWhitelistedPath } = buildWhitelistHelpers(whitelistArr);
317
438
  return function hppxMiddleware(req, res, next) {
318
439
  try {
319
- if (shouldExcludePath(req?.path, excludePaths)) return next();
440
+ let pathForExclusion;
441
+ try {
442
+ pathForExclusion = req?.path;
443
+ } catch (pathErr) {
444
+ const message = `[hppx] Failed to read req.path during exclusion check; proceeding without path-based exclusion. Underlying error: ${pathErr instanceof Error ? pathErr.message : String(pathErr)}`;
445
+ if (logger) {
446
+ try {
447
+ logger(message);
448
+ } catch (_) {
449
+ console.warn(message);
450
+ }
451
+ } else {
452
+ console.warn(message);
453
+ }
454
+ pathForExclusion = void 0;
455
+ }
456
+ if (shouldExcludePath(pathForExclusion, excludePaths)) return next();
320
457
  let anyPollutionDetected = false;
321
458
  const allPollutedKeys = [];
459
+ const warned = /* @__PURE__ */ new Set();
460
+ const warn = (message) => {
461
+ if (warned.has(message)) return;
462
+ warned.add(message);
463
+ if (logger) {
464
+ try {
465
+ logger(message);
466
+ } catch (_) {
467
+ console.warn(message);
468
+ }
469
+ } else {
470
+ console.warn(message);
471
+ }
472
+ };
322
473
  for (const source of sources) {
323
474
  if (!req || typeof req !== "object") break;
324
475
  if (req[source] === void 0) continue;
@@ -328,10 +479,10 @@ function hppx(options = {}) {
328
479
  }
329
480
  const part = req[source];
330
481
  if (!isPlainObject(part)) continue;
331
- const expandedPart = expandObjectPaths(part, maxKeyLength);
482
+ const expandedPart = expandObjectPaths(part, maxKeyLength, maxDepth);
332
483
  const pollutedKey = `${source}Polluted`;
333
484
  const processedKey = `__hppxProcessed_${source}`;
334
- const hasProcessedBefore = Boolean(req[processedKey]);
485
+ const hasProcessedBefore = Object.prototype.hasOwnProperty.call(req, processedKey);
335
486
  if (!hasProcessedBefore) {
336
487
  const { cleaned, pollutedTree, pollutedKeys } = detectAndReduce(expandedPart, {
337
488
  mergeStrategy,
@@ -342,9 +493,21 @@ function hppx(options = {}) {
342
493
  trimValues,
343
494
  preserveNull
344
495
  });
345
- setReqPropertySafe(req, source, cleaned);
346
- setReqPropertySafe(req, pollutedKey, pollutedTree);
347
- req[processedKey] = true;
496
+ setReqPropertySafe(req, source, cleaned, warn);
497
+ setReqPropertySafe(req, pollutedKey, pollutedTree, warn);
498
+ try {
499
+ Object.defineProperty(req, processedKey, {
500
+ value: true,
501
+ writable: false,
502
+ configurable: false,
503
+ enumerable: false
504
+ });
505
+ } catch (_) {
506
+ try {
507
+ req[processedKey] = true;
508
+ } catch (_assignErr) {
509
+ }
510
+ }
348
511
  const sourceData = req[source];
349
512
  const pollutedData = req[pollutedKey];
350
513
  if (isPlainObject(sourceData) && isPlainObject(pollutedData)) {
@@ -411,10 +574,8 @@ function hppx(options = {}) {
411
574
  try {
412
575
  logger(error);
413
576
  } catch (logErr) {
414
- if (process.env.NODE_ENV !== "production") {
415
- console.error("[hppx] Logger failed:", logErr);
416
- console.error("[hppx] Original error:", error);
417
- }
577
+ console.error("[hppx] Logger failed:", logErr);
578
+ console.error("[hppx] Original error:", error);
418
579
  }
419
580
  }
420
581
  return next(error);
@@ -425,6 +586,7 @@ export {
425
586
  DANGEROUS_KEYS,
426
587
  DEFAULT_SOURCES,
427
588
  DEFAULT_STRATEGY,
589
+ __resetPathSegmentCache,
428
590
  hppx as default,
429
591
  sanitize
430
592
  };