fulmine.js 5.12.0 → 5.12.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.12.0",
3
+ "version": "5.12.1",
4
4
  "description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -208,11 +208,11 @@ class Application extends Router {
208
208
  // a "trust proxy" this app never set is inherited from the parent, as express does:
209
209
  // the defaults are deleted so get() falls through to the parent's value
210
210
  if (
211
- this.settings[trustProxyDefaultSymbol] === true &&
212
- typeof parent.settings["trust proxy fn"] === "function"
211
+ this._settings[trustProxyDefaultSymbol] === true &&
212
+ typeof parent._settings["trust proxy fn"] === "function"
213
213
  ) {
214
- delete this.settings["trust proxy"];
215
- delete this.settings["trust proxy fn"];
214
+ delete this._settings["trust proxy"];
215
+ delete this._settings["trust proxy fn"];
216
216
  }
217
217
  });
218
218
  this.listenCalled = false;
@@ -248,20 +248,20 @@ class Application extends Router {
248
248
  this._draining = false;
249
249
  // read here, at construction, the way express does; an empty NODE_ENV means development,
250
250
  // which the ?? in the shared default would miss
251
- if (typeof this.settings.env === "undefined") {
252
- this.settings.env = process.env.NODE_ENV || "development";
251
+ if (typeof this._settings.env === "undefined") {
252
+ this._settings.env = process.env.NODE_ENV || "development";
253
253
  }
254
254
  for (const key in defaultSettings) {
255
- if (typeof this.settings[key] === "undefined") {
255
+ if (typeof this._settings[key] === "undefined") {
256
256
  if (typeof defaultSettings[key] === "function") {
257
- this.settings[key] = defaultSettings[key](this);
257
+ this._settings[key] = defaultSettings[key](this);
258
258
  } else {
259
- this.settings[key] = defaultSettings[key];
259
+ this._settings[key] = defaultSettings[key];
260
260
  }
261
261
  }
262
262
  }
263
263
  // non-enumerable, so the marker never shows up walking the settings
264
- Object.defineProperty(this.settings, trustProxyDefaultSymbol, {
264
+ Object.defineProperty(this._settings, trustProxyDefaultSymbol, {
265
265
  configurable: true,
266
266
  value: true
267
267
  });
@@ -372,27 +372,27 @@ class Application extends Router {
372
372
  if (!value) {
373
373
  // compiled, not deleted: an explicit false must shadow a parent's setting when
374
374
  // this app is mounted, and a deleted key would read straight through to it
375
- this.settings["trust proxy fn"] = compileTrust(false);
375
+ this._settings["trust proxy fn"] = compileTrust(false);
376
376
  } else {
377
- this.settings["trust proxy fn"] = compileTrust(value);
377
+ this._settings["trust proxy fn"] = compileTrust(value);
378
378
  }
379
379
  // set explicitly, so a mount no longer inherits the parent's
380
- Object.defineProperty(this.settings, trustProxyDefaultSymbol, {
380
+ Object.defineProperty(this._settings, trustProxyDefaultSymbol, {
381
381
  configurable: true,
382
382
  value: false
383
383
  });
384
384
  } else if (key === "stat cache") {
385
385
  // compiled here so the read path is a number and not a duration to parse per request
386
- this.settings["stat cache ms"] = durationSetting(value, "stat cache");
386
+ this._settings["stat cache ms"] = durationSetting(value, "stat cache");
387
387
  } else if (key === "query parser") {
388
388
  if (value === "extended") {
389
- this.settings["query parser fn"] = fastQueryParse;
389
+ this._settings["query parser fn"] = fastQueryParse;
390
390
  } else if (value === "simple" || value === true) {
391
- this.settings["query parser fn"] = parseQuery;
391
+ this._settings["query parser fn"] = parseQuery;
392
392
  } else if (typeof value === "function") {
393
- this.settings["query parser fn"] = value;
393
+ this._settings["query parser fn"] = value;
394
394
  } else if (value === false) {
395
- this.settings["query parser fn"] = undefined;
395
+ this._settings["query parser fn"] = undefined;
396
396
  } else {
397
397
  // express's wording, which applications match on
398
398
  throw new TypeError("unknown value for query parser function: " + value);
@@ -414,18 +414,18 @@ class Application extends Router {
414
414
  // request. Registering a route or a middleware after listen still takes them back,
415
415
  // see router.js:1615: that is a different question, about code the analysis never saw.
416
416
  if (typeof value === "function") {
417
- this.settings["etag fn"] = value;
417
+ this._settings["etag fn"] = value;
418
418
  } else {
419
419
  switch (value) {
420
420
  case true:
421
421
  case "weak":
422
- this.settings["etag fn"] = createETagGenerator({ weak: true });
422
+ this._settings["etag fn"] = createETagGenerator({ weak: true });
423
423
  break;
424
424
  case "strong":
425
- this.settings["etag fn"] = createETagGenerator({ weak: false });
425
+ this._settings["etag fn"] = createETagGenerator({ weak: false });
426
426
  break;
427
427
  case false:
428
- delete this.settings["etag fn"];
428
+ delete this._settings["etag fn"];
429
429
  break;
430
430
  default:
431
431
  // express's wording, which applications match on
@@ -434,7 +434,7 @@ class Application extends Router {
434
434
  }
435
435
  }
436
436
 
437
- this.settings[key] = value;
437
+ this._settings[key] = value;
438
438
  // any app's hot-settings copy may resolve through this one, see Router#_hot
439
439
  settingsEpoch.n++;
440
440
  return this;
@@ -529,6 +529,9 @@ class Application extends Router {
529
529
  _serveGeneric(res, req) {
530
530
  const request = this.handleRequest(res, req);
531
531
  const response = request.res;
532
+ if (request._badFraming === true) {
533
+ return this._refuseFraming(response);
534
+ }
532
535
  try {
533
536
  this._routeRequestDirect(request, response);
534
537
  } finally {
@@ -538,7 +538,7 @@ function serveStatic(root, options) {
538
538
  filePath,
539
539
  req.headers["accept-encoding"],
540
540
  twinTtl,
541
- req.app.settings["stat cache ms"]
541
+ req.app._settings["stat cache ms"]
542
542
  );
543
543
  if (twin) {
544
544
  stat = twin.stat;
@@ -546,7 +546,7 @@ function serveStatic(root, options) {
546
546
  }
547
547
  try {
548
548
  if (stat === undefined) {
549
- stat = cachedStat(statTarget, req.app.settings["stat cache ms"]);
549
+ stat = cachedStat(statTarget, req.app._settings["stat cache ms"]);
550
550
  }
551
551
  } catch (err) {
552
552
  // the one to report when nothing is found: send hands each failed attempt to the next
@@ -656,7 +656,12 @@ function serveStatic(root, options) {
656
656
  // already found before the stat below, on the ordinary path
657
657
  const variant =
658
658
  twin ??
659
- pickPrecompressed(filePath, req.headers["accept-encoding"], twinTtl, req.app.settings["stat cache ms"]);
659
+ pickPrecompressed(
660
+ filePath,
661
+ req.headers["accept-encoding"],
662
+ twinTtl,
663
+ req.app._settings["stat cache ms"]
664
+ );
660
665
  if (variant) {
661
666
  _path += variant.suffix;
662
667
  stat = variant.stat;
package/src/request.js CHANGED
@@ -152,6 +152,30 @@ const discardedDuplicates = new Set([
152
152
  // 128 KB of body buffered before uWS is asked to pause
153
153
  const READABLE_OPTIONS = { highWaterMark: 128 * 1024 };
154
154
 
155
+ /**
156
+ * Whether a content-length is a plain count of bytes, which is the only thing RFC 9112 allows.
157
+ *
158
+ * uWS trims the spaces around the value and then takes whatever is left, so "", "abc", "+1", "-1",
159
+ * "0x10" and "1e2" all arrive here. Every one of them makes uWS frame the request as carrying no
160
+ * body, and what the client sent as a body is then read as the next request on the connection.
161
+ * Node's parser refuses all of them outright, and so does this.
162
+ *
163
+ * @param {string} value as uWS hands it over
164
+ * @returns {boolean}
165
+ */
166
+ function isByteCount(value) {
167
+ if (value.length === 0) {
168
+ return false;
169
+ }
170
+ for (let i = 0; i < value.length; i++) {
171
+ const code = value.charCodeAt(i);
172
+ if (code < 0x30 || code > 0x39) {
173
+ return false;
174
+ }
175
+ }
176
+ return true;
177
+ }
178
+
155
179
  // Whose headers the shared collector below is filling. uWS's forEach is synchronous and runs no
156
180
  // user code, so the handoff cannot interleave; module-level so the callback exists once instead
157
181
  // of once per request.
@@ -336,6 +360,15 @@ module.exports = class Request extends LazyReadable {
336
360
  (headerKey.length === 14 && headerKey === "content-length") ||
337
361
  (headerKey.length === 17 && headerKey === "transfer-encoding")
338
362
  ) {
363
+ if (headerKey.length === 14) {
364
+ // a second content-length whatever it says, and one that is not a count of bytes:
365
+ // both make uWS frame the request differently from what is on the wire, see
366
+ // _badFraming and isByteCount
367
+ if (r._sawContentLength || !isByteCount(value)) {
368
+ r._badFraming = true;
369
+ }
370
+ r._sawContentLength = true;
371
+ }
339
372
  // saying anything about framing at all, "0" included. A parser that can see a
340
373
  // content-length answers about the body it describes, even an empty one: a zero length
341
374
  // with a charset nobody can decode is a 415 in express and here, so a chain may only
@@ -439,6 +472,33 @@ module.exports = class Request extends LazyReadable {
439
472
  */
440
473
  _hasBodyHeaders;
441
474
 
475
+ /**
476
+ * Whether a content-length has already been copied, so a second one is spotted. Declared for
477
+ * the same reason as rawIp.
478
+ * @type {boolean|undefined}
479
+ */
480
+ _sawContentLength;
481
+
482
+ /**
483
+ * Whether the request said two different things about how long its body is, so uWS may have
484
+ * framed it differently from the client that sent it and the proxy that forwarded it. Two
485
+ * shapes reach this, and node's parser refuses both outright:
486
+ *
487
+ * a repeated content-length uWS frames on the first and drops the rest, so a proxy in
488
+ * front reading the last one instead forwards bytes uWS then
489
+ * answers as a second, pipelined request
490
+ * one that is not a byte count uWS keeps whatever is left after trimming, an empty value
491
+ * included, and frames the request as carrying no body at all,
492
+ * which turns the body the client sent into that same second
493
+ * request. See isByteCount
494
+ *
495
+ * Either way it is request smuggling, and the request is refused rather than routed. Declared
496
+ * for the same reason as rawIp.
497
+ *
498
+ * @type {boolean|undefined}
499
+ */
500
+ _badFraming;
501
+
442
502
  /**
443
503
  * Whether the client asked for the connection to be closed. Declared for the same reason.
444
504
  * @type {boolean|undefined}
@@ -504,10 +564,18 @@ module.exports = class Request extends LazyReadable {
504
564
  // which is 0.2us on a request that is about to read a body.
505
565
  const length = req.getHeader("content-length");
506
566
  const transferEncoding = req.getHeader("transfer-encoding");
567
+ // A content-length of "0" declares no body and used to stay on the cheap side, but
568
+ // getHeader only ever returns the first of a repeated header, so a duplicate cannot be
569
+ // seen from here, and a duplicate has to be refused rather than routed: see
570
+ // _badFraming. Anything that says a word about framing takes the full copy instead.
571
+ //
572
+ // One shape stays invisible here, a content-length present with an empty value: uWS
573
+ // answers "" for that and for a header that was never sent, and nothing in its API
574
+ // tells them apart. It frames both as carrying no body, which is the right reading of
575
+ // the second, so this server stays consistent with itself either way. The full copy
576
+ // below does refuse it, which is every request except a GET whose whole chain provably
577
+ // reads no header at all.
507
578
  if (length !== "" || transferEncoding !== "") {
508
- this._hasBodyHeaders = true;
509
- }
510
- if ((length !== "" && length !== "0") || transferEncoding !== "") {
511
579
  currentRequest = this;
512
580
  this._req.forEach(Request.#collectHeader);
513
581
  currentRequest = null;
package/src/response.js CHANGED
@@ -296,7 +296,7 @@ module.exports = class Response extends LazyWritable {
296
296
  // "connection headers" off advertises neither, which Express always does: see below for
297
297
  // the one this still writes.
298
298
  this.headers =
299
- res._nodeRes || app.settings["connection headers"] === false
299
+ res._nodeRes || app._settings["connection headers"] === false
300
300
  ? {}
301
301
  : {
302
302
  connection: "keep-alive",
@@ -1066,7 +1066,7 @@ module.exports = class Response extends LazyWritable {
1066
1066
  let stat = options._stat;
1067
1067
  if (!stat) {
1068
1068
  try {
1069
- stat = cachedStat(fullpath, this.app.settings["stat cache ms"]);
1069
+ stat = cachedStat(fullpath, this.app._settings["stat cache ms"]);
1070
1070
  } catch (err) {
1071
1071
  // the fs error itself, carrying its errno and path, with send's status written on
1072
1072
  // it: a missing file is the request's 404, an unreadable one is the server's 500
package/src/router.js CHANGED
@@ -178,7 +178,10 @@ class Walk {
178
178
  // inside the route. req.next itself is left alone: making it mean this everywhere is what
179
179
  // express does, and it breaks express own res.format and app.routes.error tests here, so
180
180
  // that stays open rather than half done.
181
- this.leaveRoute = this.stepOutOfRoute.bind(this);
181
+ //
182
+ // Null here and bound on the first route that has more than one callback, which is the
183
+ // only shape that ever reads it: a request that never meets one paid a bind for nothing
184
+ this.leaveRoute = null;
182
185
  }
183
186
 
184
187
  /**
@@ -362,7 +365,7 @@ class Walk {
362
365
  // express hands res.format's handlers the next its own layer received, and its test asserts
363
366
  // that identity. With more than one callback the two differ for real, and what express
364
367
  // hands over is the one that leaves the route
365
- req._leaveRoute = route.callbacks.length > 1 ? this.leaveRoute : this.next;
368
+ req._leaveRoute = route.callbacks.length > 1 ? (this.leaveRoute ??= this.stepOutOfRoute.bind(this)) : this.next;
366
369
  if (continueRoute === "route") {
367
370
  this.step("route");
368
371
  } else if (continueRoute) {
@@ -475,7 +478,7 @@ class Walk {
475
478
  rememberApp(this, route, req);
476
479
  useApp(req, callback);
477
480
  }
478
- const pushedParams = callback.settings.mergeParams;
481
+ const pushedParams = callback._settings.mergeParams;
479
482
  if (pushedParams) {
480
483
  (req._paramStack ??= []).push(req.params);
481
484
  }
@@ -679,7 +682,7 @@ function setMountedPath(req) {
679
682
  */
680
683
  function mergesParams(route, fallback) {
681
684
  const owner = route.owner ?? fallback;
682
- return Boolean(owner?.settings?.mergeParams);
685
+ return Boolean(owner?._settings?.mergeParams);
683
686
  }
684
687
 
685
688
  /**
@@ -819,6 +822,46 @@ function onNativeAborted() {
819
822
  response.socket?.emit("error", err);
820
823
  }
821
824
 
825
+ /**
826
+ * What app.settings is wrapped in, so a write that never went through set() still tells the hot
827
+ * copies they are out of date. Only writes are trapped: a missing trap is the plain operation on
828
+ * the object itself, so reads through here behave exactly as they did.
829
+ *
830
+ * defineProperty is here for the trust proxy default marker, which set() writes that way, and for
831
+ * anything else reaching for Object.defineProperty rather than an assignment.
832
+ */
833
+ const settingsWriteTraps = {
834
+ /**
835
+ * @param {any} target
836
+ * @param {string|symbol} key
837
+ * @param {any} value
838
+ */
839
+ set(target, key, value) {
840
+ target[key] = value;
841
+ settingsEpoch.n++;
842
+ return true;
843
+ },
844
+ /**
845
+ * @param {any} target
846
+ * @param {string|symbol} key
847
+ */
848
+ deleteProperty(target, key) {
849
+ delete target[key];
850
+ settingsEpoch.n++;
851
+ return true;
852
+ },
853
+ /**
854
+ * @param {any} target
855
+ * @param {string|symbol} key
856
+ * @param {any} descriptor
857
+ */
858
+ defineProperty(target, key, descriptor) {
859
+ Object.defineProperty(target, key, descriptor);
860
+ settingsEpoch.n++;
861
+ return true;
862
+ }
863
+ };
864
+
822
865
  /**
823
866
  * The per-request constants of a fully literal native registration. µWS matched the URL byte for
824
867
  * byte against this exact pattern and dispatches by method, so the request constructor can take
@@ -1294,19 +1337,26 @@ module.exports = class Router extends EventEmitter {
1294
1337
  // an array when mounted on several paths at once, as Express allows
1295
1338
  /** @type {string|string[]} */
1296
1339
  this.mountpath = "/";
1297
- this.settings = settings;
1340
+ // The settings twice: the plain object everything inside here reads, and the Proxy the
1341
+ // outside gets. Express lets an application write app.settings["x"] straight, which set()
1342
+ // never sees, so the hot copies in _hot() would keep answering the old value until an
1343
+ // unrelated set() happened to bump the epoch and the change landed by surprise. The trap
1344
+ // bumps it on the spot. Reading through a Proxy costs about 20ns, which is why the inside
1345
+ // never does: _settings is the same object without the wrapper.
1346
+ this._settings = settings;
1347
+ this.settings = new Proxy(settings, settingsWriteTraps);
1298
1348
  // the base classes; an Application replaces these with its own per-app subclasses, and a
1299
1349
  // plain router has no request/response prototype layer, as in Express
1300
1350
  this._request = Request;
1301
1351
  this._response = Response;
1302
1352
 
1303
1353
  if (typeof settings.caseSensitive !== "undefined") {
1304
- this.settings["case sensitive routing"] = settings.caseSensitive;
1305
- delete this.settings.caseSensitive;
1354
+ settings["case sensitive routing"] = settings.caseSensitive;
1355
+ delete settings.caseSensitive;
1306
1356
  }
1307
1357
  if (typeof settings.strict !== "undefined") {
1308
- this.settings["strict routing"] = settings.strict;
1309
- delete this.settings.strict;
1358
+ settings["strict routing"] = settings.strict;
1359
+ delete settings.strict;
1310
1360
  }
1311
1361
  }
1312
1362
 
@@ -1393,7 +1443,8 @@ module.exports = class Router extends EventEmitter {
1393
1443
  get(path, ...callbacks) {
1394
1444
  if (typeof path === "string" && callbacks.length === 0) {
1395
1445
  const key = path;
1396
- const res = this.settings[key];
1446
+ // the raw object, not the Proxy: see the constructor
1447
+ const res = this._settings[key];
1397
1448
  if (typeof res === "undefined" && this.parent) {
1398
1449
  return this.parent.get(key);
1399
1450
  } else {
@@ -1441,7 +1492,7 @@ module.exports = class Router extends EventEmitter {
1441
1492
  * @returns {boolean}
1442
1493
  */
1443
1494
  _routingFlag(key) {
1444
- const own = this.settings[key];
1495
+ const own = this._settings[key];
1445
1496
  if (typeof own !== "undefined") {
1446
1497
  return Boolean(own);
1447
1498
  }
@@ -2014,6 +2065,30 @@ module.exports = class Router extends EventEmitter {
2014
2065
  return request;
2015
2066
  }
2016
2067
 
2068
+ /**
2069
+ * Refuses a request whose framing cannot be trusted and hangs up without answering. No route
2070
+ * runs, so nothing downstream can be reached by one.
2071
+ *
2072
+ * Hanging up is the whole point: uWS has already read what followed the body it believed in as
2073
+ * a second, pipelined request, and it dispatches that one unless the socket goes. Node answers
2074
+ * 400 and then closes, and this cannot do both: uWS only skips the queued request when the
2075
+ * response is closed rather than completed, and any of writeStatus, end or endWithoutBody
2076
+ * completes it. Measured every combination, and delivering the 400 always let the smuggled
2077
+ * request through, so the close wins and the client gets nothing. Nothing legitimate sends two
2078
+ * content-lengths, so there is no well-behaved client to explain it to.
2079
+ *
2080
+ * Called once handleRequest has fully returned, never from inside it: an Application links the
2081
+ * response into its pending list after the base call, and the 'close' emitted here is what
2082
+ * takes it back out again.
2083
+ *
2084
+ * @param {any} response
2085
+ */
2086
+ _refuseFraming(response) {
2087
+ response.finished = true;
2088
+ response._res.close();
2089
+ response.emit("close");
2090
+ }
2091
+
2017
2092
  /**
2018
2093
  * Tells uWS whom to call on a client abort. Out of handleRequest, because uWS only needs it
2019
2094
  * for a response that outlives its handler callback: the native handler arms it in its
@@ -2120,6 +2195,9 @@ module.exports = class Router extends EventEmitter {
2120
2195
  }
2121
2196
  const request = this.handleRequest(res, req, preset, skipHolder);
2122
2197
  const response = request.res;
2198
+ if (request._badFraming === true) {
2199
+ return this._refuseFraming(response);
2200
+ }
2123
2201
  if (optimizedParams) {
2124
2202
  request.optimizedParams = new NullObject();
2125
2203
  for (let i = 0; i < optimizedParams.length; i++) {