hppx 0.1.10 → 0.2.3

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,23 +11,28 @@ 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
  }
33
+ function __resetPathSegmentCache() {
34
+ pathSegmentCache.clear();
35
+ }
30
36
  function expandObjectPaths(obj, maxKeyLength, maxDepth = 20, currentDepth = 0, seen) {
31
37
  if (currentDepth > maxDepth) {
32
38
  throw new Error(`Maximum object depth (${maxDepth}) exceeded`);
@@ -34,35 +40,36 @@ function expandObjectPaths(obj, maxKeyLength, maxDepth = 20, currentDepth = 0, s
34
40
  const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
35
41
  if (seenSet.has(obj)) return {};
36
42
  seenSet.add(obj);
37
- const result = {};
38
- for (const rawKey of Object.keys(obj)) {
39
- const safeKey = sanitizeKey(rawKey, maxKeyLength);
40
- if (!safeKey) continue;
41
- const value = obj[rawKey];
42
- const expandedValue = isPlainObject(value) ? expandObjectPaths(
43
- value,
44
- maxKeyLength,
45
- maxDepth,
46
- currentDepth + 1,
47
- seenSet
48
- ) : value;
49
- if (safeKey.includes(".") || safeKey.includes("[")) {
50
- const segments = parsePathSegments(safeKey);
51
- if (segments.length > 0) {
52
- setIn(result, segments, expandedValue);
53
- continue;
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
+ }
54
62
  }
63
+ result[safeKey] = expandedValue;
55
64
  }
56
- result[safeKey] = expandedValue;
65
+ return result;
66
+ } finally {
67
+ seenSet.delete(obj);
57
68
  }
58
- return result;
59
69
  }
60
- function setReqPropertySafe(target, key, value) {
70
+ function setReqPropertySafe(target, key, value, onFailure) {
61
71
  try {
62
72
  const desc = Object.getOwnPropertyDescriptor(target, key);
63
- if (desc && desc.configurable === false && desc.writable === false) {
64
- return;
65
- }
66
73
  if (!desc || desc.configurable !== false) {
67
74
  Object.defineProperty(target, key, {
68
75
  value,
@@ -70,13 +77,35 @@ function setReqPropertySafe(target, key, value) {
70
77
  configurable: true,
71
78
  enumerable: true
72
79
  });
73
- 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;
74
108
  }
75
- } catch (_) {
76
- }
77
- try {
78
- target[key] = value;
79
- } catch (_) {
80
109
  }
81
110
  }
82
111
  function safeDeepClone(input, maxKeyLength, maxArrayLength, maxDepth = 20, currentDepth = 0, seen) {
@@ -87,11 +116,15 @@ function safeDeepClone(input, maxKeyLength, maxArrayLength, maxDepth = 20, curre
87
116
  const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
88
117
  if (seenSet.has(input)) return [];
89
118
  seenSet.add(input);
90
- const limit = maxArrayLength ?? 1e3;
91
- const limited = input.slice(0, limit);
92
- return limited.map(
93
- (v) => safeDeepClone(v, maxKeyLength, maxArrayLength, maxDepth, currentDepth + 1, seenSet)
94
- );
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
+ }
95
128
  }
96
129
  if (isPlainObject(input)) {
97
130
  if (currentDepth > maxDepth) {
@@ -100,19 +133,23 @@ function safeDeepClone(input, maxKeyLength, maxArrayLength, maxDepth = 20, curre
100
133
  const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
101
134
  if (seenSet.has(input)) return {};
102
135
  seenSet.add(input);
103
- const out = {};
104
- for (const k of Object.keys(input)) {
105
- if (!sanitizeKey(k, maxKeyLength)) continue;
106
- out[k] = safeDeepClone(
107
- input[k],
108
- maxKeyLength,
109
- maxArrayLength,
110
- maxDepth,
111
- currentDepth + 1,
112
- seenSet
113
- );
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);
114
152
  }
115
- return out;
116
153
  }
117
154
  return input;
118
155
  }
@@ -128,9 +165,15 @@ function mergeValues(values, strategy) {
128
165
  else acc.push(v);
129
166
  return acc;
130
167
  }, []);
131
- /* istanbul ignore next -- unreachable: strategy is validated before reaching mergeValues */
132
- default:
133
- 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
+ }
134
177
  }
