@jsenv/server 17.6.6 → 17.6.7

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.
@@ -1150,6 +1150,62 @@ const stringifyHeaderProperty = ({ name, value }) => {
1150
1150
  return `${name}=${value}`;
1151
1151
  };
1152
1152
 
1153
+ /*
1154
+ * A request target is the text between the method and the http version on the
1155
+ * request line (RFC 9112 section 3.2), or the ":path" pseudo header in http2.
1156
+ * It is a path, not a url reference, and the two must never be confused:
1157
+ * "//wp-includes/x" is an origin-form target whose first path segment is
1158
+ * empty, but `new URL("//wp-includes/x", origin)` reads it as a
1159
+ * protocol-relative reference and turns "wp-includes" into the host. So the
1160
+ * path is assigned onto the origin instead, and what a client sends can never
1161
+ * replace that origin.
1162
+ *
1163
+ * The one form that legitimately names an origin is absolute-form
1164
+ * ("GET http://host/path", sent by proxies and open-proxy scanners); it is
1165
+ * read as such so the origin it names goes through the allowedHosts check
1166
+ * like any other host, instead of reaching the routes unchecked.
1167
+ */
1168
+
1169
+ const ABSOLUTE_FORM_REGEX = /^[a-z][a-z0-9+\-.]*:\/\//i;
1170
+
1171
+ /**
1172
+ * @param {string} target the raw request target
1173
+ * @returns {{ origin: string|null, host: string|null, resource: string }|null}
1174
+ * null when the target cannot be read; the server answers 400
1175
+ */
1176
+ const readRequestTarget = (target) => {
1177
+ if (!ABSOLUTE_FORM_REGEX.test(target)) {
1178
+ // origin-form ("/path?query"), asterisk-form ("*") or authority-form
1179
+ return { origin: null, host: null, resource: target };
1180
+ }
1181
+ if (!URL.canParse(target)) {
1182
+ return null;
1183
+ }
1184
+ const urlObject = new URL(target);
1185
+ if (urlObject.protocol !== "http:" && urlObject.protocol !== "https:") {
1186
+ return null;
1187
+ }
1188
+ return {
1189
+ origin: urlObject.origin,
1190
+ host: urlObject.host,
1191
+ resource: `${urlObject.pathname}${urlObject.search}`,
1192
+ };
1193
+ };
1194
+
1195
+ // the search of baseUrl is dropped: the resource carries its own
1196
+ const resourceToUrlObject = (resource, baseUrl) => {
1197
+ const urlObject = new URL(baseUrl);
1198
+ const searchSeparatorIndex = resource.indexOf("?");
1199
+ if (searchSeparatorIndex === -1) {
1200
+ urlObject.pathname = resource;
1201
+ urlObject.search = "";
1202
+ } else {
1203
+ urlObject.pathname = resource.slice(0, searchSeparatorIndex);
1204
+ urlObject.search = resource.slice(searchSeparatorIndex);
1205
+ }
1206
+ return urlObject;
1207
+ };
1208
+
1153
1209
  // https://wicg.github.io/observable/#core-infrastructure
1154
1210
 
