@emeryld/rrroutes-server 2.4.9 → 2.5.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.
package/README.md CHANGED
@@ -68,8 +68,8 @@ const server = createRRRoute(app, {
68
68
  user: await loadUser(req),
69
69
  routesLogger: console,
70
70
  }), // ctx lives on res.locals[CTX_SYMBOL]
71
- globalMiddleware: {
72
- before: [
71
+ middleware: {
72
+ postCtx: [
73
73
  ({ ctx, next }) => {
74
74
  if (!ctx.user) throw new Error('unauthorized')
75
75
  next()
@@ -169,7 +169,7 @@ const server = createRRRoute(app, {
169
169
 
170
170
  ### Middleware order and ctx usage
171
171
 
172
- Order: `resolve` → `ctx` → `global.before` → `route.before` → handler.
172
+ Order: `sanitizer` → `preCtx` → `resolve` → `ctx` → `postCtx` → `route.before` → handler.
173
173
 
174
174
  ```ts
175
175
  import { getCtx, CtxRequestHandler } from '@emeryld/rrroutes-server'
@@ -181,7 +181,7 @@ const audit: CtxRequestHandler<Ctx> = ({ ctx, req, next }) => {
181
181
 
182
182
  const server = createRRRoute(app, {
183
183
  buildCtx: (req, res) => ({ user: res.locals.user, routesLogger: console }),
184
- globalMiddleware: { before: [audit] },
184
+ middleware: { postCtx: [audit] },
185
185
  })
186
186
 
187
187
  const routeBefore = ({ params, query, body, ctx, next }) => {
@@ -199,7 +199,38 @@ app.use((req, res, next) => {
199
199
 
200
200
  - `CtxRequestHandler` receives `{ req, res, next, ctx }` with your typed ctx.
201
201
  - `route.before` handlers now receive the same parsed `params`, `query`, and `body` payload as the handler, alongside `req`, `res`, and `ctx`.
202
- - Need post-response hooks? Register a middleware that wires `res.on('finish', handler)` inside `route.before`/`global.before` instead of relying on a dedicated "after" stage.
202
+ - Need post-response hooks? Register a middleware that wires `res.on('finish', handler)` inside `route.before`/`middleware.postCtx` instead of relying on a dedicated "after" stage.
203
+
204
+ ### Request sanitization
205
+
206
+ Use `middleware.sanitizer` when you want to sanitize raw request data before RRRoutes parses params/query/body.
207
+
208
+ ```ts
209
+ const server = createRRRoute(app, {
210
+ buildCtx,
211
+ middleware: {
212
+ sanitizer: {
213
+ trimStrings: true,
214
+ customSanitizer: (value, context) => {
215
+ if (context.target === 'query' && typeof value === 'string') {
216
+ return value.toLowerCase()
217
+ }
218
+ return value
219
+ },
220
+ },
221
+ },
222
+ })
223
+ ```
224
+
225
+ By default, the sanitizer:
226
+
227
+ - strips null bytes from strings
228
+ - removes prototype-pollution keys (`__proto__`, `prototype`, `constructor`)
229
+ - keeps whitespace unless `trimStrings: true` is set
230
+
231
+ `blockedKeys` exists to prevent prototype-pollution payloads from surviving into downstream object merges.
232
+
233
+ For full sanitizer docs/options, see `./SANITIZER.md`.
203
234
 
204
235
  ### Upload parsing
205
236
 
@@ -272,7 +303,7 @@ Context logger passthrough: if `buildCtx` provides `routesLogger`, handler debug
272
303
 
273
304
  - **Combine registries:** build leaves per domain, spread before `finalize([...usersLeaves, ...projectsLeaves])`, then register once.
274
305
  - **Fail fast on missing controllers:** use `bindAll(...)` for compile-time coverage or call `warnMissingControllers(...)` during startup to surface missing routes.
275
- - **Operator-specific middleware:** attach `route.before` per controller (e.g., role checks) and keep `global.before` minimal (auth/session parsing).
306
+ - **Operator-specific middleware:** attach `route.before` per controller (e.g., role checks) and keep `middleware.postCtx` minimal (auth/session parsing).
276
307
 
277
308
  ## Socket server (typed events, heartbeat, rooms)
278
309
 
package/dist/index.cjs CHANGED
@@ -37,9 +37,11 @@ __export(index_exports, {
37
37
  contractKeyOf: () => import_rrroutes_contract2.keyOf,
38
38
  createConnectionLoggingMiddleware: () => createConnectionLoggingMiddleware,
39
39
  createRRRoute: () => createRRRoute,
40
+ createRequestSanitizationMiddleware: () => createRequestSanitizationMiddleware,
40
41
  createSocketConnections: () => createSocketConnections,
41
42
  defineControllers: () => defineControllers,
42
43
  getCtx: () => getCtx,
44
+ requestSanitizationMiddleware: () => requestSanitizationMiddleware,
43
45
  warnMissingControllers: () => warnMissingControllers
44
46
  });
45
47
  module.exports = __toCommonJS(index_exports);
@@ -48,6 +50,140 @@ var import_rrroutes_contract2 = require("@emeryld/rrroutes-contract");
48
50
  // src/routesV3.server.ts
49
51
  var import_rrroutes_contract = require("@emeryld/rrroutes-contract");
50
52
  var import_multer = __toESM(require("multer"), 1);
53
+
54
+ // src/routesV3.server.sanitize.ts
55
+ var defaultTargets = ["params", "query", "body"];
56
+ var defaultBlockedKeys = ["__proto__", "prototype", "constructor"];
57
+ var defaultMaxDepth = 20;
58
+ var nullBytePattern = /\u0000/g;
59
+ var isPlainObject = (value) => {
60
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
61
+ const proto = Object.getPrototypeOf(value);
62
+ return proto === Object.prototype || proto === null;
63
+ };
64
+ var normalizeOptions = (options) => {
65
+ return {
66
+ targets: new Set(options.targets ?? defaultTargets),
67
+ trimStrings: options.trimStrings ?? false,
68
+ stripNullBytes: options.stripNullBytes ?? true,
69
+ stripPrototypePollutionKeys: options.stripPrototypePollutionKeys ?? true,
70
+ blockedKeys: new Set(options.blockedKeys ?? defaultBlockedKeys),
71
+ maxDepth: options.maxDepth ?? defaultMaxDepth,
72
+ customSanitizer: options.customSanitizer
73
+ };
74
+ };
75
+ var applyCustomSanitizer = (value, options, context) => {
76
+ if (!options.customSanitizer) return value;
77
+ return options.customSanitizer(value, context);
78
+ };
79
+ var sanitizeString = (value, options) => {
80
+ let next = value;
81
+ if (options.stripNullBytes && next.includes("\0")) {
82
+ next = next.replace(nullBytePattern, "");
83
+ }
84
+ if (options.trimStrings) {
85
+ next = next.trim();
86
+ }
87
+ return next;
88
+ };
89
+ var sanitizeValue = (value, options, depth, seen, req, target, path) => {
90
+ const context = {
91
+ req,
92
+ target,
93
+ path,
94
+ depth
95
+ };
96
+ if (depth > options.maxDepth) {
97
+ return applyCustomSanitizer(value, options, context);
98
+ }
99
+ if (typeof value === "string") {
100
+ const sanitized = sanitizeString(value, options);
101
+ return applyCustomSanitizer(sanitized, options, context);
102
+ }
103
+ if (value && typeof value === "object" && seen.has(value)) {
104
+ return applyCustomSanitizer(value, options, context);
105
+ }
106
+ if (Array.isArray(value)) {
107
+ seen.add(value);
108
+ const next = value.map(
109
+ (entry, index) => sanitizeValue(entry, options, depth + 1, seen, req, target, [
110
+ ...path,
111
+ index
112
+ ])
113
+ );
114
+ seen.delete(value);
115
+ return applyCustomSanitizer(next, options, context);
116
+ }
117
+ if (!isPlainObject(value)) {
118
+ return applyCustomSanitizer(value, options, context);
119
+ }
120
+ seen.add(value);
121
+ const source = value;
122
+ const objectTarget = Object.getPrototypeOf(source) === null ? /* @__PURE__ */ Object.create(null) : {};
123
+ for (const [key, entry] of Object.entries(source)) {
124
+ if (options.stripPrototypePollutionKeys && options.blockedKeys.has(key)) {
125
+ continue;
126
+ }
127
+ ;
128
+ objectTarget[key] = sanitizeValue(
129
+ entry,
130
+ options,
131
+ depth + 1,
132
+ seen,
133
+ req,
134
+ context.target,
135
+ [...path, key]
136
+ );
137
+ }
138
+ seen.delete(value);
139
+ return applyCustomSanitizer(objectTarget, options, context);
140
+ };
141
+ var createRequestSanitizationMiddleware = (options = {}) => {
142
+ const normalized = normalizeOptions(options);
143
+ return (req, _res, next) => {
144
+ try {
145
+ if (normalized.targets.has("params") && req.params) {
146
+ req.params = sanitizeValue(
147
+ req.params,
148
+ normalized,
149
+ 0,
150
+ /* @__PURE__ */ new WeakSet(),
151
+ req,
152
+ "params",
153
+ []
154
+ );
155
+ }
156
+ if (normalized.targets.has("query") && req.query) {
157
+ req.query = sanitizeValue(
158
+ req.query,
159
+ normalized,
160
+ 0,
161
+ /* @__PURE__ */ new WeakSet(),
162
+ req,
163
+ "query",
164
+ []
165
+ );
166
+ }
167
+ if (normalized.targets.has("body") && req.body !== void 0) {
168
+ req.body = sanitizeValue(
169
+ req.body,
170
+ normalized,
171
+ 0,
172
+ /* @__PURE__ */ new WeakSet(),
173
+ req,
174
+ "body",
175
+ []
176
+ );
177
+ }
178
+ next();
179
+ } catch (err) {
180
+ next(err);
181
+ }
182
+ };
183
+ };
184
+ var requestSanitizationMiddleware = createRequestSanitizationMiddleware();
185
+
186
+ // src/routesV3.server.ts
51
187
  var serverDebugEventTypes = [
52
188
  "register",
53
189
  "request",
@@ -81,12 +217,12 @@ function createServerDebugEmitter(option) {
81
217
  }
82
218
  return disabled;
83
219
  }
84
- var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
220
+ var isPlainObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
85
221
  var decodeJsonLikeQueryValue = (value) => {
86
222
  if (Array.isArray(value)) {
87
223
  return value.map((entry) => decodeJsonLikeQueryValue(entry));
88
224
  }
89
- if (isPlainObject(value)) {
225
+ if (isPlainObject2(value)) {
90
226
  const next = {};
91
227
  for (const [key, child] of Object.entries(value)) {
92
228
  next[key] = decodeJsonLikeQueryValue(child);
@@ -110,7 +246,7 @@ var REQUEST_PAYLOAD_SYMBOL = /* @__PURE__ */ Symbol.for(
110
246
  "typedLeaves.requestPayload"
111
247
  );
112
248
  function isMulterFile(value) {
113
- if (!isPlainObject(value)) return false;
249
+ if (!isPlainObject2(value)) return false;
114
250
  const candidate = value;
115
251
  return typeof candidate.fieldname === "string";
116
252
  }
@@ -126,7 +262,7 @@ function collectMulterFiles(req) {
126
262
  files.push(value);
127
263
  return;
128
264
  }
129
- if (isPlainObject(value)) {
265
+ if (isPlainObject2(value)) {
130
266
  Object.values(value).forEach(pushValue);
131
267
  }
132
268
  };
@@ -252,9 +388,12 @@ function createRRRoute(router, config) {
252
388
  if (!isVerbose || !details) return event;
253
389
  return { ...event, ...details };
254
390
  };
255
- const globalBeforeMws = [...config.globalMiddleware ?? []].map(
391
+ const middlewareConfig = config.middleware ?? {};
392
+ const postCtxMws = [...middlewareConfig.postCtx ?? []].map(
256
393
  (mw) => adaptCtxMw(mw)
257
394
  );
395
+ const preCtxMws = [...middlewareConfig.preCtx ?? []];
396
+ const sanitizerMw = middlewareConfig.sanitizer === void 0 ? void 0 : typeof middlewareConfig.sanitizer === "function" ? middlewareConfig.sanitizer : createRequestSanitizationMiddleware(middlewareConfig.sanitizer);
258
397
  const registered = getRegisteredRouteStore(router);
259
398
  const getMulterOptions = (fields) => {
260
399
  if (!fields || fields.length === 0) return void 0;
@@ -416,9 +555,11 @@ function createRRRoute(router, config) {
416
555
  }
417
556
  };
418
557
  const before = [
558
+ ...sanitizerMw ? [sanitizerMw] : [],
559
+ ...preCtxMws,
419
560
  resolvePayloadMw,
420
561
  ctxMw,
421
- ...globalBeforeMws,
562
+ ...postCtxMws,
422
563
  ...routeSpecific
423
564
  ];
424
565
  const wrapped = async (req, res, next) => {
@@ -1434,9 +1575,11 @@ var createConnectionLoggingMiddleware = (options = {}) => {
1434
1575
  contractKeyOf,
1435
1576
  createConnectionLoggingMiddleware,
1436
1577
  createRRRoute,
1578
+ createRequestSanitizationMiddleware,
1437
1579
  createSocketConnections,
1438
1580
  defineControllers,
1439
1581
  getCtx,
1582
+ requestSanitizationMiddleware,
1440
1583
  warnMissingControllers
1441
1584
  });
1442
1585
  //# sourceMappingURL=index.cjs.map