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.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  DANGEROUS_KEYS: () => DANGEROUS_KEYS,
24
24
  DEFAULT_SOURCES: () => DEFAULT_SOURCES,
25
25
  DEFAULT_STRATEGY: () => DEFAULT_STRATEGY,
26
+ __resetPathSegmentCache: () => __resetPathSegmentCache,
26
27
  default: () => hppx,
27
28
  sanitize: () => sanitize
28
29
  });
@@ -30,6 +31,7 @@ module.exports = __toCommonJS(index_exports);
30
31
  var DEFAULT_SOURCES = ["query", "body", "params"];
31
32
  var DEFAULT_STRATEGY = "keepLast";
32
33
  var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
34
+ var FORBIDDEN_KEY_CHARS = /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/;
33
35
  function isPlainObject(value) {
34
36
  if (value === null || typeof value !== "object") return false;
35
37
  const proto = Object.getPrototypeOf(value);
@@ -38,47 +40,65 @@ function isPlainObject(value) {
38
40
  function sanitizeKey(key, maxKeyLength) {
39
41
  if (typeof key !== "string") return null;
40
42
  if (DANGEROUS_KEYS.has(key)) return null;
41
- if (key.includes("\0")) return null;
43
+ if (FORBIDDEN_KEY_CHARS.test(key)) return null;
42
44
  const maxLen = maxKeyLength ?? 200;
43
45
  if (key.length > maxLen) return null;
44
46
  if (key.length > 1 && /^[.\[\]]+$/.test(key)) return null;
45
47
  return key;
46
48
  }
49
+ var PATH_SEGMENT_CACHE_LIMIT = 500;
47
50
  var pathSegmentCache = /* @__PURE__ */ new Map();
48
51
  function parsePathSegments(key) {
49
52
  const cached = pathSegmentCache.get(key);
50
53
  if (cached) return cached;
51
54
  const dotted = key.replace(/\]/g, "").replace(/\[/g, ".");
52
55
  const result = dotted.split(".").filter((s) => s.length > 0);
53
- if (pathSegmentCache.size < 500) {
54
- pathSegmentCache.set(key, result);
56
+ if (pathSegmentCache.size >= PATH_SEGMENT_CACHE_LIMIT) {
57
+ pathSegmentCache.clear();
55
58
  }
59
+ pathSegmentCache.set(key, result);
56
60
  return result;
57
61
  }
58
- function expandObjectPaths(obj, maxKeyLength) {
59
- const result = {};
60
- for (const rawKey of Object.keys(obj)) {
61
- const safeKey = sanitizeKey(rawKey, maxKeyLength);
62
- if (!safeKey) continue;
63
- const value = obj[rawKey];
64
- const expandedValue = isPlainObject(value) ? expandObjectPaths(value, maxKeyLength) : value;
65
- if (safeKey.includes(".") || safeKey.includes("[")) {
66
- const segments = parsePathSegments(safeKey);
67
- if (segments.length > 0) {
68
- setIn(result, segments, expandedValue);
69
- continue;
62
+ function __resetPathSegmentCache() {
63
+ pathSegmentCache.clear();
64
+ }
65
+ function expandObjectPaths(obj, maxKeyLength, maxDepth = 20, currentDepth = 0, seen) {
66
+ if (currentDepth > maxDepth) {
67
+ throw new Error(`Maximum object depth (${maxDepth}) exceeded`);
68
+ }
69
+ const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
70
+ if (seenSet.has(obj)) return {};
71
+ seenSet.add(obj);
72
+ try {
73
+ const result = {};
74
+ for (const rawKey of Object.keys(obj)) {
75
+ const safeKey = sanitizeKey(rawKey, maxKeyLength);
76
+ if (!safeKey) continue;
77
+ const value = obj[rawKey];
78
+ const expandedValue = isPlainObject(value) ? expandObjectPaths(
79
+ value,
80
+ maxKeyLength,
81
+ maxDepth,
82
+ currentDepth + 1,
83
+ seenSet
84
+ ) : value;
85
+ if (safeKey.includes(".") || safeKey.includes("[")) {
86
+ const segments = parsePathSegments(safeKey);
87
+ if (segments.length > 0) {
88
+ setIn(result, segments, expandedValue);
89
+ continue;
90
+ }
70
91
  }
92
+ result[safeKey] = expandedValue;
71
93
  }
72
- result[safeKey] = expandedValue;
94
+ return result;
95
+ } finally {
96
+ seenSet.delete(obj);
73
97
  }
74
- return result;
75
98
  }
76
- function setReqPropertySafe(target, key, value) {
99
+ function setReqPropertySafe(target, key, value, onFailure) {
77
100
  try {
78
101
  const desc = Object.getOwnPropertyDescriptor(target, key);
79
- if (desc && desc.configurable === false && desc.writable === false) {
80
- return;
81
- }
82
102
  if (!desc || desc.configurable !== false) {
83
103
  Object.defineProperty(target, key, {
84
104
  value,
@@ -86,28 +106,79 @@ function setReqPropertySafe(target, key, value) {
86
106
  configurable: true,
87
107
  enumerable: true
88
108
  });
89
- return;
109
+ return true;
110
+ }
111
+ if (desc.writable) {
112
+ target[key] = value;
113
+ return true;
114
+ }
115
+ try {
116
+ target[key] = value;
117
+ if (target[key] === value) return true;
118
+ } catch (_assignErr) {
119
+ }
120
+ if (onFailure) {
121
+ onFailure(
122
+ `[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}.`
123
+ );
124
+ }
125
+ return false;
126
+ } catch (_definePropErr) {
127
+ try {
128
+ target[key] = value;
129
+ if (target[key] === value) return true;
130
+ } catch (_assignErr) {
131
+ }
132
+ {
133
+ if (onFailure) {
134
+ onFailure(`[hppx] Could not write sanitized value to req.${key}: defineProperty failed.`);
135
+ }
136
+ return false;
90
137
  }
91
- } catch (_) {
92
- }
93
- try {
94
- target[key] = value;
95
- } catch (_) {
96
138
  }
97
139
  }
98
- function safeDeepClone(input, maxKeyLength, maxArrayLength) {
140
+ function safeDeepClone(input, maxKeyLength, maxArrayLength, maxDepth = 20, currentDepth = 0, seen) {
99
141
  if (Array.isArray(input)) {
100
- const limit = maxArrayLength ?? 1e3;
101
- const limited = input.slice(0, limit);
102
- return limited.map((v) => safeDeepClone(v, maxKeyLength, maxArrayLength));
142
+ if (currentDepth > maxDepth) {
143
+ throw new Error(`Maximum object depth (${maxDepth}) exceeded`);
144
+ }
145
+ const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
146
+ if (seenSet.has(input)) return [];
147
+ seenSet.add(input);
148
+ try {
149
+ const limit = maxArrayLength ?? 1e3;
150
+ const limited = input.slice(0, limit);
151
+ return limited.map(
152
+ (v) => safeDeepClone(v, maxKeyLength, maxArrayLength, maxDepth, currentDepth + 1, seenSet)
153
+ );
154
+ } finally {
155
+ seenSet.delete(input);
156
+ }
103
157
  }
104
158
  if (isPlainObject(input)) {
105
- const out = {};
106
- for (const k of Object.keys(input)) {
107
- if (!sanitizeKey(k, maxKeyLength)) continue;
108
- out[k] = safeDeepClone(input[k], maxKeyLength, maxArrayLength);
159
+ if (currentDepth > maxDepth) {
160
+ throw new Error(`Maximum object depth (${maxDepth}) exceeded`);
161
+ }
162
+ const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
163
+ if (seenSet.has(input)) return {};
164
+ seenSet.add(input);
165
+ try {
166
+ const out = {};
167
+ for (const k of Object.keys(input)) {
168
+ if (!sanitizeKey(k, maxKeyLength)) continue;
169
+ out[k] = safeDeepClone(
170
+ input[k],
171
+ maxKeyLength,
172
+ maxArrayLength,
173
+ maxDepth,
174
+ currentDepth + 1,
175
+ seenSet
176
+ );
177
+ }
178
+ return out;
179
+ } finally {
180
+ seenSet.delete(input);
109
181
  }
110
- return out;
111
182
  }
112
183
  return input;
113
184
  }
@@ -123,8 +194,15 @@ function mergeValues(values, strategy) {
123
194
  else acc.push(v);
124
195
  return acc;
125
196
  }, []);
126
- default:
127
- return values[values.length - 1];
197
+ /* istanbul ignore next -- exhaustiveness check unreachable from outside:
198
+ validateSanitizeOptions rejects every non-listed strategy at construction
199
+ time, so the only way to reach this branch is a programmer error (a new
200
+ MergeStrategy union member added without updating this switch). Failing
201
+ loudly here is preferable to a silent fallback. */
202
+ default: {
203
+ const _exhaustive = strategy;
204
+ throw new Error(`Unknown mergeStrategy: ${_exhaustive}`);
205
+ }
128
206
  }
129
207
  }
130
208
  function isUrlEncodedContentType(req) {
@@ -148,6 +226,7 @@ function normalizeWhitelist(whitelist) {
148
226
  if (typeof whitelist === "string") return [whitelist];
149
227
  return whitelist.filter((w) => typeof w === "string");
150
228
  }
229
+ var WHITELIST_PATH_CACHE_LIMIT = 1e3;
151
230
  function buildWhitelistHelpers(whitelist) {
152
231
  const exact = new Set(whitelist);
153
232
  const prefixes = whitelist.filter((w) => w.length > 0);
@@ -173,9 +252,10 @@ function buildWhitelistHelpers(whitelist) {
173
252
  }
174
253
  }
175
254
  }
176
- if (pathCache.size < 1e3) {
177
- pathCache.set(full, result);
255
+ if (pathCache.size >= WHITELIST_PATH_CACHE_LIMIT) {
256
+ pathCache.clear();
178
257
  }
258
+ pathCache.set(full, result);
179
259
  return result;
180
260
  }
181
261
  };
@@ -223,20 +303,18 @@ function moveWhitelistedFromPolluted(reqPart, polluted, isWhitelisted) {
223
303
  function detectAndReduce(input, opts) {
224
304
  let keyCount = 0;
225
305
  const polluted = {};
226
- const pollutedKeys = [];
227
- function processNode(node, path = [], depth = 0) {
228
- if (node === null || node === void 0) return opts.preserveNull ? node : node;
306
+ const pollutedKeysSet = /* @__PURE__ */ new Set();
307
+ const cloned = safeDeepClone(input, opts.maxKeyLength, opts.maxArrayLength, opts.maxDepth);
308
+ function processNode(node, path = [], depth = 0, inArray = false) {
309
+ if (node === null) return opts.preserveNull ? null : void 0;
310
+ if (node === void 0) return node;
229
311
  if (Array.isArray(node)) {
230
- const limit = opts.maxArrayLength ?? 1e3;
231
- const limitedNode = node.slice(0, limit);
232
- const mapped = limitedNode.map((v) => processNode(v, path, depth));
233
- if (opts.mergeStrategy === "combine") {
234
- return mergeValues(mapped, "combine");
312
+ const mapped = node.map((v) => processNode(v, path, depth, true));
313
+ if (!inArray) {
314
+ setIn(polluted, path, node);
315
+ pollutedKeysSet.add(path.join("."));
235
316
  }
236
- setIn(polluted, path, safeDeepClone(limitedNode, opts.maxKeyLength, opts.maxArrayLength));
237
- pollutedKeys.push(path.join("."));
238
- const reduced = mergeValues(mapped, opts.mergeStrategy);
239
- return reduced;
317
+ return mergeValues(mapped, opts.mergeStrategy);
240
318
  }
241
319
  if (isPlainObject(node)) {
242
320
  if (depth > opts.maxDepth)
@@ -251,7 +329,7 @@ function detectAndReduce(input, opts) {
251
329
  if (!safeKey) continue;
252
330
  const child = node[rawKey];
253
331
  const childPath = path.concat([safeKey]);
254
- let value = processNode(child, childPath, depth + 1);
332
+ let value = processNode(child, childPath, depth + 1, false);
255
333
  if (typeof value === "string" && opts.trimValues) value = value.trim();
256
334
  out[safeKey] = value;
257
335
  }
@@ -259,13 +337,14 @@ function detectAndReduce(input, opts) {
259
337
  }
260
338
  return node;
261
339
  }
262
- const cloned = safeDeepClone(input, opts.maxKeyLength, opts.maxArrayLength);
263
- const cleaned = processNode(cloned, [], 0);
264
- return { cleaned, pollutedTree: polluted, pollutedKeys };
340
+ const cleaned = processNode(cloned, [], 0, false);
341
+ return { cleaned, pollutedTree: polluted, pollutedKeys: Array.from(pollutedKeysSet) };
265
342
  }
266
343
  function sanitize(input, options = {}) {
344
+ validateSanitizeOptions(options);
267
345
  const maxKeyLength = options.maxKeyLength ?? 200;
268
- const expandedInput = isPlainObject(input) ? expandObjectPaths(input, maxKeyLength) : input;
346
+ const maxDepthVal = options.maxDepth ?? 20;
347
+ const expandedInput = isPlainObject(input) ? expandObjectPaths(input, maxKeyLength, maxDepthVal) : input;
269
348
  const whitelist = normalizeWhitelist(options.whitelist);
270
349
  const { isWhitelistedPath } = buildWhitelistHelpers(whitelist);
271
350
  const {
@@ -288,7 +367,7 @@ function sanitize(input, options = {}) {
288
367
  moveWhitelistedFromPolluted(cleaned, pollutedTree, isWhitelistedPath);
289
368
  return cleaned;
290
369
  }
291
- function validateOptions(options) {
370
+ function validateSanitizeOptions(options) {
292
371
  if (options.maxDepth !== void 0 && (typeof options.maxDepth !== "number" || options.maxDepth < 1 || options.maxDepth > 100)) {
293
372
  throw new TypeError("maxDepth must be a number between 1 and 100");
294
373
  }
@@ -304,10 +383,34 @@ function validateOptions(options) {
304
383
  if (options.mergeStrategy !== void 0 && !["keepFirst", "keepLast", "combine"].includes(options.mergeStrategy)) {
305
384
  throw new TypeError("mergeStrategy must be 'keepFirst', 'keepLast', or 'combine'");
306
385
  }
386
+ if (options.trimValues !== void 0 && typeof options.trimValues !== "boolean") {
387
+ throw new TypeError("trimValues must be a boolean");
388
+ }
389
+ if (options.preserveNull !== void 0 && typeof options.preserveNull !== "boolean") {
390
+ throw new TypeError("preserveNull must be a boolean");
391
+ }
392
+ if (options.whitelist !== void 0) {
393
+ if (typeof options.whitelist !== "string" && !Array.isArray(options.whitelist)) {
394
+ throw new TypeError("whitelist must be a string or an array of strings");
395
+ }
396
+ if (Array.isArray(options.whitelist)) {
397
+ for (const entry of options.whitelist) {
398
+ if (typeof entry !== "string") {
399
+ throw new TypeError("whitelist must be a string or an array of strings");
400
+ }
401
+ }
402
+ }
403
+ }
404
+ }
405
+ function validateOptions(options) {
406
+ validateSanitizeOptions(options);
307
407
  if (options.sources !== void 0 && !Array.isArray(options.sources)) {
308
408
  throw new TypeError("sources must be an array");
309
409
  }
310
410
  if (options.sources !== void 0) {
411
+ if (options.sources.length === 0) {
412
+ throw new TypeError("sources must contain at least one of 'query', 'body', 'params'");
413
+ }
311
414
  for (const source of options.sources) {
312
415
  if (!["query", "body", "params"].includes(source)) {
313
416
  throw new TypeError("sources must only contain 'query', 'body', or 'params'");
@@ -317,8 +420,27 @@ function validateOptions(options) {
317
420
  if (options.checkBodyContentType !== void 0 && !["urlencoded", "any", "none"].includes(options.checkBodyContentType)) {
318
421
  throw new TypeError("checkBodyContentType must be 'urlencoded', 'any', or 'none'");
319
422
  }
320
- if (options.excludePaths !== void 0 && !Array.isArray(options.excludePaths)) {
321
- throw new TypeError("excludePaths must be an array");
423
+ if (options.excludePaths !== void 0) {
424
+ if (!Array.isArray(options.excludePaths)) {
425
+ throw new TypeError("excludePaths must be an array");
426
+ }
427
+ for (const entry of options.excludePaths) {
428
+ if (typeof entry !== "string") {
429
+ throw new TypeError("excludePaths must contain only strings");
430
+ }
431
+ }
432
+ }
433
+ if (options.logger !== void 0 && typeof options.logger !== "function") {
434
+ throw new TypeError("logger must be a function");
435
+ }
436
+ if (options.onPollutionDetected !== void 0 && typeof options.onPollutionDetected !== "function") {
437
+ throw new TypeError("onPollutionDetected must be a function");
438
+ }
439
+ if (options.strict !== void 0 && typeof options.strict !== "boolean") {
440
+ throw new TypeError("strict must be a boolean");
441
+ }
442
+ if (options.logPollution !== void 0 && typeof options.logPollution !== "boolean") {
443
+ throw new TypeError("logPollution must be a boolean");
322
444
  }
323
445
  }
324
446
  function hppx(options = {}) {
@@ -344,9 +466,39 @@ function hppx(options = {}) {
344
466
  const { isWhitelistedPath } = buildWhitelistHelpers(whitelistArr);
345
467
  return function hppxMiddleware(req, res, next) {
346
468
  try {
347
- if (shouldExcludePath(req?.path, excludePaths)) return next();
469
+ let pathForExclusion;
470
+ try {
471
+ pathForExclusion = req?.path;
472
+ } catch (pathErr) {
473
+ 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)}`;
474
+ if (logger) {
475
+ try {
476
+ logger(message);
477
+ } catch (_) {
478
+ console.warn(message);
479
+ }
480
+ } else {
481
+ console.warn(message);
482
+ }
483
+ pathForExclusion = void 0;
484
+ }
485
+ if (shouldExcludePath(pathForExclusion, excludePaths)) return next();
348
486
  let anyPollutionDetected = false;
349
487
  const allPollutedKeys = [];
488
+ const warned = /* @__PURE__ */ new Set();
489
+ const warn = (message) => {
490
+ if (warned.has(message)) return;
491
+ warned.add(message);
492
+ if (logger) {
493
+ try {
494
+ logger(message);
495
+ } catch (_) {
496
+ console.warn(message);
497
+ }
498
+ } else {
499
+ console.warn(message);
500
+ }
501
+ };
350
502
  for (const source of sources) {
351
503
  if (!req || typeof req !== "object") break;
352
504
  if (req[source] === void 0) continue;
@@ -356,10 +508,10 @@ function hppx(options = {}) {
356
508
  }
357
509
  const part = req[source];
358
510
  if (!isPlainObject(part)) continue;
359
- const expandedPart = expandObjectPaths(part, maxKeyLength);
511
+ const expandedPart = expandObjectPaths(part, maxKeyLength, maxDepth);
360
512
  const pollutedKey = `${source}Polluted`;
361
513
  const processedKey = `__hppxProcessed_${source}`;
362
- const hasProcessedBefore = Boolean(req[processedKey]);
514
+ const hasProcessedBefore = Object.prototype.hasOwnProperty.call(req, processedKey);
363
515
  if (!hasProcessedBefore) {
364
516
  const { cleaned, pollutedTree, pollutedKeys } = detectAndReduce(expandedPart, {
365
517
  mergeStrategy,
@@ -370,9 +522,21 @@ function hppx(options = {}) {
370
522
  trimValues,
371
523
  preserveNull
372
524
  });
373
- setReqPropertySafe(req, source, cleaned);
374
- setReqPropertySafe(req, pollutedKey, pollutedTree);
375
- req[processedKey] = true;
525
+ setReqPropertySafe(req, source, cleaned, warn);
526
+ setReqPropertySafe(req, pollutedKey, pollutedTree, warn);
527
+ try {
528
+ Object.defineProperty(req, processedKey, {
529
+ value: true,
530
+ writable: false,
531
+ configurable: false,
532
+ enumerable: false
533
+ });
534
+ } catch (_) {
535
+ try {
536
+ req[processedKey] = true;
537
+ } catch (_assignErr) {
538
+ }
539
+ }
376
540
  const sourceData = req[source];
377
541
  const pollutedData = req[pollutedKey];
378
542
  if (isPlainObject(sourceData) && isPlainObject(pollutedData)) {
@@ -439,10 +603,8 @@ function hppx(options = {}) {
439
603
  try {
440
604
  logger(error);
441
605
  } catch (logErr) {
442
- if (process.env.NODE_ENV !== "production") {
443
- console.error("[hppx] Logger failed:", logErr);
444
- console.error("[hppx] Original error:", error);
445
- }
606
+ console.error("[hppx] Logger failed:", logErr);
607
+ console.error("[hppx] Original error:", error);
446
608
  }
447
609
  }
448
610
  return next(error);
@@ -454,6 +616,7 @@ function hppx(options = {}) {
454
616
  DANGEROUS_KEYS,
455
617
  DEFAULT_SOURCES,
456
618
  DEFAULT_STRATEGY,
619
+ __resetPathSegmentCache,
457
620
  sanitize
458
621
  });
459
622
  if (module.exports.default) { module.exports = Object.assign(module.exports.default, module.exports); }