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.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,23 +40,28 @@ 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
  }
62
+ function __resetPathSegmentCache() {
63
+ pathSegmentCache.clear();
64
+ }
58
65
  function expandObjectPaths(obj, maxKeyLength, maxDepth = 20, currentDepth = 0, seen) {
59
66
  if (currentDepth > maxDepth) {
60
67
  throw new Error(`Maximum object depth (${maxDepth}) exceeded`);
@@ -62,35 +69,36 @@ function expandObjectPaths(obj, maxKeyLength, maxDepth = 20, currentDepth = 0, s
62
69
  const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
63
70
  if (seenSet.has(obj)) return {};
64
71
  seenSet.add(obj);
65
- const result = {};
66
- for (const rawKey of Object.keys(obj)) {
67
- const safeKey = sanitizeKey(rawKey, maxKeyLength);
68
- if (!safeKey) continue;
69
- const value = obj[rawKey];
70
- const expandedValue = isPlainObject(value) ? expandObjectPaths(
71
- value,
72
- maxKeyLength,
73
- maxDepth,
74
- currentDepth + 1,
75
- seenSet
76
- ) : value;
77
- if (safeKey.includes(".") || safeKey.includes("[")) {
78
- const segments = parsePathSegments(safeKey);
79
- if (segments.length > 0) {
80
- setIn(result, segments, expandedValue);
81
- continue;
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
+ }
82
91
  }
92
+ result[safeKey] = expandedValue;
83
93
  }
84
- result[safeKey] = expandedValue;
94
+ return result;
95
+ } finally {
96
+ seenSet.delete(obj);
85
97
  }
86
- return result;
87
98
  }
88
- function setReqPropertySafe(target, key, value) {
99
+ function setReqPropertySafe(target, key, value, onFailure) {
89
100
  try {
90
101
  const desc = Object.getOwnPropertyDescriptor(target, key);
91
- if (desc && desc.configurable === false && desc.writable === false) {
92
- return;
93
- }
94
102
  if (!desc || desc.configurable !== false) {
95
103
  Object.defineProperty(target, key, {
96
104
  value,
@@ -98,13 +106,35 @@ function setReqPropertySafe(target, key, value) {
98
106
  configurable: true,
99
107
  enumerable: true
100
108
  });
101
- 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;
102
137
  }
103
- } catch (_) {
104
- }
105
- try {
106
- target[key] = value;
107
- } catch (_) {
108
138
  }
109
139
  }
110
140
  function safeDeepClone(input, maxKeyLength, maxArrayLength, maxDepth = 20, currentDepth = 0, seen) {
@@ -115,11 +145,15 @@ function safeDeepClone(input, maxKeyLength, maxArrayLength, maxDepth = 20, curre
115
145
  const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
116
146
  if (seenSet.has(input)) return [];
117
147
  seenSet.add(input);
118
- const limit = maxArrayLength ?? 1e3;
119
- const limited = input.slice(0, limit);
120
- return limited.map(
121
- (v) => safeDeepClone(v, maxKeyLength, maxArrayLength, maxDepth, currentDepth + 1, seenSet)
122
- );
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
+ }
123
157
  }
124
158
  if (isPlainObject(input)) {
125
159
  if (currentDepth > maxDepth) {
@@ -128,19 +162,23 @@ function safeDeepClone(input, maxKeyLength, maxArrayLength, maxDepth = 20, curre
128
162
  const seenSet = seen ?? /* @__PURE__ */ new WeakSet();
129
163
  if (seenSet.has(input)) return {};
130
164
  seenSet.add(input);
131
- const out = {};
132
- for (const k of Object.keys(input)) {
133
- if (!sanitizeKey(k, maxKeyLength)) continue;
134
- out[k] = safeDeepClone(
135
- input[k],
136
- maxKeyLength,
137
- maxArrayLength,
138
- maxDepth,
139
- currentDepth + 1,
140
- seenSet
141
- );
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);
142
181
  }
143
- return out;
144
182
  }
145
183
  return input;
146
184
  }
@@ -156,9 +194,15 @@ function mergeValues(values, strategy) {
156
194
  else acc.push(v);
157
195
  return acc;
158
196
  }, []);
159
- /* istanbul ignore next -- unreachable: strategy is validated before reaching mergeValues */
160
- default:
161
- 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
+ }
162
206
  }
