fulmine.js 5.11.1 → 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/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;
@@ -592,6 +660,10 @@ module.exports = class Request extends LazyReadable {
592
660
  this._isOptions = this.method === "OPTIONS";
593
661
  this._isHead = this.method === "HEAD";
594
662
  }
663
+ // the folded _opPath and the percent scan of _originalPath, built on the hop that first
664
+ // wants them and dropped by every rewrite, see _pathMatches and Walk#dispatch
665
+ this._opPathLower = null;
666
+ this._mayFailDecode = null;
595
667
  this.params = {};
596
668
 
597
669
  // Two Sets per request, for two things almost no request needs.
@@ -812,7 +884,7 @@ module.exports = class Request extends LazyReadable {
812
884
  * meant to carry one value, but nothing stops a proxy from appending.
813
885
  */
814
886
  get #authority() {
815
- const trust = this.app.get("trust proxy fn");
887
+ const trust = this.app._hot().trustProxyFn;
816
888
  // parsedIp is what connection.remoteAddress carries, without building the socket stand-in
817
889
  const isTrusted = !!(trust && trust(this.parsedIp, 0));
818
890
  const rawHeader = (isTrusted && this.headers["x-forwarded-host"]) || this.headers["host"];
@@ -886,7 +958,7 @@ module.exports = class Request extends LazyReadable {
886
958
  * @returns {string|undefined} undefined on a unix socket, which has no address
887
959
  */
888
960
  get ip() {
889
- const trust = this.app.get("trust proxy fn");
961
+ const trust = this.app._hot().trustProxyFn;
890
962
  if (!trust) {
891
963
  return this.parsedIp;
892
964
  }
@@ -899,7 +971,7 @@ module.exports = class Request extends LazyReadable {
899
971
  * @returns {string[]}
900
972
  */
901
973
  get ips() {
902
- const trust = this.app.get("trust proxy fn");
974
+ const trust = this.app._hot().trustProxyFn;
903
975
  if (!trust) {
904
976
  return [];
905
977
  }
@@ -918,7 +990,7 @@ module.exports = class Request extends LazyReadable {
918
990
  // own ssl flag answers when nothing has built the stand-in yet
919
991
  const conn = this.#cachedConnection;
920
992
  const proto = (conn ? conn.encrypted : this.app.ssl) ? "https" : "http";
921
- const trust = this.app.get("trust proxy fn");
993
+ const trust = this.app._hot().trustProxyFn;
922
994
  if (!trust) {
923
995
  return proto;
924
996
  }
@@ -956,6 +1028,8 @@ module.exports = class Request extends LazyReadable {
956
1028
  this.path = newPath;
957
1029
  this.endsWithSlash = newPath.charCodeAt(newPath.length - 1) === 0x2f;
958
1030
  this._opPath = newPath;
1031
+ this._opPathLower = null;
1032
+ this._mayFailDecode = null;
959
1033
  this._lastUrl = newUrl;
960
1034
  }
961
1035
 
@@ -985,7 +1059,7 @@ module.exports = class Request extends LazyReadable {
985
1059
  * @returns {Record<string, any>}
986
1060
  */
987
1061
  get query() {
988
- const qp = this.app.get("query parser fn");
1062
+ const qp = this.app._hot().queryParserFn;
989
1063
  // the vendored default already answers on a bare null prototype, so it goes out as is;
990
1064
  // any other parser is copied onto one, which is what kept fast-querystring's result from
991
1065
  // inspecting as "Empty <[Object: null prototype] {}>" where Express shows the bare form
@@ -1053,7 +1127,7 @@ module.exports = class Request extends LazyReadable {
1053
1127
  */
1054
1128
  _readRawIp() {
1055
1129
  const uwsRes = this._res;
1056
- if (this.app.get("trust proxy protocol")) {
1130
+ if (this.app._hot().trustProxyProtocol) {
1057
1131
  const proxied = uwsRes.getProxiedRemoteAddress();
1058
1132
  // empty unless a preamble arrived, which is the only thing that tells the two apart
1059
1133
  if (proxied.byteLength !== 0) {
package/src/response.js CHANGED
@@ -31,6 +31,9 @@ const {
31
31
  isPreconditionFailure,
32
32
  isRangeFresh,
33
33
  escapeHtml,
34
+ validateHeaderName,
35
+ validateHeaderValue,
36
+ headerIsWritable,
34
37
  withDefaultCharset,
35
38
  withUtf8Charset,
36
39
  asStatError,
@@ -293,7 +296,7 @@ module.exports = class Response extends LazyWritable {
293
296
  // "connection headers" off advertises neither, which Express always does: see below for
294
297
  // the one this still writes.
295
298
  this.headers =
296
- res._nodeRes || app.settings["connection headers"] === false
299
+ res._nodeRes || app._settings["connection headers"] === false
297
300
  ? {}
298
301
  : {
299
302
  connection: "keep-alive",
@@ -305,7 +308,7 @@ module.exports = class Response extends LazyWritable {
305
308
  if (req._connectionClose) {
306
309
  this.headers.connection = "close";
307
310
  }
308
- if (this.app.get("x-powered-by")) {
311
+ if (app._hot().xPoweredBy) {
309
312
  this.headers["x-powered-by"] = "Fulmine";
310
313
  }
311
314
 
@@ -613,6 +616,10 @@ module.exports = class Response extends LazyWritable {
613
616
  * not one of them: uWS wants the length through tryEnd or endWithoutBody, so it is taken out
614
617
  * here and kept on totalSize, where it also turns chunked framing off.
615
618
  *
619
+ * One writeHeader per header on purpose. Packing the whole head into a single writeStatus
620
+ * works on the wire, and was measured slower: constant header strings cross the boundary
621
+ * already flat, while the packed head is concatenated fresh per response, see issue #11.
622
+ *
616
623
  * @param {boolean} utf8 unused, kept because node's equivalent takes it and the two callers
617
624
  * differ on what they know about the body
618
625
  */
@@ -881,8 +888,18 @@ module.exports = class Response extends LazyWritable {
881
888
  // req.fresh, which compares If-None-Match against it.
882
889
  // body is defined by the time it gets here, so an empty one still earns an ETag. Testing
883
890
  // its truthiness instead meant send("") and send(null) came back without one.
884
- const etagFn = this.app.get("etag fn");
885
- if (etagFn && !this.headers["etag"] && !this.req.noEtag) {
891
+ // Every method by default, not only GET and HEAD: gating it looked safe, freshness being
892
+ // defined over those two alone, and express's own suite failed on it, "should send ETag
893
+ // in response to <METHOD> request" exists per method. The "etag methods" setting is that
894
+ // gate as an opt-in, see issue #10.
895
+ const hot = this.app._hot();
896
+ const etagFn = hot.etagFn;
897
+ if (
898
+ etagFn &&
899
+ !this.headers["etag"] &&
900
+ !this.req.noEtag &&
901
+ (hot.etagMethods === null || hot.etagMethods.has(this.req.method))
902
+ ) {
886
903
  const etag = etagFn(body);
887
904
  // an application's own etag function is allowed to decline: returning nothing means no
888
905
  // header, rather than a header saying "undefined"
@@ -1049,7 +1066,7 @@ module.exports = class Response extends LazyWritable {
1049
1066
  let stat = options._stat;
1050
1067
  if (!stat) {
1051
1068
  try {
1052
- stat = cachedStat(fullpath, this.app.settings["stat cache ms"]);
1069
+ stat = cachedStat(fullpath, this.app._settings["stat cache ms"]);
1053
1070
  } catch (err) {
1054
1071
  // the fs error itself, carrying its errno and path, with send's status written on
1055
1072
  // it: a missing file is the request's 404, an unreadable one is the server's 500
@@ -1318,27 +1335,51 @@ module.exports = class Response extends LazyWritable {
1318
1335
  * @param {any} value an array sends the header once per entry
1319
1336
  * @returns {this}
1320
1337
  * @throws {Error} once the headers have gone out
1321
- * @throws {TypeError} if the name is not a string
1338
+ * @throws {TypeError} if the name is not a token, the value is undefined, or the value holds a
1339
+ * character that cannot go on the wire
1322
1340
  */
1323
1341
  setHeader(field, value) {
1324
1342
  if (this.headersSent) {
1325
1343
  throw new Error("Cannot set headers after they are sent to the client");
1326
1344
  }
1327
- if (typeof field !== "string") {
1328
- throw new TypeError("Header name must be a valid HTTP token");
1329
- } else {
1330
- field = field.toLowerCase();
1331
- if (Array.isArray(value)) {
1332
- // each entry as text, as node serialises them: a raw number reaching uWS's
1333
- // writeHeader would throw mid-response
1334
- this.headers[field] = value.map(String);
1335
- return this;
1336
- }
1337
- this.headers[field] = String(value);
1345
+ validateHeaderName(field);
1346
+ if (value === undefined) {
1347
+ /** @type {NodeJS.ErrnoException} */
1348
+ const err = new TypeError(`Invalid value "undefined" for header "${field}"`);
1349
+ err.code = "ERR_HTTP_INVALID_HEADER_VALUE";
1350
+ throw err;
1338
1351
  }
1352
+ // each entry as text, as node serialises them: a raw number reaching uWS's writeHeader
1353
+ // would throw mid-response. Coerced before it is checked, since that is the string the
1354
+ // wire gets, and checked before it is stored: a value that got in here would be written by
1355
+ // whatever flushes next, and on the error path that is a second throw with nobody left to
1356
+ // catch it
1357
+ const out = Array.isArray(value) ? value.map(String) : String(value);
1358
+ validateHeaderValue(field, out);
1359
+ this.headers[field.toLowerCase()] = out;
1339
1360
  return this;
1340
1361
  }
1341
1362
 
1363
+ /**
1364
+ * Throws away any header that could not be written, so that flushing this response cannot fail
1365
+ * on one. setHeader refuses these on the way in, but `res.headers` is the live object, so an
1366
+ * assignment into that still gets a value in here.
1367
+ *
1368
+ * Only the error page calls it. A throw out of the flush there is not recoverable: the error
1369
+ * page is what runs after a throw, so it would be the second one, with nobody left to catch
1370
+ * it, and on the node shim that is the process. Everything writable is left alone, since a
1371
+ * middleware's own headers belong on the error response too.
1372
+ *
1373
+ * @returns {void}
1374
+ */
1375
+ _dropUnwritableHeaders() {
1376
+ for (const header in this.headers) {
1377
+ if (!headerIsWritable(header, this.headers[header])) {
1378
+ delete this.headers[header];
1379
+ }
1380
+ }
1381
+ }
1382
+
1342
1383
  /**
1343
1384
  * Hands the status line and the headers over now, without waiting for a body, which is node's
1344
1385
  * flushHeaders(). Callers use it to let the client start on the head while the body is still
@@ -1387,10 +1428,6 @@ module.exports = class Response extends LazyWritable {
1387
1428
  * @param {() => void} [callback]
1388
1429
  * @returns {void}
1389
1430
  */
1390
-
1391
- /**
1392
- *
1393
- */
1394
1431
  writeEarlyHints(hints, callback) {
1395
1432
  this.#refuseInformationAfterHead();
1396
1433
  // node writes the hints first and calls back after, so a caller that sequences work on it
@@ -1460,7 +1497,7 @@ module.exports = class Response extends LazyWritable {
1460
1497
  }
1461
1498
 
1462
1499
  /**
1463
- * node's `assignSocket` and `detachSocket`, which the http server uses when a response is
1500
+ * node's `assignSocket`, which the http server uses when a response is
1464
1501
  * handed a raw socket. There is no such socket here.
1465
1502
  *
1466
1503
  * @param {any} [socket]
@@ -1469,6 +1506,8 @@ module.exports = class Response extends LazyWritable {
1469
1506
  assignSocket(socket) {}
1470
1507
 
1471
1508
  /**
1509
+ * node's `detachSocket`, which the http server uses when a response is
1510
+ * handed a raw socket. There is no such socket here.
1472
1511
  * @param {any} [socket]
1473
1512
  * @returns {void}
1474
1513
  */
@@ -1595,11 +1634,11 @@ module.exports = class Response extends LazyWritable {
1595
1634
  this.set(header, field[header]);
1596
1635
  }
1597
1636
  } else {
1598
- field = field.toLowerCase();
1637
+ const name = field.toLowerCase();
1599
1638
  // a header is text on the wire whatever it was here, and Express coerces at this point,
1600
1639
  // so res.get answers what was sent rather than the number or object it was given
1601
1640
  let out = Array.isArray(value) ? value.map(String) : String(value);
1602
- if (field === "content-type") {
1641
+ if (name === "content-type") {
1603
1642
  if (Array.isArray(out)) {
1604
1643
  throw new TypeError("Content-Type cannot be set to an Array");
1605
1644
  }
@@ -1607,6 +1646,9 @@ module.exports = class Response extends LazyWritable {
1607
1646
  // missing application/manifest+json among others, which Express does charset.
1608
1647
  out = withDefaultCharset(out);
1609
1648
  }
1649
+ // the name as it was written, not the lowercased one: setHeader lowercases it itself,
1650
+ // and it is the name that a refused header is reported by, which Express takes from
1651
+ // what the caller passed
1610
1652
  this.setHeader(field, out);
1611
1653
  }
1612
1654
  return this;
@@ -1643,12 +1685,15 @@ module.exports = class Response extends LazyWritable {
1643
1685
  }
1644
1686
 
1645
1687
  /**
1646
- * Every header set so far, as the object they are kept in rather than a copy, so writing to
1647
- * it writes to the response.
1688
+ * Every header set so far, as a shallow copy on a null prototype, which is what node's
1689
+ * OutgoingMessage answers. It used to hand out the live object, and a write into that
1690
+ * reached the wire without setHeader's validation, see issue #6; nothing in here or in the
1691
+ * middleware that was checked relies on the live one, so the copy costs an allocation on a
1692
+ * method the framework itself never calls.
1648
1693
  * @returns {Record<string, any>}
1649
1694
  */
1650
1695
  getHeaders() {
1651
- return this.headers;
1696
+ return Object.assign({ __proto__: null }, this.headers);
1652
1697
  }
1653
1698
 
1654
1699
  /**
@@ -1837,10 +1882,8 @@ module.exports = class Response extends LazyWritable {
1837
1882
  if (!this.headers["content-type"]) {
1838
1883
  this.headers["content-type"] = "application/json; charset=utf-8";
1839
1884
  }
1840
- const escape = this.app.get("json escape");
1841
- const replacer = this.app.get("json replacer");
1842
- const spaces = this.app.get("json spaces");
1843
- return this.send(stringify(body, replacer, spaces, escape));
1885
+ const hot = this.app._hot();
1886
+ return this.send(stringify(body, hot.jsonReplacer, hot.jsonSpaces, hot.jsonEscape));
1844
1887
  }
1845
1888
 
1846
1889
  /**
@@ -2002,7 +2045,8 @@ module.exports = class Response extends LazyWritable {
2002
2045
  type(type) {
2003
2046
  const ct = type.indexOf("/") === -1 ? contentTypeFor(type) : type;
2004
2047
 
2005
- return this.set("content-type", ct);
2048
+ // the name Express passes, since a refused value is reported by the name it was set under
2049
+ return this.set("Content-Type", ct);
2006
2050
  }
2007
2051
 
2008
2052
  /**