135
178
  }
136
179
  function isUrlEncodedContentType(req) {
@@ -154,6 +197,7 @@ function normalizeWhitelist(whitelist) {
154
197
  if (typeof whitelist === "string") return [whitelist];
155
198
  return whitelist.filter((w) => typeof w === "string");
156
199
  }
200
+ var WHITELIST_PATH_CACHE_LIMIT = 1e3;
157
201
  function buildWhitelistHelpers(whitelist) {
158
202
  const exact = new Set(whitelist);
159
203
  const prefixes = whitelist.filter((w) => w.length > 0);
@@ -179,9 +223,10 @@ function buildWhitelistHelpers(whitelist) {
179
223
  }
180
224
  }
181
225
  }
182
- if (pathCache.size < 1e3) {
183
- pathCache.set(full, result);
226
+ if (pathCache.size >= WHITELIST_PATH_CACHE_LIMIT) {
227
+ pathCache.clear();
184
228
  }
229
+ pathCache.set(full, result);
185
230
  return result;
186
231
  }
187
232
  };
@@ -229,25 +274,18 @@ function moveWhitelistedFromPolluted(reqPart, polluted, isWhitelisted) {
229
274
  function detectAndReduce(input, opts) {
230
275
  let keyCount = 0;
231
276
  const polluted = {};
232
- const pollutedKeys = [];
233
- function processNode(node, path = [], depth = 0) {
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) {
234
280
  if (node === null) return opts.preserveNull ? null : void 0;
235
281
  if (node === void 0) return node;
236
282
  if (Array.isArray(node)) {
237
- const limit = opts.maxArrayLength ?? 1e3;
238
- const limitedNode = node.slice(0, limit);
239
- const mapped = limitedNode.map((v) => processNode(v, path, depth));
240
- if (opts.mergeStrategy === "combine") {
241
- 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("."));
242
287
  }
243
- setIn(
244
- polluted,
245
- path,
246
- safeDeepClone(limitedNode, opts.maxKeyLength, opts.maxArrayLength, opts.maxDepth)
247
- );
248
- pollutedKeys.push(path.join("."));
249
- const reduced = mergeValues(mapped, opts.mergeStrategy);
250
- return reduced;
288
+ return mergeValues(mapped, opts.mergeStrategy);
251
289
  }
252
290
  if (isPlainObject(node)) {
253
291
  if (depth > opts.maxDepth)
@@ -262,7 +300,7 @@ function detectAndReduce(input, opts) {
262
300
  if (!safeKey) continue;
263
301
  const child = node[rawKey];
264
302
  const childPath = path.concat([safeKey]);
265
- let value = processNode(child, childPath, depth + 1);
303
+ let value = processNode(child, childPath, depth + 1, false);
266
304
  if (typeof value === "string" && opts.trimValues) value = value.trim();
267
305
  out[safeKey] = value;
268
306
  }
@@ -270,9 +308,8 @@ function detectAndReduce(input, opts) {
270
308
  }
271
309
  return node;
272
310
  }
273
- const cloned = safeDeepClone(input, opts.maxKeyLength, opts.maxArrayLength, opts.maxDepth);
274
- const cleaned = processNode(cloned, [], 0);
275
- return { cleaned, pollutedTree: polluted, pollutedKeys };
311
+ const cleaned = processNode(cloned, [], 0, false);
312
+ return { cleaned, pollutedTree: polluted, pollutedKeys: Array.from(pollutedKeysSet) };
276
313
  }
277
314
  function sanitize(input, options = {}) {
278
315
  validateSanitizeOptions(options);
@@ -317,6 +354,24 @@ function validateSanitizeOptions(options) {
317
354
  if (options.mergeStrategy !== void 0 && !["keepFirst", "keepLast", "combine"].includes(options.mergeStrategy)) {
318
355
  throw new TypeError("mergeStrategy must be 'keepFirst', 'keepLast', or 'combine'");
319
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
+ }
320
375
  }
321
376
  function validateOptions(options) {
322
377
  validateSanitizeOptions(options);
@@ -324,6 +379,9 @@ function validateOptions(options) {
324
379
  throw new TypeError("sources must be an array");
325
380
  }
326
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
+ }
327
385
  for (const source of options.sources) {
328
386
  if (!["query", "body", "params"].includes(source)) {
329
387
  throw new TypeError("sources must only contain 'query', 'body', or 'params'");
@@ -333,8 +391,15 @@ function validateOptions(options) {
333
391
  if (options.checkBodyContentType !== void 0 && !["urlencoded", "any", "none"].includes(options.checkBodyContentType)) {
334
392
  throw new TypeError("checkBodyContentType must be 'urlencoded', 'any', or 'none'");
335
393
  }
336
- if (options.excludePaths !== void 0 && !Array.isArray(options.excludePaths)) {
337
- 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
+ }
338
403
  }
339
404
  if (options.logger !== void 0 && typeof options.logger !== "function") {
340
405
  throw new TypeError("logger must be a function");
@@ -342,6 +407,12 @@ function validateOptions(options) {
342
407
  if (options.onPollutionDetected !== void 0 && typeof options.onPollutionDetected !== "function") {
343
408
  throw new TypeError("onPollutionDetected must be a function");
344
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");
415
+ }
345
416
  }
346
417
  function hppx(options = {}) {
347
418
  validateOptions(options);
@@ -366,9 +437,39 @@ function hppx(options = {}) {
366
437
  const { isWhitelistedPath } = buildWhitelistHelpers(whitelistArr);
367
438
  return function hppxMiddleware(req, res, next) {
368
439
  try {
369
- 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();
370
457
  let anyPollutionDetected = false;
371
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
+ };
372
473
  for (const source of sources) {
373
474
  if (!req || typeof req !== "object") break;
374
475
  if (req[source] === void 0) continue;
@@ -381,7 +482,7 @@ function hppx(options = {}) {
381
482
  const expandedPart = expandObjectPaths(part, maxKeyLength, maxDepth);
382
483
  const pollutedKey = `${source}Polluted`;
383
484
  const processedKey = `__hppxProcessed_${source}`;
384
- const hasProcessedBefore = Boolean(req[processedKey]);
485
+ const hasProcessedBefore = Object.prototype.hasOwnProperty.call(req, processedKey);
385
486
  if (!hasProcessedBefore) {
386
487
  const { cleaned, pollutedTree, pollutedKeys } = detectAndReduce(expandedPart, {
387
488
  mergeStrategy,
@@ -392,9 +493,21 @@ function hppx(options = {}) {
392
493
  trimValues,
393
494
  preserveNull
394
495
  });
395
- setReqPropertySafe(req, source, cleaned);
396
- setReqPropertySafe(req, pollutedKey, pollutedTree);
397
- 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
+ }
398
511
  const sourceData = req[source];
399
512
  const pollutedData = req[pollutedKey];
400
513
  if (isPlainObject(sourceData) && isPlainObject(pollutedData)) {
@@ -461,10 +574,8 @@ function hppx(options = {}) {
461
574
  try {
462
575
  logger(error);
463
576
  } catch (logErr) {
464
- if (process.env.NODE_ENV !== "production") {
465
- console.error("[hppx] Logger failed:", logErr);
466
- console.error("[hppx] Original error:", error);
467
- }
577
+ console.error("[hppx] Logger failed:", logErr);
578
+ console.error("[hppx] Original error:", error);
468
579
  }
469
580
  }
470
581
  return next(error);
@@ -475,6 +586,7 @@ export {
475
586
  DANGEROUS_KEYS,
476
587
  DEFAULT_SOURCES,
477
588
  DEFAULT_STRATEGY,
589
+ __resetPathSegmentCache,
478
590
  hppx as default,
479
591
  sanitize
480
592
  };