163
207
  }
164
208
  function isUrlEncodedContentType(req) {
@@ -182,6 +226,7 @@ function normalizeWhitelist(whitelist) {
182
226
  if (typeof whitelist === "string") return [whitelist];
183
227
  return whitelist.filter((w) => typeof w === "string");
184
228
  }
229
+ var WHITELIST_PATH_CACHE_LIMIT = 1e3;
185
230
  function buildWhitelistHelpers(whitelist) {
186
231
  const exact = new Set(whitelist);
187
232
  const prefixes = whitelist.filter((w) => w.length > 0);
@@ -207,9 +252,10 @@ function buildWhitelistHelpers(whitelist) {
207
252
  }
208
253
  }
209
254
  }
210
- if (pathCache.size < 1e3) {
211
- pathCache.set(full, result);
255
+ if (pathCache.size >= WHITELIST_PATH_CACHE_LIMIT) {
256
+ pathCache.clear();
212
257
  }
258
+ pathCache.set(full, result);
213
259
  return result;
214
260
  }
215
261
  };
@@ -257,25 +303,18 @@ function moveWhitelistedFromPolluted(reqPart, polluted, isWhitelisted) {
257
303
  function detectAndReduce(input, opts) {
258
304
  let keyCount = 0;
259
305
  const polluted = {};
260
- const pollutedKeys = [];
261
- function processNode(node, path = [], depth = 0) {
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) {
262
309
  if (node === null) return opts.preserveNull ? null : void 0;
263
310
  if (node === void 0) return node;
264
311
  if (Array.isArray(node)) {
265
- const limit = opts.maxArrayLength ?? 1e3;
266
- const limitedNode = node.slice(0, limit);
267
- const mapped = limitedNode.map((v) => processNode(v, path, depth));
268
- if (opts.mergeStrategy === "combine") {
269
- 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("."));
270
316
  }
271
- setIn(
272
- polluted,
273
- path,
274
- safeDeepClone(limitedNode, opts.maxKeyLength, opts.maxArrayLength, opts.maxDepth)
275
- );
276
- pollutedKeys.push(path.join("."));
277
- const reduced = mergeValues(mapped, opts.mergeStrategy);
278
- return reduced;
317
+ return mergeValues(mapped, opts.mergeStrategy);
279
318
  }
280
319
  if (isPlainObject(node)) {
281
320
  if (depth > opts.maxDepth)
@@ -290,7 +329,7 @@ function detectAndReduce(input, opts) {
290
329
  if (!safeKey) continue;
291
330
  const child = node[rawKey];
292
331
  const childPath = path.concat([safeKey]);
293
- let value = processNode(child, childPath, depth + 1);
332
+ let value = processNode(child, childPath, depth + 1, false);
294
333
  if (typeof value === "string" && opts.trimValues) value = value.trim();
295
334
  out[safeKey] = value;
296
335
  }
@@ -298,9 +337,8 @@ function detectAndReduce(input, opts) {
298
337
  }
299
338
  return node;
300
339
  }
301
- const cloned = safeDeepClone(input, opts.maxKeyLength, opts.maxArrayLength, opts.maxDepth);
302
- const cleaned = processNode(cloned, [], 0);
303
- return { cleaned, pollutedTree: polluted, pollutedKeys };
340
+ const cleaned = processNode(cloned, [], 0, false);
341
+ return { cleaned, pollutedTree: polluted, pollutedKeys: Array.from(pollutedKeysSet) };
304
342
  }
305
343
  function sanitize(input, options = {}) {
306
344
  validateSanitizeOptions(options);
@@ -345,6 +383,24 @@ function validateSanitizeOptions(options) {
345
383
  if (options.mergeStrategy !== void 0 && !["keepFirst", "keepLast", "combine"].includes(options.mergeStrategy)) {
346
384
  throw new TypeError("mergeStrategy must be 'keepFirst', 'keepLast', or 'combine'");
347
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
+ }
348
404
  }
349
405
  function validateOptions(options) {
350
406
  validateSanitizeOptions(options);
@@ -352,6 +408,9 @@ function validateOptions(options) {
352
408
  throw new TypeError("sources must be an array");
353
409
  }
354
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
+ }
355
414
  for (const source of options.sources) {
356
415
  if (!["query", "body", "params"].includes(source)) {
357
416
  throw new TypeError("sources must only contain 'query', 'body', or 'params'");
@@ -361,8 +420,15 @@ function validateOptions(options) {
361
420
  if (options.checkBodyContentType !== void 0 && !["urlencoded", "any", "none"].includes(options.checkBodyContentType)) {
362
421
  throw new TypeError("checkBodyContentType must be 'urlencoded', 'any', or 'none'");
363
422
  }
364
- if (options.excludePaths !== void 0 && !Array.isArray(options.excludePaths)) {
365
- 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
+ }
366
432
  }
367
433
  if (options.logger !== void 0 && typeof options.logger !== "function") {
368
434
  throw new TypeError("logger must be a function");
@@ -370,6 +436,12 @@ function validateOptions(options) {
370
436
  if (options.onPollutionDetected !== void 0 && typeof options.onPollutionDetected !== "function") {
371
437
  throw new TypeError("onPollutionDetected must be a function");
372
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");
444
+ }
373
445
  }
374
446
  function hppx(options = {}) {
375
447
  validateOptions(options);
@@ -394,9 +466,39 @@ function hppx(options = {}) {
394
466
  const { isWhitelistedPath } = buildWhitelistHelpers(whitelistArr);
395
467
  return function hppxMiddleware(req, res, next) {
396
468
  try {
397
- 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();
398
486
  let anyPollutionDetected = false;
399
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
+ };
400
502
  for (const source of sources) {
401
503
  if (!req || typeof req !== "object") break;
402
504
  if (req[source] === void 0) continue;
@@ -409,7 +511,7 @@ function hppx(options = {}) {
409
511
  const expandedPart = expandObjectPaths(part, maxKeyLength, maxDepth);
410
512
  const pollutedKey = `${source}Polluted`;
411
513
  const processedKey = `__hppxProcessed_${source}`;
412
- const hasProcessedBefore = Boolean(req[processedKey]);
514
+ const hasProcessedBefore = Object.prototype.hasOwnProperty.call(req, processedKey);
413
515
  if (!hasProcessedBefore) {
414
516
  const { cleaned, pollutedTree, pollutedKeys } = detectAndReduce(expandedPart, {
415
517
  mergeStrategy,
@@ -420,9 +522,21 @@ function hppx(options = {}) {
420
522
  trimValues,
421
523
  preserveNull
422
524
  });
423
- setReqPropertySafe(req, source, cleaned);
424
- setReqPropertySafe(req, pollutedKey, pollutedTree);
425
- 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
+ }
426
540
  const sourceData = req[source];
427
541
  const pollutedData = req[pollutedKey];
428
542
  if (isPlainObject(sourceData) && isPlainObject(pollutedData)) {
@@ -489,10 +603,8 @@ function hppx(options = {}) {
489
603
  try {
490
604
  logger(error);
491
605
  } catch (logErr) {
492
- if (process.env.NODE_ENV !== "production") {
493
- console.error("[hppx] Logger failed:", logErr);
494
- console.error("[hppx] Original error:", error);
495
- }
606
+ console.error("[hppx] Logger failed:", logErr);
607
+ console.error("[hppx] Original error:", error);
496
608
  }
497
609
  }
498
610
  return next(error);
@@ -504,6 +616,7 @@ function hppx(options = {}) {
504
616
  DANGEROUS_KEYS,
505
617
  DEFAULT_SOURCES,
506
618
  DEFAULT_STRATEGY,
619
+ __resetPathSegmentCache,
507
620
  sanitize
508
621
  });
509
622
  if (module.exports.default) { module.exports = Object.assign(module.exports.default, module.exports); }