1155
1211
  if ("observable" in Symbol === false) {
@@ -1329,8 +1385,13 @@ const fromNodeRequest = (
1329
1385
  nodeRequest.pause();
1330
1386
  const body = observableFromNodeStream(nodeRequest);
1331
1387
 
1388
+ // an absolute-form target names the origin the request is for; the host
1389
+ // header is ignored in that case (RFC 9112 section 3.3)
1390
+ const requestTarget = readRequestTarget(nodeRequest.url);
1332
1391
  let requestOrigin;
1333
- if (nodeRequest.upgrade) {
1392
+ if (requestTarget.origin) {
1393
+ requestOrigin = requestTarget.origin;
1394
+ } else if (nodeRequest.upgrade) {
1334
1395
  requestOrigin = serverOrigin;
1335
1396
  } else if (nodeRequest.authority) {
1336
1397
  requestOrigin = nodeRequest.connection.encrypted
@@ -1463,7 +1524,7 @@ const fromNodeRequest = (
1463
1524
  http2: Boolean(nodeRequest.stream),
1464
1525
  origin: requestOrigin,
1465
1526
  ...getPropertiesFromResource({
1466
- resource: nodeRequest.url,
1527
+ resource: requestTarget.resource,
1467
1528
  baseUrl: requestOrigin,
1468
1529
  }),
1469
1530
  method: nodeRequest.method,
@@ -1690,13 +1751,12 @@ const applyRedirectionToRequest = (
1690
1751
  };
1691
1752
  };
1692
1753
  const getPropertiesFromResource = ({ resource, baseUrl }) => {
1693
- const urlObject = new URL(resource, baseUrl);
1694
- let pathname = urlObject.pathname;
1754
+ const urlObject = resourceToUrlObject(resource, baseUrl);
1695
1755
 
1696
1756
  return {
1697
1757
  url: String(urlObject),
1698
1758
  searchParams: urlObject.searchParams,
1699
- pathname,
1759
+ pathname: urlObject.pathname,
1700
1760
  resource,
1701
1761
  };
1702
1762
  };
@@ -5833,8 +5893,17 @@ const createResourcePattern = (pattern) => {
5833
5893
  }
5834
5894
 
5835
5895
  const patternEndsWithSlash = pathnamePatternString.endsWith("/");
5896
+ // a pattern constraining nothing: every resource matches it, so whoever uses
5897
+ // it cannot name the resource that matched
5898
+ const matchesEveryResource =
5899
+ !searchPattern &&
5900
+ !hashPattern &&
5901
+ (pathnamePatternString === "*" ||
5902
+ pathnamePatternString === "/" ||
5903
+ pathnamePatternString === "/*");
5836
5904
 
5837
5905
  return {
5906
+ matchesEveryResource,
5838
5907
  match: (resource) => {
5839
5908
  const [pathname, search, hash] = resourceToParts(resource);
5840
5909
  let decodedPathname = decodeURIComponent(pathname);
@@ -6477,9 +6546,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
6477
6546
  }
6478
6547
  if (!route.matchMethod(request.method)) {
6479
6548
  // a 405 asserts the resource exists with other methods: a route
6480
- // matching any resource ("GET *") cannot assert that, so it does
6481
- // not turn an unknown resource into a 405
6482
- if (!route.isFallback && route.resource !== "*") {
6549
+ // matching every resource ("GET *", "GET /") names no resource, so
6550
+ // it cannot make that assertion and an unknown address stays a 404
6551
+ if (!route.isFallback && !route.matchesEveryResource) {
6483
6552
  wouldHaveMatched.methodSet.add(route.method);
6484
6553
  }
6485
6554
  continue;
@@ -6827,6 +6896,8 @@ const createRoute = ({
6827
6896
  : (requestResource) => {
6828
6897
  return resourcePattern.match(requestResource);
6829
6898
  },
6899
+ matchesEveryResource:
6900
+ resource === "*" || resourcePattern.matchesEveryResource,
6830
6901
  matchHeaders:
6831
6902
  headers === undefined
6832
6903
  ? () => true
@@ -7972,7 +8043,14 @@ const startServer = async ({
7972
8043
 
7973
8044
  {
7974
8045
  const requestEventHandler = async (nodeRequest, nodeResponse) => {
7975
- const requestHost = nodeRequest.authority || nodeRequest.headers.host;
8046
+ const requestTarget = readRequestTarget(nodeRequest.url);
8047
+ if (!requestTarget) {
8048
+ nodeResponse.writeHead(400, "Request url is not supported");
8049
+ nodeResponse.end();
8050
+ return;
8051
+ }
8052
+ const requestHost =
8053
+ requestTarget.host || nodeRequest.authority || nodeRequest.headers.host;
7976
8054
  if (!isHostAllowed(requestHost)) {
7977
8055
  logger.warn(
7978
8056
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
@@ -7983,19 +8061,11 @@ const startServer = async ({
7983
8061
  }
7984
8062
  if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
7985
8063
  nodeResponse.writeHead(301, {
7986
- location: `${serverOrigin}${nodeRequest.url}`,
8064
+ location: `${serverOrigin}${requestTarget.resource}`,
7987
8065
  });
7988
8066
  nodeResponse.end();
7989
8067
  return;
7990
8068
  }
7991
- try {
7992
- // eslint-disable-next-line no-new
7993
- new URL(nodeRequest.url, "http://example.com");
7994
- } catch {
7995
- nodeResponse.writeHead(400, "Request url is not supported");
7996
- nodeResponse.end();
7997
- return;
7998
- }
7999
8069
 
8000
8070
  const [receiveRequestOperation, sendResponseOperation] =
8001
8071
  prepareHandleRequestOperations(nodeRequest, nodeResponse);
@@ -8072,7 +8142,13 @@ const startServer = async ({
8072
8142
  };
8073
8143
  // https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
8074
8144
  const upgradeEventHandler = async (nodeRequest, socket, head) => {
8075
- const requestHost = nodeRequest.headers.host;
8145
+ const requestTarget = readRequestTarget(nodeRequest.url);
8146
+ if (!requestTarget) {
8147
+ socket.write(`HTTP/1.1 400 Request url is not supported\r\n\r\n`);
8148
+ socket.destroy();
8149
+ return;
8150
+ }
8151
+ const requestHost = requestTarget.host || nodeRequest.headers.host;
8076
8152
  if (!isHostAllowed(requestHost)) {
8077
8153
  logger.warn(
8078
8154
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
@@ -8987,8 +9063,11 @@ const fetchFileSystem = async (
8987
9063
  if (request.params && "0" in request.params) {
8988
9064
  resource = request.params["0"];
8989
9065
  } else {
8990
- resource = request.resource.slice(1);
9066
+ resource = request.resource;
8991
9067
  }
9068
+ // the resource is resolved against the served directory, so it must stay a
9069
+ // relative reference: a leading slash would resolve to the filesystem root
9070
+ resource = resource.replace(/^\/+/, "");
8992
9071
  const filesystemUrl = new URL(resource, directoryUrlString);
8993
9072
  const urlString = asUrlString(filesystemUrl);
8994
9073
  if (!urlIsOrIsInsideOf(urlString, directoryUrlString)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "17.6.6",
3
+ "version": "17.6.7",
4
4
  "type": "module",
5
5
  "description": "A modern Node.js HTTP server with declarative routing, content negotiation, and WebSocket support",
6
6
  "repository": {
@@ -20,6 +20,10 @@ import {
20
20
  } from "../internal/colorize_response_status.js";
21
21
  import { headersFromObject } from "../internal/headers_from_object.js";
22
22
  import { parseSingleHeaderWithAttributes } from "../internal/multiple_header.js";
23
+ import {
24
+ readRequestTarget,
25
+ resourceToUrlObject,
26
+ } from "../internal/request_target.js";
23
27
  import { observableFromNodeStream } from "./observable_from_node_stream.js";
24
28
 
25
29
  export const fromNodeRequest = (
@@ -66,8 +70,13 @@ export const fromNodeRequest = (
66
70
  nodeRequest.pause();
67
71
  const body = observableFromNodeStream(nodeRequest);
68
72
 
73
+ // an absolute-form target names the origin the request is for; the host
74
+ // header is ignored in that case (RFC 9112 section 3.3)
75
+ const requestTarget = readRequestTarget(nodeRequest.url);
69
76
  let requestOrigin;
70
- if (nodeRequest.upgrade) {
77
+ if (requestTarget.origin) {
78
+ requestOrigin = requestTarget.origin;
79
+ } else if (nodeRequest.upgrade) {
71
80
  requestOrigin = serverOrigin;
72
81
  } else if (nodeRequest.authority) {
73
82
  requestOrigin = nodeRequest.connection.encrypted
@@ -200,7 +209,7 @@ export const fromNodeRequest = (
200
209
  http2: Boolean(nodeRequest.stream),
201
210
  origin: requestOrigin,
202
211
  ...getPropertiesFromResource({
203
- resource: nodeRequest.url,
212
+ resource: requestTarget.resource,
204
213
  baseUrl: requestOrigin,
205
214
  }),
206
215
  method: nodeRequest.method,
@@ -440,13 +449,12 @@ export const applyRedirectionToRequest = (
440
449
  };
441
450
  };
442
451
  const getPropertiesFromResource = ({ resource, baseUrl }) => {
443
- const urlObject = new URL(resource, baseUrl);
444
- let pathname = urlObject.pathname;
452
+ const urlObject = resourceToUrlObject(resource, baseUrl);
445
453
 
446
454
  return {
447
455
  url: String(urlObject),
448
456
  searchParams: urlObject.searchParams,
449
- pathname,
457
+ pathname: urlObject.pathname,
450
458
  resource,
451
459
  };
452
460
  };
@@ -0,0 +1,55 @@
1
+ /*
2
+ * A request target is the text between the method and the http version on the
3
+ * request line (RFC 9112 section 3.2), or the ":path" pseudo header in http2.
4
+ * It is a path, not a url reference, and the two must never be confused:
5
+ * "//wp-includes/x" is an origin-form target whose first path segment is
6
+ * empty, but `new URL("//wp-includes/x", origin)` reads it as a
7
+ * protocol-relative reference and turns "wp-includes" into the host. So the
8
+ * path is assigned onto the origin instead, and what a client sends can never
9
+ * replace that origin.
10
+ *
11
+ * The one form that legitimately names an origin is absolute-form
12
+ * ("GET http://host/path", sent by proxies and open-proxy scanners); it is
13
+ * read as such so the origin it names goes through the allowedHosts check
14
+ * like any other host, instead of reaching the routes unchecked.
15
+ */
16
+
17
+ const ABSOLUTE_FORM_REGEX = /^[a-z][a-z0-9+\-.]*:\/\//i;
18
+
19
+ /**
20
+ * @param {string} target the raw request target
21
+ * @returns {{ origin: string|null, host: string|null, resource: string }|null}
22
+ * null when the target cannot be read; the server answers 400
23
+ */
24
+ export const readRequestTarget = (target) => {
25
+ if (!ABSOLUTE_FORM_REGEX.test(target)) {
26
+ // origin-form ("/path?query"), asterisk-form ("*") or authority-form
27
+ return { origin: null, host: null, resource: target };
28
+ }
29
+ if (!URL.canParse(target)) {
30
+ return null;
31
+ }
32
+ const urlObject = new URL(target);
33
+ if (urlObject.protocol !== "http:" && urlObject.protocol !== "https:") {
34
+ return null;
35
+ }
36
+ return {
37
+ origin: urlObject.origin,
38
+ host: urlObject.host,
39
+ resource: `${urlObject.pathname}${urlObject.search}`,
40
+ };
41
+ };
42
+
43
+ // the search of baseUrl is dropped: the resource carries its own
44
+ export const resourceToUrlObject = (resource, baseUrl) => {
45
+ const urlObject = new URL(baseUrl);
46
+ const searchSeparatorIndex = resource.indexOf("?");
47
+ if (searchSeparatorIndex === -1) {
48
+ urlObject.pathname = resource;
49
+ urlObject.search = "";
50
+ } else {
51
+ urlObject.pathname = resource.slice(0, searchSeparatorIndex);
52
+ urlObject.search = resource.slice(searchSeparatorIndex);
53
+ }
54
+ return urlObject;
55
+ };
@@ -112,8 +112,11 @@ export const fetchFileSystem = async (
112
112
  if (request.params && "0" in request.params) {
113
113
  resource = request.params["0"];
114
114
  } else {
115
- resource = request.resource.slice(1);
115
+ resource = request.resource;
116
116
  }
117
+ // the resource is resolved against the served directory, so it must stay a
118
+ // relative reference: a leading slash would resolve to the filesystem root
119
+ resource = resource.replace(/^\/+/, "");
117
120
  const filesystemUrl = new URL(resource, directoryUrlString);
118
121
  const urlString = asUrlString(filesystemUrl);
119
122
  if (!urlIsOrIsInsideOf(urlString, directoryUrlString)) {
@@ -356,9 +356,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
356
356
  }
357
357
  if (!route.matchMethod(request.method)) {
358
358
  // a 405 asserts the resource exists with other methods: a route
359
- // matching any resource ("GET *") cannot assert that, so it does
360
- // not turn an unknown resource into a 405
361
- if (!route.isFallback && route.resource !== "*") {
359
+ // matching every resource ("GET *", "GET /") names no resource, so
360
+ // it cannot make that assertion and an unknown address stays a 404
361
+ if (!route.isFallback && !route.matchesEveryResource) {
362
362
  wouldHaveMatched.methodSet.add(route.method);
363
363
  }
364
364
  continue;
@@ -709,6 +709,8 @@ const createRoute = ({
709
709
  : (requestResource) => {
710
710
  return resourcePattern.match(requestResource);
711
711
  },
712
+ matchesEveryResource:
713
+ resource === "*" || resourcePattern.matchesEveryResource,
712
714
  matchHeaders:
713
715
  headers === undefined
714
716
  ? () => true
@@ -18,6 +18,7 @@ import { listen, stopListening } from "./internal/listen.js";
18
18
  import { listenEvent } from "./internal/listen_event.js";
19
19
  import { listenRequest } from "./internal/listen_request.js";
20
20
  import { listenServerConnectionError } from "./internal/listen_server_connection_error.js";
21
+ import { readRequestTarget } from "./internal/request_target.js";
21
22
  import { composeTwoResponses } from "./internal/response_composition.js";
22
23
  import { createSecureServer } from "./internal/secure_server.js";
23
24
  import { resolveServerOrigins } from "./internal/server_origins.js";
@@ -792,7 +793,14 @@ export const startServer = async ({
792
793
 
793
794
  request: {
794
795
  const requestEventHandler = async (nodeRequest, nodeResponse) => {
795
- const requestHost = nodeRequest.authority || nodeRequest.headers.host;
796
+ const requestTarget = readRequestTarget(nodeRequest.url);
797
+ if (!requestTarget) {
798
+ nodeResponse.writeHead(400, "Request url is not supported");
799
+ nodeResponse.end();
800
+ return;
801
+ }
802
+ const requestHost =
803
+ requestTarget.host || nodeRequest.authority || nodeRequest.headers.host;
796
804
  if (!isHostAllowed(requestHost)) {
797
805
  logger.warn(
798
806
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
@@ -803,19 +811,11 @@ export const startServer = async ({
803
811
  }
804
812
  if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
805
813
  nodeResponse.writeHead(301, {
806
- location: `${serverOrigin}${nodeRequest.url}`,
814
+ location: `${serverOrigin}${requestTarget.resource}`,
807
815
  });
808
816
  nodeResponse.end();
809
817
  return;
810
818
  }
811
- try {
812
- // eslint-disable-next-line no-new
813
- new URL(nodeRequest.url, "http://example.com");
814
- } catch {
815
- nodeResponse.writeHead(400, "Request url is not supported");
816
- nodeResponse.end();
817
- return;
818
- }
819
819
 
820
820
  const [receiveRequestOperation, sendResponseOperation] =
821
821
  prepareHandleRequestOperations(nodeRequest, nodeResponse);
@@ -897,7 +897,13 @@ export const startServer = async ({
897
897
  };
898
898
  // https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
899
899
  const upgradeEventHandler = async (nodeRequest, socket, head) => {
900
- const requestHost = nodeRequest.headers.host;
900
+ const requestTarget = readRequestTarget(nodeRequest.url);
901
+ if (!requestTarget) {
902
+ socket.write(`HTTP/1.1 400 Request url is not supported\r\n\r\n`);
903
+ socket.destroy();
904
+ return;
905
+ }
906
+ const requestHost = requestTarget.host || nodeRequest.headers.host;
901
907
  if (!isHostAllowed(requestHost)) {
902
908
  logger.warn(
903
909
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,