From 366d575b055b01e0b91853f523ea8a29fdbabe89 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 17 Oct 2026 13:45:03 -0500 Subject: [PATCH 01/13] fix: separate origin authorization from allowed hosts --- .../independent-origin-authorization.md | 7 + README.md | 7 + examples/allowed-origins/README.md | 82 ++++ examples/allowed-origins/app.js | 1 + examples/allowed-origins/webpack.config.js | 15 + lib/Server.js | 221 +++++----- lib/options.check.js | 2 +- lib/options.json | 28 ++ .../normalize-options.test.js.snap.webpack5 | 385 ++++++++++++++++++ .../validate-options.test.js.snap.webpack5 | 60 +++ .../__snapshots__/basic.test.js.snap.webpack5 | 2 + test/cli/allowedOrigins-option.test.js | 26 ++ .../allowed-hosts.test.js.snap.webpack5 | 128 +++--- test/e2e/allowed-hosts.test.js | 64 +-- test/e2e/allowed-origins.test.js | 245 +++++++++++ test/e2e/cross-origin-request.test.js | 35 +- test/e2e/event-source.test.js | 41 +- test/e2e/options-middleware.test.js | 2 +- test/e2e/proxy-web-socket-origin.test.js | 37 ++ test/e2e/web-socket-server-url.test.js | 5 + test/normalize-options.test.js | 18 + test/ports-map.js | 2 + test/schema-check.test.js | 15 + test/server/allowed-origins.test.js | 136 +++++++ test/server/proxy-option.test.js | 12 +- test/validate-options.test.js | 22 + types/lib/Server.d.ts | 53 ++- 27 files changed, 1417 insertions(+), 234 deletions(-) create mode 100644 .changeset/independent-origin-authorization.md create mode 100644 examples/allowed-origins/README.md create mode 100644 examples/allowed-origins/app.js create mode 100644 examples/allowed-origins/webpack.config.js create mode 100644 test/cli/allowedOrigins-option.test.js create mode 100644 test/e2e/allowed-origins.test.js create mode 100644 test/server/allowed-origins.test.js diff --git a/.changeset/independent-origin-authorization.md b/.changeset/independent-origin-authorization.md new file mode 100644 index 0000000000..e7a38ad04a --- /dev/null +++ b/.changeset/independent-origin-authorization.md @@ -0,0 +1,7 @@ +--- +"webpack-dev-server": minor +--- + +Add `allowedOrigins` to authorize cross-origin HTTP requests, HMR connections, and WebSocket proxies independently of `allowedHosts` and `client.webSocketURL`. Host configuration no longer disables origin protection, including when `allowedHosts` is `"all"`. + +Cross-origin frontends must now list their full HTTP(S) origin in `allowedOrigins`. Origins include the protocol and port; loopback hostnames are no longer interchangeable. Use CORS (`crossorigin="anonymous"` on script and image elements) so the server can identify the authorized page. The server supplies CORS headers for authorized origins, while Fetch Metadata and resource-policy checks continue to protect requests without `Origin`. Configuring `headers` alone no longer grants cross-origin access. `allowedOrigins: "all"` explicitly opts out of origin checks without disabling host validation. diff --git a/README.md b/README.md index 76f490fd9d..a1698901ed 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,11 @@ over a global one._ There are two main, recommended methods of using the module: +For a frontend served from a different origin, configure `devServer.allowedOrigins`. +See the [allowed origins example](examples/allowed-origins/README.md) for configuration +and migration details. `allowedHosts` and `client.webSocketURL` do not authorize +cross-origin access. + ### With the CLI The easiest way to use it is with the [webpack CLI](https://webpack.js.org/api/cli/). In the directory where your @@ -105,6 +110,8 @@ Options: --no-watch-options-stdin Negative 'watch-options-stdin' option. --allowed-hosts Allows to enumerate the hosts from which access to the dev server are allowed (useful when you are proxying dev server, by default is 'auto'). --allowed-hosts-reset Clear all items provided in 'allowedHosts' configuration. Allows to enumerate the hosts from which access to the dev server are allowed (useful when you are proxying dev server, by default is 'auto'). + --allowed-origins Allows additional HTTP(S) origins to access the dev server. Defaults to 'auto' (same-origin only). Use 'all' to disable origin checks independently of allowedHosts. + --allowed-origins-reset Clear all items provided in 'allowedOrigins' configuration. Allows additional HTTP(S) origins to access the dev server. Defaults to 'auto' (same-origin only). Use 'all' to disable origin checks independently of allowedHosts. --bonjour Allows to broadcasts dev server via ZeroConf networking on start. --no-bonjour Disallows to broadcasts dev server via ZeroConf networking on start. --no-client Disables client script. diff --git a/examples/allowed-origins/README.md b/examples/allowed-origins/README.md new file mode 100644 index 0000000000..cc74c408db --- /dev/null +++ b/examples/allowed-origins/README.md @@ -0,0 +1,82 @@ +# Allowing a separate frontend origin + +`allowedHosts` validates the destination `Host` header. `allowedOrigins` controls +which pages can access HTTP resources, HMR (WebSocket or Server-Sent Events), and +WebSocket proxies. Configuring `allowedHosts` or `client.webSocketURL` does not +authorize another origin. + +```js +export default { + devServer: { + allowedHosts: ["localhost"], + allowedOrigins: ["http://localhost:3000"], + client: { + webSocketURL: "ws://localhost:8080/ws", + }, + }, +}; +``` + +Run this example from this directory: + +```console +npx webpack serve --port 8080 --open +``` + +The page at `http://localhost:8080` should display `Success!`. A frontend served +from `http://localhost:3000` can also fetch the bundle and connect to HMR. For +example, run this in that frontend's browser console: + +```js +const response = await fetch("http://localhost:8080/main.js"); +console.log(response.status); // 200 +``` + +The server supplies `Access-Control-Allow-Origin` for an authorized origin. Other +origins receive 403 for HTTP requests carrying `Origin` and cannot receive HMR +messages or connect through a WebSocket proxy. The host check still applies. + +## Values + +- `"auto"` (default) or `[]`: allow only the same origin. +- An HTTP(S) origin or an array of origins: allow those origins in addition to + the same origin. The scheme, hostname, and port must match. Entries may have a + trailing slash, but no path, credentials, query, or fragment. Hostname + wildcards and opaque origins (`"null"`) are not supported. +- `"all"`: explicitly disable origin checks and send + `Access-Control-Allow-Origin: *`. Only use this when you intend to expose the + development server to any website. This does not disable the host check. + +The CLI accepts repeated flags: + +```console +npx webpack serve --allowed-origins http://localhost:3000 --allowed-origins https://app.example.com +``` + +## Migrating cross-origin setups + +Previously, an entry in `allowedHosts` or a custom `client.webSocketURL` could +implicitly disable origin protection. Add the frontend's full HTTP(S) origin to +`allowedOrigins`, including for reverse proxies that change the host, port, or +protocol. For TLS terminated at a reverse proxy, list the public HTTPS origin. +Forwarded headers do not grant permission. + +`localhost`, `127.0.0.1`, and `[::1]` are different origins. Another port on the +same hostname is also a different origin. Add each frontend origin that you use. + +Cross-origin script and image elements should use `crossorigin="anonymous"` so +the browser sends `Origin` and applies CORS: + +```html + +``` + +A cross-origin `no-cors` request without `Origin` cannot be matched to an allowed +page, even if it sends a referrer header. Fetch Metadata checks and +`Cross-Origin-Resource-Policy: same-origin` continue to protect those requests. +Configuring CORS headers through `headers` alone does not authorize an origin; +use `allowedOrigins`. Custom response headers, including preflight headers when +needed, can still be configured through `headers`. + +The internal invalidate and open-editor endpoints retain their same-origin +protection because they change server state. diff --git a/examples/allowed-origins/app.js b/examples/allowed-origins/app.js new file mode 100644 index 0000000000..2c4cc03a01 --- /dev/null +++ b/examples/allowed-origins/app.js @@ -0,0 +1 @@ +document.querySelector("#target").textContent = "Success!"; diff --git a/examples/allowed-origins/webpack.config.js b/examples/allowed-origins/webpack.config.js new file mode 100644 index 0000000000..4e5a6ecde3 --- /dev/null +++ b/examples/allowed-origins/webpack.config.js @@ -0,0 +1,15 @@ +import { setup } from "../util.js"; + +export default setup( + { + entry: "./app.js", + devServer: { + allowedHosts: ["localhost"], + allowedOrigins: ["http://localhost:3000"], + client: { + webSocketURL: "ws://localhost:8080/ws", + }, + }, + }, + import.meta.url, +); diff --git a/lib/Server.js b/lib/Server.js index d4914aa113..1d0e89bdec 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -247,6 +247,7 @@ function loadWebpackPeer() { * @property {DevMiddlewareOptions=} devMiddleware * @property {boolean=} compress * @property {("auto" | "all" | string | string[])=} allowedHosts + * @property {("auto" | "all" | string | string[])=} allowedOrigins * @property {(boolean | ConnectHistoryApiFallbackOptions)=} historyApiFallback * @property {(boolean | Record | BonjourOptions)=} bonjour * @property {(string | string[] | WatchFiles | (string | WatchFiles)[])=} watchFiles @@ -875,6 +876,39 @@ class Server { options.allowedHosts = "all"; } + if (typeof options.allowedOrigins === "undefined") { + options.allowedOrigins = "auto"; + } else { + const origins = Array.isArray(options.allowedOrigins) + ? options.allowedOrigins + : [options.allowedOrigins]; + + if (origins.includes("all")) { + options.allowedOrigins = "all"; + } else { + options.allowedOrigins = origins + .filter((origin) => origin !== "auto") + .map((origin) => { + const url = new URL(origin); + + if ( + !/^https?:$/.test(url.protocol) || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + throw new Error( + `Invalid allowedOrigins entry: ${origin}. Use an HTTP(S) origin without a path, credentials, query or fragment.`, + ); + } + + return url.origin; + }); + } + } + if (typeof options.bonjour === "undefined") { options.bonjour = false; } else if (typeof options.bonjour === "boolean") { @@ -1804,8 +1838,7 @@ class Server { if ( !headers || !this.isValidHost(headers, "host") || - !this.isValidHost(headers, "origin") || - !this.isSameOrigin(headers) + !this.isOriginAllowed(headers) ) { if (!socket.destroyed) { socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); @@ -1969,30 +2002,37 @@ class Server { const headers = /** @type {{ [key: string]: string | undefined }} */ (req.headers); - const headerName = headers[":authority"] ? ":authority" : "host"; - - if (this.isValidHost(headers, headerName, false)) { - next(); - return; - } + const hasOrigin = typeof headers.origin === "string"; + const originAllowed = this.isOriginAllowed(headers); if ( - headers["sec-fetch-mode"] === "no-cors" && - headers["sec-fetch-site"] === "cross-site" + !originAllowed && + (hasOrigin || + (headers["sec-fetch-mode"] === "no-cors" && + (headers["sec-fetch-site"] === "cross-site" || + headers["sec-fetch-site"] === "same-site"))) ) { res.statusCode = 403; res.end("Cross-Origin request blocked"); return; } - // Block cross-origin resource loading when Sec-Fetch-* headers are absent (HTTP origins) - if ( - this.options.allowedHosts !== "all" && - !this.isUserCORSWildcardEnabled() - ) { + // Also protect browsers that omit Fetch Metadata. A no-cors request + // without Origin cannot be matched against an explicit origin list. + if (!originAllowed) { res.setHeader("Cross-Origin-Resource-Policy", "same-origin"); } + // Cache entries must not reuse a response authorized for another page, + // including when a request without Origin was made first. + res.setHeader("Vary", "Origin"); + + if (this.options.allowedOrigins === "all") { + res.setHeader("Access-Control-Allow-Origin", "*"); + } else if (typeof headers.origin === "string" && originAllowed) { + res.setHeader("Access-Control-Allow-Origin", headers.origin); + } + next(); }, }); @@ -2676,8 +2716,7 @@ class Server { * * The middleware hands over each connection and the request it joined with, * and applies no policy of its own. This is that policy: the same - * `allowedHosts`, `Origin` and same-origin rules this server has always - * applied, in the same order, before anything is published to a client. + * host and origin rules, before anything is published to a client. * @private * @returns {void} */ @@ -2726,9 +2765,7 @@ class Server { if ( !headers || !this.isValidHost(headers, "host") || - (!isSameOriginStream && - (!this.isValidHost(headers, "origin") || - !this.isSameOrigin(headers))) + (!isSameOriginStream && !this.isOriginAllowed(headers)) ) { // Said to the client before it is closed, so the reason reaches the // page rather than only this server's log — a refusal that closes @@ -3379,61 +3416,13 @@ class Server { } }; - /** - * @private - * @returns {boolean} true when the user has configured a wildcard - * Access-Control-Allow-Origin header (opting into fully open cross-origin access) - */ - isUserCORSWildcardEnabled() { - const { headers } = this.options; - - if (!headers) { - return false; - } - - if (typeof headers === "function") { - return false; - } - - /** - * @param {string | string[]} value header value - * @returns {boolean} true when value is the "*" wildcard - */ - const isWildcard = (value) => { - if (typeof value === "string") { - return value.trim() === "*"; - } - - if (Array.isArray(value)) { - return value.length === 1 && isWildcard(value[0]); - } - - return false; - }; - - if (Array.isArray(headers)) { - return headers.some( - (header) => - header.key.toLowerCase() === "access-control-allow-origin" && - isWildcard(header.value), - ); - } - - return Object.entries(headers).some( - ([key, value]) => - key.toLowerCase() === "access-control-allow-origin" && - isWildcard(value), - ); - } - /** * @private * @param {{ [key: string]: string | undefined }} headers headers * @param {string} headerToCheck header to check - * @param {boolean} validateHost need to validate host * @returns {boolean} true when host is valid, otherwise false */ - isValidHost(headers, headerToCheck, validateHost = true) { + isValidHost(headers, headerToCheck) { const isOrigin = headerToCheck === "origin"; if (isOrigin && this.options.allowedHosts === "all") { @@ -3475,76 +3464,80 @@ class Server { // For convenience, always allow localhost (hostname === 'localhost') // and its subdomains (hostname.endsWith(".localhost")). // allow hostname of listening address (hostname === this.options.host) - const isValidHostname = validateHost - ? ipaddr.IPv4.isValid(hostname) || - ipaddr.IPv6.isValid(hostname) || - hostname === "localhost" || - hostname.endsWith(".localhost") || - hostname === this.options.host - : false; - - return isValidHostname; + return ( + ipaddr.IPv4.isValid(hostname) || + ipaddr.IPv6.isValid(hostname) || + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === this.options.host + ); } /** * @private * @param {{ [key: string]: string | undefined }} headers headers - * @returns {boolean} true when is same origin, otherwise false + * @returns {boolean} true when the request origin is allowed */ - isSameOrigin(headers) { - if (this.options.allowedHosts === "all") { - return true; - } - - const originHeader = headers.origin; - - if (!originHeader) { - return this.options.allowedHosts === "all"; - } + isOriginAllowed(headers) { + const { allowedOrigins } = this.options; - if (DEFAULT_ALLOWED_PROTOCOLS.test(originHeader)) { + if (allowedOrigins === "all") { return true; } - const origin = this.#parseHostnameFromHeader(originHeader); + const origin = this.#parseOrigin(headers.origin); if (origin === null) { return false; } - if (this.isHostAllowed(origin)) { - return true; + return ( + this.isSameOrigin(headers) || + (Array.isArray(allowedOrigins) && allowedOrigins.includes(origin)) + ); + } + + /** + * @param {string | undefined} value Origin header value + * @returns {string | null} serialized HTTP(S) origin, or null if invalid + */ + #parseOrigin(value) { + if ( + typeof value !== "string" || + !/^https?:\/\/[^/?#\s@\\]+$/i.test(value) + ) { + return null; } - const hostHeader = headers.host; + try { + const url = new URL(value); - if (!hostHeader) { - return this.options.allowedHosts === "all"; + // Origin is a single serialized origin, never a URL with credentials, + // a path, or a list. Opaque origins ("null") cannot identify a page. + return url.origin; + } catch { + return null; } + } - const host = this.#parseHostnameFromHeader(hostHeader); + /** + * @private + * @param {{ [key: string]: string | undefined }} headers headers + * @returns {boolean} true when scheme, hostname and port match + */ + isSameOrigin(headers) { + const origin = this.#parseOrigin(headers.origin); + const host = headers[":authority"] || headers.host; - if (host === null) { + if (origin === null || !host) { return false; } - if (this.isHostAllowed(host)) { - return true; - } - - // Treat all loopback aliases as equivalent: localhost may resolve to - // 127.0.0.1 or ::1 depending on the OS, causing a false mismatch. - // Only widen when allowedHosts is "auto" (default) or already permits a - // loopback alias, so an explicit allow-list excluding loopback is honored. - const loopbacks = new Set(["localhost", "127.0.0.1", "::1"]); - const loopbackPermitted = - this.options.allowedHosts === "auto" || - [...loopbacks].some((alias) => this.isHostAllowed(alias)); - if (loopbacks.has(origin) && loopbacks.has(host) && loopbackPermitted) { - return true; - } + const protocol = this.isTlsServer ? "https" : "http"; - return origin === host; + // Do not infer the public origin from forwarded headers or webSocketURL. + // A reverse proxy with a different public origin must opt in explicitly. + return origin === this.#parseOrigin(`${protocol}://${host}`); } /** diff --git a/lib/options.check.js b/lib/options.check.js index 0381b3c4c1..7f3bcdf4dc 100644 --- a/lib/options.check.js +++ b/lib/options.check.js @@ -2,4 +2,4 @@ // DO NOT MODIFY BY HAND. Run `npm run fix:schema-check` to update. /* eslint-disable */ // @ts-nocheck -"use strict";export const validate = validate10;export default validate10;const schema11 = {"title":"Dev Server options","type":"object","definitions":{"App":{"instanceof":"Function","description":"Allows to use custom applications, such as 'connect', 'fastify', etc.","link":"https://webpack.js.org/configuration/dev-server/#devserverapp"},"AllowedHosts":{"anyOf":[{"type":"array","minItems":1,"items":{"$ref":"#/definitions/AllowedHostsItem"}},{"enum":["auto","all"]},{"$ref":"#/definitions/AllowedHostsItem"}],"description":"Allows to enumerate the hosts from which access to the dev server are allowed (useful when you are proxying dev server, by default is 'auto').","link":"https://webpack.js.org/configuration/dev-server/#devserverallowedhosts"},"AllowedHostsItem":{"type":"string","minLength":1},"Bonjour":{"anyOf":[{"type":"boolean","cli":{"negatedDescription":"Disallows to broadcasts dev server via ZeroConf networking on start."}},{"type":"object","description":"Options for bonjour.","link":"https://github.com/watson/bonjour#initializing"}],"description":"Allows to broadcasts dev server via ZeroConf networking on start.","link":" https://webpack.js.org/configuration/dev-server/#devserverbonjour"},"Client":{"description":"Allows to specify options for client script in the browser or disable client script.","link":"https://webpack.js.org/configuration/dev-server/#devserverclient","anyOf":[{"enum":[false],"cli":{"negatedDescription":"Disables client script."}},{"type":"object","additionalProperties":false,"properties":{"logging":{"$ref":"#/definitions/ClientLogging"},"overlay":{"$ref":"#/definitions/ClientOverlay"},"progress":{"$ref":"#/definitions/ClientProgress"},"reconnect":{"$ref":"#/definitions/ClientReconnect"},"webSocketTransport":{"$ref":"#/definitions/ClientWebSocketTransport"},"webSocketURL":{"$ref":"#/definitions/ClientWebSocketURL"}}}]},"ClientLogging":{"enum":["none","error","warn","info","log","verbose"],"description":"Allows to set log level in the browser.","link":"https://webpack.js.org/configuration/dev-server/#logging"},"ClientOverlay":{"anyOf":[{"description":"Enables a full-screen overlay in the browser when there are compiler errors or warnings.","link":"https://webpack.js.org/configuration/dev-server/#overlay","type":"boolean","cli":{"negatedDescription":"Disables the full-screen overlay in the browser when there are compiler errors or warnings."}},{"type":"object","additionalProperties":false,"properties":{"errors":{"anyOf":[{"description":"Enables a full-screen overlay in the browser when there are compiler errors.","type":"boolean","cli":{"negatedDescription":"Disables the full-screen overlay in the browser when there are compiler errors."}},{"instanceof":"Function","description":"Filter compiler errors. Return true to include and return false to exclude."}]},"warnings":{"anyOf":[{"description":"Enables a full-screen overlay in the browser when there are compiler warnings.","type":"boolean","cli":{"negatedDescription":"Disables the full-screen overlay in the browser when there are compiler warnings."}},{"instanceof":"Function","description":"Filter compiler warnings. Return true to include and return false to exclude."}]},"runtimeErrors":{"anyOf":[{"description":"Enables a full-screen overlay in the browser when there are uncaught runtime errors.","type":"boolean","cli":{"negatedDescription":"Disables the full-screen overlay in the browser when there are uncaught runtime errors."}},{"instanceof":"Function","description":"Filter uncaught runtime errors. Return true to include and return false to exclude."}]},"trustedTypesPolicyName":{"description":"The name of a Trusted Types policy for the overlay. Defaults to 'webpack-dev-server#overlay'.","type":"string"}}}]},"ClientProgress":{"description":"Displays compilation progress in the browser. Options include 'linear' and 'circular' for visual indicators.","link":"https://webpack.js.org/configuration/dev-server/#progress","type":["boolean","string"],"enum":[true,false,"linear","circular"],"cli":{"negatedDescription":"Does not display compilation progress in the browser."}},"ClientReconnect":{"description":"Tells dev-server the number of times it should try to reconnect the client.","link":"https://webpack.js.org/configuration/dev-server/#reconnect","anyOf":[{"type":"boolean","cli":{"negatedDescription":"Tells dev-server to not to try to reconnect the client."}},{"type":"number","minimum":0}]},"ClientWebSocketTransport":{"anyOf":[{"$ref":"#/definitions/ClientWebSocketTransportEnum"},{"$ref":"#/definitions/ClientWebSocketTransportString"}],"description":"Allows to set custom web socket transport to communicate with dev server.","link":"https://webpack.js.org/configuration/dev-server/#websockettransport"},"ClientWebSocketTransportEnum":{"enum":["ws","sse"]},"ClientWebSocketTransportString":{"type":"string","minLength":1},"ClientWebSocketURL":{"description":"Allows to specify URL to web socket server (useful when you're proxying dev server and client script does not always know where to connect to).","link":"https://webpack.js.org/configuration/dev-server/#websocketurl","anyOf":[{"type":"string","minLength":1},{"type":"object","additionalProperties":false,"properties":{"hostname":{"description":"Tells clients connected to devServer to use the provided hostname.","type":"string","minLength":1},"pathname":{"description":"Tells clients connected to devServer to use the provided path to connect.","type":"string"},"password":{"description":"Tells clients connected to devServer to use the provided password to authenticate.","type":"string"},"port":{"description":"Tells clients connected to devServer to use the provided port.","anyOf":[{"type":"number"},{"type":"string","minLength":1}]},"protocol":{"description":"Tells clients connected to devServer to use the provided protocol.","anyOf":[{"enum":["auto"]},{"type":"string","minLength":1}]},"username":{"description":"Tells clients connected to devServer to use the provided username to authenticate.","type":"string"}}}]},"Compress":{"type":"boolean","description":"Enables gzip compression for everything served.","link":"https://webpack.js.org/configuration/dev-server/#devservercompress","cli":{"negatedDescription":"Disables gzip compression for everything served."}},"DevMiddleware":{"description":"Provide options to 'webpack-dev-middleware' which handles webpack assets.","link":"https://webpack.js.org/configuration/dev-server/#devserverdevmiddleware","type":"object","additionalProperties":true},"HeaderObject":{"type":"object","additionalProperties":false,"properties":{"key":{"description":"key of header.","type":"string"},"value":{"description":"value of header.","type":"string"}},"cli":{"exclude":true}},"Headers":{"anyOf":[{"type":"array","items":{"$ref":"#/definitions/HeaderObject"},"minItems":1},{"type":"object"},{"instanceof":"Function"}],"description":"Allows to set custom headers on response.","link":"https://webpack.js.org/configuration/dev-server/#devserverheaders"},"HistoryApiFallback":{"anyOf":[{"type":"boolean","cli":{"negatedDescription":"Disallows to proxy requests through a specified index page."}},{"type":"object","description":"Options for `historyApiFallback`.","link":"https://github.com/bripkens/connect-history-api-fallback#options"}],"description":"Allows to proxy requests through a specified index page (by default 'index.html'), useful for Single Page Applications that utilise the HTML5 History API.","link":"https://webpack.js.org/configuration/dev-server/#devserverhistoryapifallback"},"Host":{"description":"Allows to specify a hostname to use.","link":"https://webpack.js.org/configuration/dev-server/#devserverhost","anyOf":[{"enum":["local-ip","local-ipv4","local-ipv6"]},{"type":"string","minLength":1}]},"Hot":{"anyOf":[{"type":"boolean","cli":{"negatedDescription":"Disables Hot Module Replacement."}},{"enum":["only"]}],"description":"Enables Hot Module Replacement.","link":"https://webpack.js.org/configuration/dev-server/#devserverhot"},"IPC":{"anyOf":[{"type":"string","minLength":1},{"type":"boolean","enum":[true]}],"description":"Listen to a unix socket.","link":"https://webpack.js.org/configuration/dev-server/#devserveripc"},"LiveReload":{"type":"boolean","description":"Enables reload/refresh the page(s) when file changes are detected (enabled by default).","cli":{"negatedDescription":"Disables reload/refresh the page(s) when file changes are detected (enabled by default)."},"link":"https://webpack.js.org/configuration/dev-server/#devserverlivereload"},"OnListening":{"instanceof":"Function","description":"Provides the ability to execute a custom function when dev server starts listening.","link":"https://webpack.js.org/configuration/dev-server/#devserveronlistening"},"Open":{"anyOf":[{"type":"array","items":{"anyOf":[{"$ref":"#/definitions/OpenString"},{"$ref":"#/definitions/OpenObject"}]}},{"$ref":"#/definitions/OpenBoolean"},{"$ref":"#/definitions/OpenString"},{"$ref":"#/definitions/OpenObject"}],"description":"Allows to configure dev server to open the browser(s) and page(s) after server had been started (set it to true to open your default browser).","link":"https://webpack.js.org/configuration/dev-server/#devserveropen"},"OpenBoolean":{"type":"boolean","cli":{"negatedDescription":"Does not open the default browser."}},"OpenObject":{"type":"object","additionalProperties":false,"properties":{"target":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"string"}],"description":"Opens specified page in browser."},"app":{"anyOf":[{"type":"object","additionalProperties":false,"properties":{"name":{"anyOf":[{"type":"array","items":{"type":"string","minLength":1},"minItems":1},{"type":"string","minLength":1}]},"arguments":{"items":{"type":"string","minLength":1}}}},{"type":"string","minLength":1,"description":"Open specified browser.","cli":{"exclude":true}}],"description":"Open specified browser."}}},"OpenString":{"type":"string","minLength":1},"Port":{"anyOf":[{"type":"number","minimum":0,"maximum":65535},{"type":"string","minLength":1},{"enum":["auto"]}],"description":"Allows to specify a port to use.","link":"https://webpack.js.org/configuration/dev-server/#devserverport"},"Proxy":{"type":"array","items":{"anyOf":[{"type":"object"},{"instanceof":"Function"}]},"description":"Allows to proxy requests, can be useful when you have a separate API backend development server and you want to send API requests on the same domain.","link":"https://webpack.js.org/configuration/dev-server/#devserverproxy"},"Server":{"anyOf":[{"$ref":"#/definitions/ServerEnum"},{"$ref":"#/definitions/ServerFn"},{"$ref":"#/definitions/ServerString"},{"$ref":"#/definitions/ServerObject"}],"link":"https://webpack.js.org/configuration/dev-server/#devserverserver","description":"Allows to set server and options (by default 'http')."},"ServerType":{"enum":["http","https","http2"]},"ServerFn":{"instanceof":"Function"},"ServerEnum":{"enum":["http","https","http2"],"cli":{"exclude":true}},"ServerString":{"type":"string","minLength":1,"cli":{"exclude":true}},"ServerObject":{"type":"object","properties":{"type":{"anyOf":[{"$ref":"#/definitions/ServerType"},{"$ref":"#/definitions/ServerString"},{"$ref":"#/definitions/ServerFn"}]},"options":{"$ref":"#/definitions/ServerOptions"}},"additionalProperties":false},"ServerOptions":{"type":"object","additionalProperties":true,"properties":{"passphrase":{"type":"string","description":"Passphrase for a pfx file."},"requestCert":{"type":"boolean","description":"Request for an SSL certificate.","cli":{"negatedDescription":"Does not request for an SSL certificate."}},"ca":{"anyOf":[{"type":"array","items":{"anyOf":[{"type":"string"},{"instanceof":"Buffer"}]}},{"type":"string"},{"instanceof":"Buffer"}],"description":"Path to an SSL CA certificate or content of an SSL CA certificate."},"cert":{"anyOf":[{"type":"array","items":{"anyOf":[{"type":"string"},{"instanceof":"Buffer"}]}},{"type":"string"},{"instanceof":"Buffer"}],"description":"Path to an SSL certificate or content of an SSL certificate."},"crl":{"anyOf":[{"type":"array","items":{"anyOf":[{"type":"string"},{"instanceof":"Buffer"}]}},{"type":"string"},{"instanceof":"Buffer"}],"description":"Path to PEM formatted CRLs (Certificate Revocation Lists) or content of PEM formatted CRLs (Certificate Revocation Lists)."},"key":{"anyOf":[{"type":"array","items":{"anyOf":[{"type":"string"},{"instanceof":"Buffer"},{"type":"object","additionalProperties":true}]}},{"type":"string"},{"instanceof":"Buffer"}],"description":"Path to an SSL key or content of an SSL key."},"pfx":{"anyOf":[{"type":"array","items":{"anyOf":[{"type":"string"},{"instanceof":"Buffer"},{"type":"object","additionalProperties":true}]}},{"type":"string"},{"instanceof":"Buffer"}],"description":"Path to an SSL pfx file or content of an SSL pfx file."}}},"SetupExitSignals":{"type":"boolean","description":"Allows to close dev server and exit the process on SIGINT and SIGTERM signals (enabled by default for CLI).","link":"https://webpack.js.org/configuration/dev-server/#devserversetupexitsignals","cli":{"exclude":true}},"SetupMiddlewares":{"instanceof":"Function","description":"Provides the ability to execute a custom function and apply custom middleware(s).","link":"https://webpack.js.org/configuration/dev-server/#devserversetupmiddlewares"},"Static":{"anyOf":[{"type":"array","items":{"anyOf":[{"$ref":"#/definitions/StaticString"},{"$ref":"#/definitions/StaticObject"}]}},{"type":"boolean","cli":{"negatedDescription":"Disallows to configure options for serving static files from directory."}},{"$ref":"#/definitions/StaticString"},{"$ref":"#/definitions/StaticObject"}],"description":"Allows to configure options for serving static files from directory (by default 'public' directory).","link":"https://webpack.js.org/configuration/dev-server/#devserverstatic"},"StaticObject":{"type":"object","additionalProperties":false,"properties":{"directory":{"type":"string","minLength":1,"description":"Directory for static contents.","link":"https://webpack.js.org/configuration/dev-server/#directory"},"staticOptions":{"type":"object","link":"https://webpack.js.org/configuration/dev-server/#staticoptions","additionalProperties":true},"publicPath":{"anyOf":[{"type":"array","items":{"type":"string"},"minItems":1},{"type":"string"}],"description":"The static files will be available in the browser under this public path.","link":"https://webpack.js.org/configuration/dev-server/#publicpath"},"serveIndex":{"anyOf":[{"type":"boolean","cli":{"negatedDescription":"Does not tell dev server to use serveIndex middleware."}},{"type":"object","additionalProperties":true}],"description":"Tells dev server to use serveIndex middleware when enabled.","link":"https://webpack.js.org/configuration/dev-server/#serveindex"},"watch":{"anyOf":[{"type":"boolean","cli":{"negatedDescription":"Does not watch for files in static content directory."}},{"type":"object","description":"Options for watch.","link":"https://github.com/paulmillr/chokidar#api"}],"description":"Watches for files in static content directory.","link":"https://webpack.js.org/configuration/dev-server/#watch"}}},"StaticString":{"type":"string","minLength":1},"WatchFiles":{"anyOf":[{"type":"array","items":{"anyOf":[{"$ref":"#/definitions/WatchFilesString"},{"$ref":"#/definitions/WatchFilesObject"}]}},{"$ref":"#/definitions/WatchFilesString"},{"$ref":"#/definitions/WatchFilesObject"}],"description":"Allows to configure list of globs/directories/files to watch for file changes.","link":"https://webpack.js.org/configuration/dev-server/#devserverwatchfiles"},"WatchFilesObject":{"cli":{"exclude":true},"type":"object","properties":{"paths":{"anyOf":[{"type":"array","items":{"type":"string","minLength":1}},{"type":"string","minLength":1}],"description":"Path(s) of globs/directories/files to watch for file changes."},"options":{"type":"object","description":"Configure advanced options for watching. See the chokidar documentation for the possible options.","link":"https://github.com/paulmillr/chokidar#api","additionalProperties":true}},"additionalProperties":false},"WatchFilesString":{"type":"string","minLength":1},"WebSocketServer":{"anyOf":[{"$ref":"#/definitions/WebSocketServerEnum"},{"$ref":"#/definitions/WebSocketServerString"},{"$ref":"#/definitions/WebSocketServerFunction"},{"$ref":"#/definitions/WebSocketServerObject"}],"description":"Allows to set web socket server and options (by default 'ws').","link":"https://webpack.js.org/configuration/dev-server/#devserverwebsocketserver"},"WebSocketServerType":{"enum":["ws"]},"WebSocketServerEnum":{"anyOf":[{"enum":[false],"cli":{"negatedDescription":"Disallows to set web socket server and options."}},{"enum":["ws","sse"],"cli":{"exclude":true}}]},"WebSocketServerFunction":{"instanceof":"Function"},"WebSocketServerObject":{"type":"object","properties":{"type":{"anyOf":[{"$ref":"#/definitions/WebSocketServerType"},{"$ref":"#/definitions/WebSocketServerString"},{"$ref":"#/definitions/WebSocketServerFunction"}]},"options":{"type":"object","additionalProperties":true,"cli":{"exclude":true}}},"additionalProperties":false},"WebSocketServerString":{"type":"string","minLength":1,"cli":{"exclude":true}}},"additionalProperties":false,"properties":{"allowedHosts":{"$ref":"#/definitions/AllowedHosts"},"bonjour":{"$ref":"#/definitions/Bonjour"},"client":{"$ref":"#/definitions/Client"},"compress":{"$ref":"#/definitions/Compress"},"devMiddleware":{"$ref":"#/definitions/DevMiddleware"},"headers":{"$ref":"#/definitions/Headers"},"historyApiFallback":{"$ref":"#/definitions/HistoryApiFallback"},"host":{"$ref":"#/definitions/Host"},"hot":{"$ref":"#/definitions/Hot"},"ipc":{"$ref":"#/definitions/IPC"},"liveReload":{"$ref":"#/definitions/LiveReload"},"onListening":{"$ref":"#/definitions/OnListening"},"open":{"$ref":"#/definitions/Open"},"port":{"$ref":"#/definitions/Port"},"proxy":{"$ref":"#/definitions/Proxy"},"server":{"$ref":"#/definitions/Server"},"app":{"$ref":"#/definitions/App"},"setupExitSignals":{"$ref":"#/definitions/SetupExitSignals"},"setupMiddlewares":{"$ref":"#/definitions/SetupMiddlewares"},"static":{"$ref":"#/definitions/Static"},"watchFiles":{"$ref":"#/definitions/WatchFiles"},"webSocketServer":{"$ref":"#/definitions/WebSocketServer"}}};const schema15 = {"anyOf":[{"type":"boolean","cli":{"negatedDescription":"Disallows to broadcasts dev server via ZeroConf networking on start."}},{"type":"object","description":"Options for bonjour.","link":"https://github.com/watson/bonjour#initializing"}],"description":"Allows to broadcasts dev server via ZeroConf networking on start.","link":" https://webpack.js.org/configuration/dev-server/#devserverbonjour"};const schema25 = {"type":"boolean","description":"Enables gzip compression for everything served.","link":"https://webpack.js.org/configuration/dev-server/#devservercompress","cli":{"negatedDescription":"Disables gzip compression for everything served."}};const schema26 = {"description":"Provide options to 'webpack-dev-middleware' which handles webpack assets.","link":"https://webpack.js.org/configuration/dev-server/#devserverdevmiddleware","type":"object","additionalProperties":true};const schema29 = {"anyOf":[{"type":"boolean","cli":{"negatedDescription":"Disallows to proxy requests through a specified index page."}},{"type":"object","description":"Options for `historyApiFallback`.","link":"https://github.com/bripkens/connect-history-api-fallback#options"}],"description":"Allows to proxy requests through a specified index page (by default 'index.html'), useful for Single Page Applications that utilise the HTML5 History API.","link":"https://webpack.js.org/configuration/dev-server/#devserverhistoryapifallback"};const schema30 = {"description":"Allows to specify a hostname to use.","link":"https://webpack.js.org/configuration/dev-server/#devserverhost","anyOf":[{"enum":["local-ip","local-ipv4","local-ipv6"]},{"type":"string","minLength":1}]};const schema31 = {"anyOf":[{"type":"boolean","cli":{"negatedDescription":"Disables Hot Module Replacement."}},{"enum":["only"]}],"description":"Enables Hot Module Replacement.","link":"https://webpack.js.org/configuration/dev-server/#devserverhot"};const schema32 = {"anyOf":[{"type":"string","minLength":1},{"type":"boolean","enum":[true]}],"description":"Listen to a unix socket.","link":"https://webpack.js.org/configuration/dev-server/#devserveripc"};const schema33 = {"type":"boolean","description":"Enables reload/refresh the page(s) when file changes are detected (enabled by default).","cli":{"negatedDescription":"Disables reload/refresh the page(s) when file changes are detected (enabled by default)."},"link":"https://webpack.js.org/configuration/dev-server/#devserverlivereload"};const schema34 = {"instanceof":"Function","description":"Provides the ability to execute a custom function when dev server starts listening.","link":"https://webpack.js.org/configuration/dev-server/#devserveronlistening"};const schema41 = {"anyOf":[{"type":"number","minimum":0,"maximum":65535},{"type":"string","minLength":1},{"enum":["auto"]}],"description":"Allows to specify a port to use.","link":"https://webpack.js.org/configuration/dev-server/#devserverport"};const schema42 = {"type":"array","items":{"anyOf":[{"type":"object"},{"instanceof":"Function"}]},"description":"Allows to proxy requests, can be useful when you have a separate API backend development server and you want to send API requests on the same domain.","link":"https://webpack.js.org/configuration/dev-server/#devserverproxy"};const schema52 = {"instanceof":"Function","description":"Allows to use custom applications, such as 'connect', 'fastify', etc.","link":"https://webpack.js.org/configuration/dev-server/#devserverapp"};const schema53 = {"type":"boolean","description":"Allows to close dev server and exit the process on SIGINT and SIGTERM signals (enabled by default for CLI).","link":"https://webpack.js.org/configuration/dev-server/#devserversetupexitsignals","cli":{"exclude":true}};const schema54 = {"instanceof":"Function","description":"Provides the ability to execute a custom function and apply custom middleware(s).","link":"https://webpack.js.org/configuration/dev-server/#devserversetupmiddlewares"};const func2 = Object.prototype.hasOwnProperty;const schema12 = {"anyOf":[{"type":"array","minItems":1,"items":{"$ref":"#/definitions/AllowedHostsItem"}},{"enum":["auto","all"]},{"$ref":"#/definitions/AllowedHostsItem"}],"description":"Allows to enumerate the hosts from which access to the dev server are allowed (useful when you are proxying dev server, by default is 'auto').","link":"https://webpack.js.org/configuration/dev-server/#devserverallowedhosts"};const schema13 = {"type":"string","minLength":1};function validate11(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;const _errs0 = errors;let valid0 = false;const _errs1 = errors;if(errors === _errs1){if(Array.isArray(data)){if(data.length < 1){const err0 = {instancePath,schemaPath:"#/anyOf/0/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}else {var valid1 = true;const len0 = data.length;for(let i0=0; i0=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}else {const err21 = {instancePath:instancePath+"/reconnect",schemaPath:"#/definitions/ClientReconnect/anyOf/1/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}var _valid5 = _errs50 === errors;valid11 = valid11 || _valid5;}if(!valid11){const err22 = {instancePath:instancePath+"/reconnect",schemaPath:"#/definitions/ClientReconnect/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}else {errors = _errs46;if(vErrors !== null){if(_errs46){vErrors.length = _errs46;}else {vErrors = null;}}}var valid1 = _errs44 === errors;}else {var valid1 = true;}if(valid1){if(data.webSocketTransport !== undefined){const _errs53 = errors;if(!(validate14(data.webSocketTransport, {instancePath:instancePath+"/webSocketTransport",parentData:data,parentDataProperty:"webSocketTransport",rootData}))){vErrors = vErrors === null ? validate14.errors : vErrors.concat(validate14.errors);errors = vErrors.length;}var valid1 = _errs53 === errors;}else {var valid1 = true;}if(valid1){if(data.webSocketURL !== undefined){let data9 = data.webSocketURL;const _errs54 = errors;const _errs56 = errors;let valid13 = false;const _errs57 = errors;if(errors === _errs57){if(typeof data9 === "string"){if(data9.length < 1){const err23 = {instancePath:instancePath+"/webSocketURL",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/0/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/webSocketURL",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/0/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}var _valid6 = _errs57 === errors;valid13 = valid13 || _valid6;if(!valid13){const _errs59 = errors;if(errors === _errs59){if(data9 && typeof data9 == "object" && !Array.isArray(data9)){const _errs61 = errors;for(const key2 in data9){if(!((((((key2 === "hostname") || (key2 === "pathname")) || (key2 === "password")) || (key2 === "port")) || (key2 === "protocol")) || (key2 === "username"))){const err25 = {instancePath:instancePath+"/webSocketURL",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;break;}}if(_errs61 === errors){if(data9.hostname !== undefined){let data10 = data9.hostname;const _errs62 = errors;if(errors === _errs62){if(typeof data10 === "string"){if(data10.length < 1){const err26 = {instancePath:instancePath+"/webSocketURL/hostname",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/hostname/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}else {const err27 = {instancePath:instancePath+"/webSocketURL/hostname",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/hostname/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}var valid14 = _errs62 === errors;}else {var valid14 = true;}if(valid14){if(data9.pathname !== undefined){const _errs64 = errors;if(typeof data9.pathname !== "string"){const err28 = {instancePath:instancePath+"/webSocketURL/pathname",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/pathname/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}var valid14 = _errs64 === errors;}else {var valid14 = true;}if(valid14){if(data9.password !== undefined){const _errs66 = errors;if(typeof data9.password !== "string"){const err29 = {instancePath:instancePath+"/webSocketURL/password",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/password/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}var valid14 = _errs66 === errors;}else {var valid14 = true;}if(valid14){if(data9.port !== undefined){let data13 = data9.port;const _errs68 = errors;const _errs69 = errors;let valid15 = false;const _errs70 = errors;if(!(typeof data13 == "number")){const err30 = {instancePath:instancePath+"/webSocketURL/port",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/port/anyOf/0/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}var _valid7 = _errs70 === errors;valid15 = valid15 || _valid7;if(!valid15){const _errs72 = errors;if(errors === _errs72){if(typeof data13 === "string"){if(data13.length < 1){const err31 = {instancePath:instancePath+"/webSocketURL/port",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/port/anyOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}else {const err32 = {instancePath:instancePath+"/webSocketURL/port",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/port/anyOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}var _valid7 = _errs72 === errors;valid15 = valid15 || _valid7;}if(!valid15){const err33 = {instancePath:instancePath+"/webSocketURL/port",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/port/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}else {errors = _errs69;if(vErrors !== null){if(_errs69){vErrors.length = _errs69;}else {vErrors = null;}}}var valid14 = _errs68 === errors;}else {var valid14 = true;}if(valid14){if(data9.protocol !== undefined){let data14 = data9.protocol;const _errs74 = errors;const _errs75 = errors;let valid16 = false;const _errs76 = errors;if(!(data14 === "auto")){const err34 = {instancePath:instancePath+"/webSocketURL/protocol",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/protocol/anyOf/0/enum",keyword:"enum",params:{allowedValues: schema24.anyOf[1].properties.protocol.anyOf[0].enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}var _valid8 = _errs76 === errors;valid16 = valid16 || _valid8;if(!valid16){const _errs77 = errors;if(errors === _errs77){if(typeof data14 === "string"){if(data14.length < 1){const err35 = {instancePath:instancePath+"/webSocketURL/protocol",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/protocol/anyOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}else {const err36 = {instancePath:instancePath+"/webSocketURL/protocol",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/protocol/anyOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}var _valid8 = _errs77 === errors;valid16 = valid16 || _valid8;}if(!valid16){const err37 = {instancePath:instancePath+"/webSocketURL/protocol",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/protocol/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}else {errors = _errs75;if(vErrors !== null){if(_errs75){vErrors.length = _errs75;}else {vErrors = null;}}}var valid14 = _errs74 === errors;}else {var valid14 = true;}if(valid14){if(data9.username !== undefined){const _errs79 = errors;if(typeof data9.username !== "string"){const err38 = {instancePath:instancePath+"/webSocketURL/username",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/username/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}var valid14 = _errs79 === errors;}else {var valid14 = true;}}}}}}}}else {const err39 = {instancePath:instancePath+"/webSocketURL",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}var _valid6 = _errs59 === errors;valid13 = valid13 || _valid6;}if(!valid13){const err40 = {instancePath:instancePath+"/webSocketURL",schemaPath:"#/definitions/ClientWebSocketURL/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}else {errors = _errs56;if(vErrors !== null){if(_errs56){vErrors.length = _errs56;}else {vErrors = null;}}}var valid1 = _errs54 === errors;}else {var valid1 = true;}}}}}}}}else {const err41 = {instancePath,schemaPath:"#/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}var _valid0 = _errs3 === errors;valid0 = valid0 || _valid0;}if(!valid0){const err42 = {instancePath,schemaPath:"#/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;validate13.errors = vErrors;return false;}else {errors = _errs0;if(vErrors !== null){if(_errs0){vErrors.length = _errs0;}else {vErrors = null;}}}validate13.errors = vErrors;return errors === 0;}const schema27 = {"anyOf":[{"type":"array","items":{"$ref":"#/definitions/HeaderObject"},"minItems":1},{"type":"object"},{"instanceof":"Function"}],"description":"Allows to set custom headers on response.","link":"https://webpack.js.org/configuration/dev-server/#devserverheaders"};const schema28 = {"type":"object","additionalProperties":false,"properties":{"key":{"description":"key of header.","type":"string"},"value":{"description":"value of header.","type":"string"}},"cli":{"exclude":true}};function validate17(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;const _errs0 = errors;let valid0 = false;const _errs1 = errors;if(errors === _errs1){if(Array.isArray(data)){if(data.length < 1){const err0 = {instancePath,schemaPath:"#/anyOf/0/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}else {var valid1 = true;const len0 = data.length;for(let i0=0; i0 65535 || isNaN(data13)){const err18 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/0/maximum",keyword:"maximum",params:{comparison: "<=", limit: 65535},message:"must be <= 65535"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}else {if(data13 < 0 || isNaN(data13)){const err19 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/0/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}else {const err20 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/0/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}var _valid5 = _errs70 === errors;valid16 = valid16 || _valid5;if(!valid16){const _errs72 = errors;if(errors === _errs72){if(typeof data13 === "string"){if(data13.length < 1){const err21 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}else {const err22 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}var _valid5 = _errs72 === errors;valid16 = valid16 || _valid5;if(!valid16){const _errs74 = errors;if(!(data13 === "auto")){const err23 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/2/enum",keyword:"enum",params:{allowedValues: schema41.anyOf[2].enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}var _valid5 = _errs74 === errors;valid16 = valid16 || _valid5;}}if(!valid16){const err24 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs69;if(vErrors !== null){if(_errs69){vErrors.length = _errs69;}else {vErrors = null;}}}var valid0 = _errs67 === errors;}else {var valid0 = true;}if(valid0){if(data.proxy !== undefined){let data14 = data.proxy;const _errs76 = errors;const _errs77 = errors;if(errors === _errs77){if(Array.isArray(data14)){var valid18 = true;const len0 = data14.length;for(let i0=0; i0=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}else {const err21 = {instancePath:instancePath+"/reconnect",schemaPath:"#/definitions/ClientReconnect/anyOf/1/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}var _valid5 = _errs50 === errors;valid11 = valid11 || _valid5;}if(!valid11){const err22 = {instancePath:instancePath+"/reconnect",schemaPath:"#/definitions/ClientReconnect/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}else {errors = _errs46;if(vErrors !== null){if(_errs46){vErrors.length = _errs46;}else {vErrors = null;}}}var valid1 = _errs44 === errors;}else {var valid1 = true;}if(valid1){if(data.webSocketTransport !== undefined){const _errs53 = errors;if(!(validate16(data.webSocketTransport, {instancePath:instancePath+"/webSocketTransport",parentData:data,parentDataProperty:"webSocketTransport",rootData}))){vErrors = vErrors === null ? validate16.errors : vErrors.concat(validate16.errors);errors = vErrors.length;}var valid1 = _errs53 === errors;}else {var valid1 = true;}if(valid1){if(data.webSocketURL !== undefined){let data9 = data.webSocketURL;const _errs54 = errors;const _errs56 = errors;let valid13 = false;const _errs57 = errors;if(errors === _errs57){if(typeof data9 === "string"){if(data9.length < 1){const err23 = {instancePath:instancePath+"/webSocketURL",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/0/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/webSocketURL",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/0/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}var _valid6 = _errs57 === errors;valid13 = valid13 || _valid6;if(!valid13){const _errs59 = errors;if(errors === _errs59){if(data9 && typeof data9 == "object" && !Array.isArray(data9)){const _errs61 = errors;for(const key2 in data9){if(!((((((key2 === "hostname") || (key2 === "pathname")) || (key2 === "password")) || (key2 === "port")) || (key2 === "protocol")) || (key2 === "username"))){const err25 = {instancePath:instancePath+"/webSocketURL",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;break;}}if(_errs61 === errors){if(data9.hostname !== undefined){let data10 = data9.hostname;const _errs62 = errors;if(errors === _errs62){if(typeof data10 === "string"){if(data10.length < 1){const err26 = {instancePath:instancePath+"/webSocketURL/hostname",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/hostname/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}else {const err27 = {instancePath:instancePath+"/webSocketURL/hostname",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/hostname/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}var valid14 = _errs62 === errors;}else {var valid14 = true;}if(valid14){if(data9.pathname !== undefined){const _errs64 = errors;if(typeof data9.pathname !== "string"){const err28 = {instancePath:instancePath+"/webSocketURL/pathname",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/pathname/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}var valid14 = _errs64 === errors;}else {var valid14 = true;}if(valid14){if(data9.password !== undefined){const _errs66 = errors;if(typeof data9.password !== "string"){const err29 = {instancePath:instancePath+"/webSocketURL/password",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/password/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}var valid14 = _errs66 === errors;}else {var valid14 = true;}if(valid14){if(data9.port !== undefined){let data13 = data9.port;const _errs68 = errors;const _errs69 = errors;let valid15 = false;const _errs70 = errors;if(!(typeof data13 == "number")){const err30 = {instancePath:instancePath+"/webSocketURL/port",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/port/anyOf/0/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}var _valid7 = _errs70 === errors;valid15 = valid15 || _valid7;if(!valid15){const _errs72 = errors;if(errors === _errs72){if(typeof data13 === "string"){if(data13.length < 1){const err31 = {instancePath:instancePath+"/webSocketURL/port",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/port/anyOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}else {const err32 = {instancePath:instancePath+"/webSocketURL/port",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/port/anyOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}var _valid7 = _errs72 === errors;valid15 = valid15 || _valid7;}if(!valid15){const err33 = {instancePath:instancePath+"/webSocketURL/port",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/port/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}else {errors = _errs69;if(vErrors !== null){if(_errs69){vErrors.length = _errs69;}else {vErrors = null;}}}var valid14 = _errs68 === errors;}else {var valid14 = true;}if(valid14){if(data9.protocol !== undefined){let data14 = data9.protocol;const _errs74 = errors;const _errs75 = errors;let valid16 = false;const _errs76 = errors;if(!(data14 === "auto")){const err34 = {instancePath:instancePath+"/webSocketURL/protocol",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/protocol/anyOf/0/enum",keyword:"enum",params:{allowedValues: schema27.anyOf[1].properties.protocol.anyOf[0].enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}var _valid8 = _errs76 === errors;valid16 = valid16 || _valid8;if(!valid16){const _errs77 = errors;if(errors === _errs77){if(typeof data14 === "string"){if(data14.length < 1){const err35 = {instancePath:instancePath+"/webSocketURL/protocol",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/protocol/anyOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}else {const err36 = {instancePath:instancePath+"/webSocketURL/protocol",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/protocol/anyOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}var _valid8 = _errs77 === errors;valid16 = valid16 || _valid8;}if(!valid16){const err37 = {instancePath:instancePath+"/webSocketURL/protocol",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/protocol/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}else {errors = _errs75;if(vErrors !== null){if(_errs75){vErrors.length = _errs75;}else {vErrors = null;}}}var valid14 = _errs74 === errors;}else {var valid14 = true;}if(valid14){if(data9.username !== undefined){const _errs79 = errors;if(typeof data9.username !== "string"){const err38 = {instancePath:instancePath+"/webSocketURL/username",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/properties/username/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}var valid14 = _errs79 === errors;}else {var valid14 = true;}}}}}}}}else {const err39 = {instancePath:instancePath+"/webSocketURL",schemaPath:"#/definitions/ClientWebSocketURL/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}var _valid6 = _errs59 === errors;valid13 = valid13 || _valid6;}if(!valid13){const err40 = {instancePath:instancePath+"/webSocketURL",schemaPath:"#/definitions/ClientWebSocketURL/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}else {errors = _errs56;if(vErrors !== null){if(_errs56){vErrors.length = _errs56;}else {vErrors = null;}}}var valid1 = _errs54 === errors;}else {var valid1 = true;}}}}}}}}else {const err41 = {instancePath,schemaPath:"#/anyOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}var _valid0 = _errs3 === errors;valid0 = valid0 || _valid0;}if(!valid0){const err42 = {instancePath,schemaPath:"#/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;validate15.errors = vErrors;return false;}else {errors = _errs0;if(vErrors !== null){if(_errs0){vErrors.length = _errs0;}else {vErrors = null;}}}validate15.errors = vErrors;return errors === 0;}const schema30 = {"anyOf":[{"type":"array","items":{"$ref":"#/definitions/HeaderObject"},"minItems":1},{"type":"object"},{"instanceof":"Function"}],"description":"Allows to set custom headers on response.","link":"https://webpack.js.org/configuration/dev-server/#devserverheaders"};const schema31 = {"type":"object","additionalProperties":false,"properties":{"key":{"description":"key of header.","type":"string"},"value":{"description":"value of header.","type":"string"}},"cli":{"exclude":true}};function validate19(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;const _errs0 = errors;let valid0 = false;const _errs1 = errors;if(errors === _errs1){if(Array.isArray(data)){if(data.length < 1){const err0 = {instancePath,schemaPath:"#/anyOf/0/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}else {var valid1 = true;const len0 = data.length;for(let i0=0; i0 65535 || isNaN(data14)){const err18 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/0/maximum",keyword:"maximum",params:{comparison: "<=", limit: 65535},message:"must be <= 65535"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}else {if(data14 < 0 || isNaN(data14)){const err19 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/0/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}else {const err20 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/0/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}var _valid5 = _errs71 === errors;valid16 = valid16 || _valid5;if(!valid16){const _errs73 = errors;if(errors === _errs73){if(typeof data14 === "string"){if(data14.length < 1){const err21 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}else {const err22 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}var _valid5 = _errs73 === errors;valid16 = valid16 || _valid5;if(!valid16){const _errs75 = errors;if(!(data14 === "auto")){const err23 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf/2/enum",keyword:"enum",params:{allowedValues: schema44.anyOf[2].enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}var _valid5 = _errs75 === errors;valid16 = valid16 || _valid5;}}if(!valid16){const err24 = {instancePath:instancePath+"/port",schemaPath:"#/definitions/Port/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;validate10.errors = vErrors;return false;}else {errors = _errs70;if(vErrors !== null){if(_errs70){vErrors.length = _errs70;}else {vErrors = null;}}}var valid0 = _errs68 === errors;}else {var valid0 = true;}if(valid0){if(data.proxy !== undefined){let data15 = data.proxy;const _errs77 = errors;const _errs78 = errors;if(errors === _errs78){if(Array.isArray(data15)){var valid18 = true;const len0 = data15.length;for(let i0=0; i0 allowedHosts is array 1`] = ` { "allowedHosts": "all", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -55,6 +56,342 @@ exports[`normalize options > allowedHosts is array 1`] = ` exports[`normalize options > allowedHosts is string 1`] = ` { "allowedHosts": "all", + "allowedOrigins": "auto", + "bonjour": false, + "client": { + "logging": "info", + "overlay": true, + "reconnect": 10, + "webSocketURL": {}, + }, + "compress": true, + "devMiddleware": {}, + "historyApiFallback": false, + "host": undefined, + "hot": true, + "liveReload": true, + "open": [], + "port": "", + "server": { + "options": {}, + "type": "http", + }, + "setupExitSignals": true, + "static": [ + { + "directory": "/public", + "publicPath": [ + "/", + ], + "serveIndex": { + "icons": true, + }, + "staticOptions": {}, + "watch": { + "alwaysStat": true, + "atomic": false, + "followSymlinks": false, + "ignoreInitial": true, + "ignorePermissionErrors": true, + "persistent": true, + "usePolling": false, + }, + }, + ], + "watchFiles": [], + "webSocketServer": { + "options": { + "path": "/ws", + }, + "type": "ws", + }, +} +`; + +exports[`normalize options > allowedOrigins all from CLI 1`] = ` +{ + "allowedHosts": "auto", + "allowedOrigins": "all", + "bonjour": false, + "client": { + "logging": "info", + "overlay": true, + "reconnect": 10, + "webSocketURL": {}, + }, + "compress": true, + "devMiddleware": {}, + "historyApiFallback": false, + "host": undefined, + "hot": true, + "liveReload": true, + "open": [], + "port": "", + "server": { + "options": {}, + "type": "http", + }, + "setupExitSignals": true, + "static": [ + { + "directory": "/public", + "publicPath": [ + "/", + ], + "serveIndex": { + "icons": true, + }, + "staticOptions": {}, + "watch": { + "alwaysStat": true, + "atomic": false, + "followSymlinks": false, + "ignoreInitial": true, + "ignorePermissionErrors": true, + "persistent": true, + "usePolling": false, + }, + }, + ], + "watchFiles": [], + "webSocketServer": { + "options": { + "path": "/ws", + }, + "type": "ws", + }, +} +`; + +exports[`normalize options > allowedOrigins array 1`] = ` +{ + "allowedHosts": "auto", + "allowedOrigins": [ + "http://localhost:3000", + "https://[::1]", + ], + "bonjour": false, + "client": { + "logging": "info", + "overlay": true, + "reconnect": 10, + "webSocketURL": {}, + }, + "compress": true, + "devMiddleware": {}, + "historyApiFallback": false, + "host": undefined, + "hot": true, + "liveReload": true, + "open": [], + "port": "", + "server": { + "options": {}, + "type": "http", + }, + "setupExitSignals": true, + "static": [ + { + "directory": "/public", + "publicPath": [ + "/", + ], + "serveIndex": { + "icons": true, + }, + "staticOptions": {}, + "watch": { + "alwaysStat": true, + "atomic": false, + "followSymlinks": false, + "ignoreInitial": true, + "ignorePermissionErrors": true, + "persistent": true, + "usePolling": false, + }, + }, + ], + "watchFiles": [], + "webSocketServer": { + "options": { + "path": "/ws", + }, + "type": "ws", + }, +} +`; + +exports[`normalize options > allowedOrigins auto 1`] = ` +{ + "allowedHosts": "auto", + "allowedOrigins": [], + "bonjour": false, + "client": { + "logging": "info", + "overlay": true, + "reconnect": 10, + "webSocketURL": {}, + }, + "compress": true, + "devMiddleware": {}, + "historyApiFallback": false, + "host": undefined, + "hot": true, + "liveReload": true, + "open": [], + "port": "", + "server": { + "options": {}, + "type": "http", + }, + "setupExitSignals": true, + "static": [ + { + "directory": "/public", + "publicPath": [ + "/", + ], + "serveIndex": { + "icons": true, + }, + "staticOptions": {}, + "watch": { + "alwaysStat": true, + "atomic": false, + "followSymlinks": false, + "ignoreInitial": true, + "ignorePermissionErrors": true, + "persistent": true, + "usePolling": false, + }, + }, + ], + "watchFiles": [], + "webSocketServer": { + "options": { + "path": "/ws", + }, + "type": "ws", + }, +} +`; + +exports[`normalize options > allowedOrigins auto from CLI 1`] = ` +{ + "allowedHosts": "auto", + "allowedOrigins": [], + "bonjour": false, + "client": { + "logging": "info", + "overlay": true, + "reconnect": 10, + "webSocketURL": {}, + }, + "compress": true, + "devMiddleware": {}, + "historyApiFallback": false, + "host": undefined, + "hot": true, + "liveReload": true, + "open": [], + "port": "", + "server": { + "options": {}, + "type": "http", + }, + "setupExitSignals": true, + "static": [ + { + "directory": "/public", + "publicPath": [ + "/", + ], + "serveIndex": { + "icons": true, + }, + "staticOptions": {}, + "watch": { + "alwaysStat": true, + "atomic": false, + "followSymlinks": false, + "ignoreInitial": true, + "ignorePermissionErrors": true, + "persistent": true, + "usePolling": false, + }, + }, + ], + "watchFiles": [], + "webSocketServer": { + "options": { + "path": "/ws", + }, + "type": "ws", + }, +} +`; + +exports[`normalize options > allowedOrigins empty list 1`] = ` +{ + "allowedHosts": "auto", + "allowedOrigins": [], + "bonjour": false, + "client": { + "logging": "info", + "overlay": true, + "reconnect": 10, + "webSocketURL": {}, + }, + "compress": true, + "devMiddleware": {}, + "historyApiFallback": false, + "host": undefined, + "hot": true, + "liveReload": true, + "open": [], + "port": "", + "server": { + "options": {}, + "type": "http", + }, + "setupExitSignals": true, + "static": [ + { + "directory": "/public", + "publicPath": [ + "/", + ], + "serveIndex": { + "icons": true, + }, + "staticOptions": {}, + "watch": { + "alwaysStat": true, + "atomic": false, + "followSymlinks": false, + "ignoreInitial": true, + "ignorePermissionErrors": true, + "persistent": true, + "usePolling": false, + }, + }, + ], + "watchFiles": [], + "webSocketServer": { + "options": { + "path": "/ws", + }, + "type": "ws", + }, +} +`; + +exports[`normalize options > allowedOrigins string 1`] = ` +{ + "allowedHosts": "auto", + "allowedOrigins": [ + "https://example.com", + ], "bonjour": false, "client": { "logging": "info", @@ -109,6 +446,7 @@ exports[`normalize options > allowedHosts is string 1`] = ` exports[`normalize options > client custom webSocketTransport path 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -164,6 +502,7 @@ exports[`normalize options > client custom webSocketTransport path 1`] = ` exports[`normalize options > client host and port 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -221,6 +560,7 @@ exports[`normalize options > client host and port 1`] = ` exports[`normalize options > client host and string port 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -278,6 +618,7 @@ exports[`normalize options > client host and string port 1`] = ` exports[`normalize options > client path 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -334,6 +675,7 @@ exports[`normalize options > client path 1`] = ` exports[`normalize options > client path without leading/ending slashes 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -390,6 +732,7 @@ exports[`normalize options > client path without leading/ending slashes 1`] = ` exports[`normalize options > client.webSocketTransport ws string 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -445,6 +788,7 @@ exports[`normalize options > client.webSocketTransport ws string 1`] = ` exports[`normalize options > client.webSocketTransport ws string and webSocketServer object 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -502,6 +846,7 @@ exports[`normalize options > client.webSocketTransport ws string and webSocketSe exports[`normalize options > client.webSocketTransport ws string and webSocketServer object with port as string 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -559,6 +904,7 @@ exports[`normalize options > client.webSocketTransport ws string and webSocketSe exports[`normalize options > client.webSocketTransport ws string and webSocketServer ws string 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -614,6 +960,7 @@ exports[`normalize options > client.webSocketTransport ws string and webSocketSe exports[`normalize options > dev is set 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -670,6 +1017,7 @@ exports[`normalize options > dev is set 1`] = ` exports[`normalize options > hot is false 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -724,6 +1072,7 @@ exports[`normalize options > hot is false 1`] = ` exports[`normalize options > hot is only 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -778,6 +1127,7 @@ exports[`normalize options > hot is only 1`] = ` exports[`normalize options > hot is true 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -832,6 +1182,7 @@ exports[`normalize options > hot is true 1`] = ` exports[`normalize options > liveReload is false 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -886,6 +1237,7 @@ exports[`normalize options > liveReload is false 1`] = ` exports[`normalize options > liveReload is true 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -940,6 +1292,7 @@ exports[`normalize options > liveReload is true 1`] = ` exports[`normalize options > multi compiler client.logging should override infrastructureLogging.level 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "none", @@ -994,6 +1347,7 @@ exports[`normalize options > multi compiler client.logging should override infra exports[`normalize options > multi compiler client.logging should respect infrastructureLogging.level 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "warn", @@ -1048,6 +1402,7 @@ exports[`normalize options > multi compiler client.logging should respect infras exports[`normalize options > multi compiler client.logging should respect infrastructureLogging.level 2`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "warn", @@ -1102,6 +1457,7 @@ exports[`normalize options > multi compiler client.logging should respect infras exports[`normalize options > multi compiler client.logging should respect infrastructureLogging.level 3`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "warn", @@ -1156,6 +1512,7 @@ exports[`normalize options > multi compiler client.logging should respect infras exports[`normalize options > multi compiler watchOptions is set 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1210,6 +1567,7 @@ exports[`normalize options > multi compiler watchOptions is set 1`] = ` exports[`normalize options > no options 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1264,6 +1622,7 @@ exports[`normalize options > no options 1`] = ` exports[`normalize options > port string 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1318,6 +1677,7 @@ exports[`normalize options > port string 1`] = ` exports[`normalize options > single compiler client.logging should default to infrastructureLogging.level 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "verbose", @@ -1372,6 +1732,7 @@ exports[`normalize options > single compiler client.logging should default to in exports[`normalize options > single compiler client.logging should override to infrastructureLogging.level 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "none", @@ -1426,6 +1787,7 @@ exports[`normalize options > single compiler client.logging should override to i exports[`normalize options > single compiler watchOptions is object 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1480,6 +1842,7 @@ exports[`normalize options > single compiler watchOptions is object 1`] = ` exports[`normalize options > single compiler watchOptions is object with static watch overriding it 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1535,6 +1898,7 @@ exports[`normalize options > single compiler watchOptions is object with static exports[`normalize options > single compiler watchOptions is object with static watch true 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1589,6 +1953,7 @@ exports[`normalize options > single compiler watchOptions is object with static exports[`normalize options > single compiler watchOptions is object with watch false 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1643,6 +2008,7 @@ exports[`normalize options > single compiler watchOptions is object with watch f exports[`normalize options > static is an array of static objects 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1716,6 +2082,7 @@ exports[`normalize options > static is an array of static objects 1`] = ` exports[`normalize options > static is an array of strings 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1789,6 +2156,7 @@ exports[`normalize options > static is an array of strings 1`] = ` exports[`normalize options > static is an array of strings and static objects 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1862,6 +2230,7 @@ exports[`normalize options > static is an array of strings and static objects 1` exports[`normalize options > static is an object 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1916,6 +2285,7 @@ exports[`normalize options > static is an object 1`] = ` exports[`normalize options > static is an object with staticOptions 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -1973,6 +2343,7 @@ exports[`normalize options > static is an object with staticOptions 1`] = ` exports[`normalize options > static is false 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2007,6 +2378,7 @@ exports[`normalize options > static is false 1`] = ` exports[`normalize options > static is string 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2061,6 +2433,7 @@ exports[`normalize options > static is string 1`] = ` exports[`normalize options > static is true 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2115,6 +2488,7 @@ exports[`normalize options > static is true 1`] = ` exports[`normalize options > static publicPath is a string 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2169,6 +2543,7 @@ exports[`normalize options > static publicPath is a string 1`] = ` exports[`normalize options > static publicPath is an array 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2224,6 +2599,7 @@ exports[`normalize options > static publicPath is an array 1`] = ` exports[`normalize options > static serveIndex is an object more options 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2281,6 +2657,7 @@ exports[`normalize options > static serveIndex is an object more options 1`] = ` exports[`normalize options > static serveIndex is an object with icons false 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2335,6 +2712,7 @@ exports[`normalize options > static serveIndex is an object with icons false 1`] exports[`normalize options > static serveIndex is false 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2387,6 +2765,7 @@ exports[`normalize options > static serveIndex is false 1`] = ` exports[`normalize options > static serveIndex is true 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2441,6 +2820,7 @@ exports[`normalize options > static serveIndex is true 1`] = ` exports[`normalize options > static watch is an object 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2496,6 +2876,7 @@ exports[`normalize options > static watch is an object 1`] = ` exports[`normalize options > static watch is false 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2542,6 +2923,7 @@ exports[`normalize options > static watch is false 1`] = ` exports[`normalize options > static watch is true 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2596,6 +2978,7 @@ exports[`normalize options > static watch is true 1`] = ` exports[`normalize options > username and password 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2653,6 +3036,7 @@ exports[`normalize options > username and password 1`] = ` exports[`normalize options > webSocketServer custom server class 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", @@ -2707,6 +3091,7 @@ exports[`normalize options > webSocketServer custom server class 1`] = ` exports[`normalize options > webSocketServer custom server path 1`] = ` { "allowedHosts": "auto", + "allowedOrigins": "auto", "bonjour": false, "client": { "logging": "info", diff --git a/test/__snapshots__/validate-options.test.js.snap.webpack5 b/test/__snapshots__/validate-options.test.js.snap.webpack5 index ed7af59e52..3d345f52a7 100644 --- a/test/__snapshots__/validate-options.test.js.snap.webpack5 +++ b/test/__snapshots__/validate-options.test.js.snap.webpack5 @@ -28,6 +28,66 @@ exports[`options > validate > should throw an error on the \"allowedHosts\" opti ] `; +exports[`options > validate > should throw an error on the \"allowedOrigins\" option with '123' value 1`] = ` +[ + "options.allowedOrigins", +] +`; + +exports[`options > validate > should throw an error on the \"allowedOrigins\" option with '[\"\"]' value 1`] = ` +[ + "options.allowedOrigins[0]", +] +`; + +exports[`options > validate > should throw an error on the \"allowedOrigins\" option with '[\"example.com\"]' value 1`] = ` +[ + "options.allowedOrigins[0]", +] +`; + +exports[`options > validate > should throw an error on the \"allowedOrigins\" option with '[\"https://example.com#fragment\"]' value 1`] = ` +[ + "options.allowedOrigins[0]", +] +`; + +exports[`options > validate > should throw an error on the \"allowedOrigins\" option with '[\"https://example.com/path\"]' value 1`] = ` +[ + "options.allowedOrigins[0]", +] +`; + +exports[`options > validate > should throw an error on the \"allowedOrigins\" option with '[\"https://example.com?query\"]' value 1`] = ` +[ + "options.allowedOrigins[0]", +] +`; + +exports[`options > validate > should throw an error on the \"allowedOrigins\" option with '[\"https://user@example.com\"]' value 1`] = ` +[ + "options.allowedOrigins[0]", +] +`; + +exports[`options > validate > should throw an error on the \"allowedOrigins\" option with '[\"null\"]' value 1`] = ` +[ + "options.allowedOrigins[0]", +] +`; + +exports[`options > validate > should throw an error on the \"allowedOrigins\" option with 'false' value 1`] = ` +[ + "options.allowedOrigins", +] +`; + +exports[`options > validate > should throw an error on the \"allowedOrigins\" option with 'true' value 1`] = ` +[ + "options.allowedOrigins", +] +`; + exports[`options > validate > should throw an error on the \"app\" option with 'false' value 1`] = ` [ "options.app", diff --git a/test/cli/__snapshots__/basic.test.js.snap.webpack5 b/test/cli/__snapshots__/basic.test.js.snap.webpack5 index 31fcd52943..7ac159cede 100644 --- a/test/cli/__snapshots__/basic.test.js.snap.webpack5 +++ b/test/cli/__snapshots__/basic.test.js.snap.webpack5 @@ -61,6 +61,8 @@ Options --watch-options-stdin Stop watching when stdin stream has ended. --allowed-hosts Allows to enumerate the hosts from which access to the dev server are allowed (useful when you are proxying dev server, by default is 'auto'). --allowed-hosts-reset Clear all items provided in 'allowedHosts' configuration. Allows to enumerate the hosts from which access to the dev server are allowed (useful when you are proxying dev server, by default is 'auto'). + --allowed-origins Allows additional HTTP(S) origins to access the dev server. Defaults to 'auto' (same-origin only). Use 'all' to disable origin checks independently of allowedHosts. + --allowed-origins-reset Clear all items provided in 'allowedOrigins' configuration. Allows additional HTTP(S) origins to access the dev server. Defaults to 'auto' (same-origin only). Use 'all' to disable origin checks independently of allowedHosts. --bonjour Allows to broadcasts dev server via ZeroConf networking on start. --no-bonjour Disallows to broadcasts dev server via ZeroConf networking on start. --no-client Disables client script. diff --git a/test/cli/allowedOrigins-option.test.js b/test/cli/allowedOrigins-option.test.js new file mode 100644 index 0000000000..a4e96993cf --- /dev/null +++ b/test/cli/allowedOrigins-option.test.js @@ -0,0 +1,26 @@ +import { describe, it } from "node:test"; +import { expect } from "expect"; +import { testBin } from "../helpers/test-bin.js"; +import portsMap from "../ports-map.js"; + +const port = portsMap["cli-allowed-origins"]; + +describe('"allowedOrigins" CLI option', () => { + for (const origins of [ + ["auto"], + ["all"], + ["https://example.com"], + ["http://localhost:3000", "https://example.com"], + ]) { + it(`should accept ${origins.join(", ")}`, async () => { + const { exitCode, stderr } = await testBin([ + "--port", + port, + ...origins.flatMap((origin) => ["--allowed-origins", origin]), + ]); + + expect(exitCode).toBe(0); + expect(stderr).toContain("Content not from webpack is served"); + }); + } +}); diff --git a/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 b/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 index 642cd9dd55..88bfa650dd 100644 --- a/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 @@ -46,231 +46,231 @@ exports[`allowed hosts > check host headers > should allow hosts that pass a wil [] `; -exports[`allowed hosts > check host headers > should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch) 1`] = ` +exports[`allowed hosts > check host headers > should always allow \`localhost\` if options.allowedHosts is auto 1`] = ` 200 `; -exports[`allowed hosts > check host headers > should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch) 2`] = ` +exports[`allowed hosts > check host headers > should always allow \`localhost\` if options.allowedHosts is auto 2`] = ` [] `; -exports[`allowed hosts > check host headers > should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch) 3`] = ` +exports[`allowed hosts > check host headers > should always allow \`localhost\` if options.allowedHosts is auto 3`] = ` [] `; -exports[`allowed hosts > check host headers > should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch) 1`] = ` +exports[`allowed hosts > check host headers > should always allow \`localhost\` subdomain if options.allowedHosts is auto 1`] = ` 200 `; -exports[`allowed hosts > check host headers > should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch) 2`] = ` +exports[`allowed hosts > check host headers > should always allow \`localhost\` subdomain if options.allowedHosts is auto 2`] = ` [] `; -exports[`allowed hosts > check host headers > should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch) 3`] = ` +exports[`allowed hosts > check host headers > should always allow \`localhost\` subdomain if options.allowedHosts is auto 3`] = ` [] `; -exports[`allowed hosts > check host headers > should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch) 1`] = ` +exports[`allowed hosts > check host headers > should always allow any host if options.allowedHosts is all 1`] = ` 200 `; -exports[`allowed hosts > check host headers > should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch) 2`] = ` +exports[`allowed hosts > check host headers > should always allow any host if options.allowedHosts is all 2`] = ` [] `; -exports[`allowed hosts > check host headers > should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch) 3`] = ` +exports[`allowed hosts > check host headers > should always allow any host if options.allowedHosts is all 3`] = ` [] `; -exports[`allowed hosts > check host headers > should always allow \`localhost\` if options.allowedHosts is auto 1`] = ` +exports[`allowed hosts > check host headers > should always allow value from the \`host\` options if options.allowedHosts is auto 1`] = ` 200 `; -exports[`allowed hosts > check host headers > should always allow \`localhost\` if options.allowedHosts is auto 2`] = ` +exports[`allowed hosts > check host headers > should always allow value from the \`host\` options if options.allowedHosts is auto 2`] = ` [] `; -exports[`allowed hosts > check host headers > should always allow \`localhost\` if options.allowedHosts is auto 3`] = ` +exports[`allowed hosts > check host headers > should always allow value from the \`host\` options if options.allowedHosts is auto 3`] = ` [] `; -exports[`allowed hosts > check host headers > should always allow \`localhost\` subdomain if options.allowedHosts is auto 1`] = ` +exports[`allowed hosts > check host headers > should always allow value of the \`host\` option from the \`client.webSocketURL\` option if options.allowedHosts is auto 1`] = ` 200 `; -exports[`allowed hosts > check host headers > should always allow \`localhost\` subdomain if options.allowedHosts is auto 2`] = ` +exports[`allowed hosts > check host headers > should always allow value of the \`host\` option from the \`client.webSocketURL\` option if options.allowedHosts is auto 2`] = ` [] `; -exports[`allowed hosts > check host headers > should always allow \`localhost\` subdomain if options.allowedHosts is auto 3`] = ` +exports[`allowed hosts > check host headers > should always allow value of the \`host\` option from the \`client.webSocketURL\` option if options.allowedHosts is auto 3`] = ` [] `; -exports[`allowed hosts > check host headers > should always allow any host if options.allowedHosts is all 1`] = ` +exports[`allowed hosts > check host headers > should reject cross-origin websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch) 1`] = ` 200 `; -exports[`allowed hosts > check host headers > should always allow any host if options.allowedHosts is all 2`] = ` +exports[`allowed hosts > check host headers > should reject cross-origin websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch) 2`] = ` [] `; -exports[`allowed hosts > check host headers > should always allow any host if options.allowedHosts is all 3`] = ` +exports[`allowed hosts > check host headers > should reject cross-origin websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch) 3`] = ` [] `; -exports[`allowed hosts > check host headers > should always allow value from the \`host\` options if options.allowedHosts is auto 1`] = ` +exports[`allowed hosts > check host headers > should reject cross-origin websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch) 1`] = ` 200 `; -exports[`allowed hosts > check host headers > should always allow value from the \`host\` options if options.allowedHosts is auto 2`] = ` +exports[`allowed hosts > check host headers > should reject cross-origin websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch) 2`] = ` [] `; -exports[`allowed hosts > check host headers > should always allow value from the \`host\` options if options.allowedHosts is auto 3`] = ` +exports[`allowed hosts > check host headers > should reject cross-origin websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch) 3`] = ` [] `; -exports[`allowed hosts > check host headers > should always allow value of the \`host\` option from the \`client.webSocketURL\` option if options.allowedHosts is auto 1`] = ` +exports[`allowed hosts > check host headers > should reject cross-origin websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch) 1`] = ` 200 `; -exports[`allowed hosts > check host headers > should always allow value of the \`host\` option from the \`client.webSocketURL\` option if options.allowedHosts is auto 2`] = ` +exports[`allowed hosts > check host headers > should reject cross-origin websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch) 2`] = ` [] `; -exports[`allowed hosts > check host headers > should always allow value of the \`host\` option from the \`client.webSocketURL\` option if options.allowedHosts is auto 3`] = ` +exports[`allowed hosts > check host headers > should reject cross-origin websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch) 3`] = ` [] `; -exports[`allowed hosts > should connect web socket client using \"0.0.0.0\" host to web socket server with the \"auto\" value (\"ws\") 1`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom hostname to web socket server with the \"all\" value (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using \"0.0.0.0\" host to web socket server with the \"auto\" value (\"ws\") 2`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom hostname to web socket server with the \"all\" value (\"ws\") 2`] = ` [] `; -exports[`allowed hosts > should connect web socket client using \"127.0.0.1\" host to web socket server by default (\"ws\") 1`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom hostname to web socket server with the \"all\" value in array (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using \"127.0.0.1\" host to web socket server by default (\"ws\") 2`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom hostname to web socket server with the \"all\" value in array (\"ws\") 2`] = ` [] `; -exports[`allowed hosts > should connect web socket client using \"127.0.0.1\" host to web socket server with the \"auto\" value (\"ws\") 1`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom hostname to web socket server with the custom hostname value (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using \"127.0.0.1\" host to web socket server with the \"auto\" value (\"ws\") 2`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom hostname to web socket server with the custom hostname value (\"ws\") 2`] = ` [] `; -exports[`allowed hosts > should connect web socket client using \"chrome-extension:\" protocol to web socket server with the \"auto\" value (\"ws\") 1`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom hostname to web socket server with the custom hostname value starting with dot (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using \"chrome-extension:\" protocol to web socket server with the \"auto\" value (\"ws\") 2`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom hostname to web socket server with the custom hostname value starting with dot (\"ws\") 2`] = ` [] `; -exports[`allowed hosts > should connect web socket client using \"file:\" protocol to web socket server with the \"auto\" value (\"ws\") 1`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom hostname to web socket server with the multiple custom hostname values (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using \"file:\" protocol to web socket server with the \"auto\" value (\"ws\") 2`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom hostname to web socket server with the multiple custom hostname values (\"ws\") 2`] = ` [] `; -exports[`allowed hosts > should connect web socket client using \"localhost\" host to web socket server by default (\"ws\") 1`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom sub hostname to web socket server with the custom hostname value (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using \"localhost\" host to web socket server by default (\"ws\") 2`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using custom sub hostname to web socket server with the custom hostname value (\"ws\") 2`] = ` [] `; -exports[`allowed hosts > should connect web socket client using custom hostname to web socket server with the \"all\" value (\"ws\") 1`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using origin header containing an IP address with the custom hostname value (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using custom hostname to web socket server with the \"all\" value (\"ws\") 2`] = ` +exports[`allowed hosts > should connect an explicitly allowed origin using origin header containing an IP address with the custom hostname value (\"ws\") 2`] = ` [] `; -exports[`allowed hosts > should connect web socket client using custom hostname to web socket server with the \"all\" value in array (\"ws\") 1`] = ` +exports[`allowed hosts > should connect web socket client using \"0.0.0.0\" host to web socket server with the \"auto\" value (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using custom hostname to web socket server with the \"all\" value in array (\"ws\") 2`] = ` +exports[`allowed hosts > should connect web socket client using \"0.0.0.0\" host to web socket server with the \"auto\" value (\"ws\") 2`] = ` [] `; -exports[`allowed hosts > should connect web socket client using custom hostname to web socket server with the custom hostname value (\"ws\") 1`] = ` +exports[`allowed hosts > should connect web socket client using \"127.0.0.1\" host to web socket server by default (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using custom hostname to web socket server with the custom hostname value (\"ws\") 2`] = ` +exports[`allowed hosts > should connect web socket client using \"127.0.0.1\" host to web socket server by default (\"ws\") 2`] = ` [] `; -exports[`allowed hosts > should connect web socket client using custom hostname to web socket server with the custom hostname value starting with dot (\"ws\") 1`] = ` +exports[`allowed hosts > should connect web socket client using \"127.0.0.1\" host to web socket server with the \"auto\" value (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using custom hostname to web socket server with the custom hostname value starting with dot (\"ws\") 2`] = ` +exports[`allowed hosts > should connect web socket client using \"127.0.0.1\" host to web socket server with the \"auto\" value (\"ws\") 2`] = ` [] `; -exports[`allowed hosts > should connect web socket client using custom hostname to web socket server with the multiple custom hostname values (\"ws\") 1`] = ` +exports[`allowed hosts > should connect web socket client using \"[::1] host to web socket server with the \"auto\" value (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using custom hostname to web socket server with the multiple custom hostname values (\"ws\") 2`] = ` +exports[`allowed hosts > should connect web socket client using \"[::1] host to web socket server with the \"auto\" value (\"ws\") 2`] = ` [] `; -exports[`allowed hosts > should connect web socket client using custom sub hostname to web socket server with the custom hostname value (\"ws\") 1`] = ` +exports[`allowed hosts > should connect web socket client using \"localhost\" host to web socket server by default (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", ] `; -exports[`allowed hosts > should connect web socket client using custom sub hostname to web socket server with the custom hostname value (\"ws\") 2`] = ` +exports[`allowed hosts > should connect web socket client using \"localhost\" host to web socket server by default (\"ws\") 2`] = ` [] `; @@ -285,17 +285,6 @@ exports[`allowed hosts > should connect web socket client using localhost to web [] `; -exports[`allowed hosts > should connect web socket client using origin header containing an IP address with the custom hostname value (\"ws\") 1`] = ` -[ - "Hey.", - "[webpack-dev-server] connected", -] -`; - -exports[`allowed hosts > should connect web socket client using origin header containing an IP address with the custom hostname value (\"ws\") 2`] = ` -[] -`; - exports[`allowed hosts > should disconnect web client using localhost to web socket server with the \"auto\" value (\"ws\") 1`] = ` "
Invalid Host header
" `; @@ -366,13 +355,30 @@ exports[`allowed hosts > should disconnect web socket client using custom hostna [] `; -exports[`allowed hosts > should connect web socket client using \"[::1] host to web socket server with the \"auto\" value (\"ws\") 1`] = ` +exports[`allowed hosts > should reject web socket client using \"chrome-extension:\" protocol to web socket server with the \"auto\" value (\"ws\") 1`] = ` [ "Hey.", "[webpack-dev-server] connected", + "[webpack-dev-server] Invalid Host/Origin header", + "[webpack-dev-server] Disconnected!", + "[webpack-dev-server] Trying to reconnect...", ] `; -exports[`allowed hosts > should connect web socket client using \"[::1] host to web socket server with the \"auto\" value (\"ws\") 2`] = ` +exports[`allowed hosts > should reject web socket client using \"chrome-extension:\" protocol to web socket server with the \"auto\" value (\"ws\") 2`] = ` +[] +`; + +exports[`allowed hosts > should reject web socket client using \"file:\" protocol to web socket server with the \"auto\" value (\"ws\") 1`] = ` +[ + "Hey.", + "[webpack-dev-server] connected", + "[webpack-dev-server] Invalid Host/Origin header", + "[webpack-dev-server] Disconnected!", + "[webpack-dev-server] Trying to reconnect...", +] +`; + +exports[`allowed hosts > should reject web socket client using \"file:\" protocol to web socket server with the \"auto\" value (\"ws\") 2`] = ` [] `; diff --git a/test/e2e/allowed-hosts.test.js b/test/e2e/allowed-hosts.test.js index ebd8eb9ad7..df1628cfdb 100644 --- a/test/e2e/allowed-hosts.test.js +++ b/test/e2e/allowed-hosts.test.js @@ -22,6 +22,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: [`http://${proxyHost}:${proxyPort}`], client: { webSocketURL: { port: port2, @@ -93,6 +94,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: [`http://${proxyHost}:${proxyPort}`], client: { webSocketURL: { port: port2, @@ -164,6 +166,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: [`http://${proxyHost}:${proxyPort}`], client: { webSocketURL: { port: port2, @@ -235,6 +238,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: [`http://${proxyHost}:${proxyPort}`], client: { webSocketURL: { port: port2, @@ -307,6 +311,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: [`http://[${proxyHost}]:${proxyPort}`], client: { webSocketURL: { port: port2, @@ -380,6 +385,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: [`http://${proxyHost}:${proxyPort}`], client: { webSocketURL: { port: port2, @@ -444,7 +450,7 @@ describe("allowed hosts", () => { } }); - it(`should connect web socket client using "file:" protocol to web socket server with the "auto" value ("${webSocketServer}")`, async (t) => { + it(`should reject web socket client using "file:" protocol to web socket server with the "auto" value ("${webSocketServer}")`, async (t) => { const devServerHost = "127.0.0.1"; const devServerPort = port1; const proxyHost = devServerHost; @@ -521,7 +527,7 @@ describe("allowed hosts", () => { } }); - it(`should connect web socket client using "chrome-extension:" protocol to web socket server with the "auto" value ("${webSocketServer}")`, async (t) => { + it(`should reject web socket client using "chrome-extension:" protocol to web socket server with the "auto" value ("${webSocketServer}")`, async (t) => { const devServerHost = "127.0.0.1"; const devServerPort = port1; const proxyHost = devServerHost; @@ -598,7 +604,7 @@ describe("allowed hosts", () => { } }); - it(`should connect web socket client using custom hostname to web socket server with the "all" value ("${webSocketServer}")`, async (t) => { + it(`should connect an explicitly allowed origin using custom hostname to web socket server with the "all" value ("${webSocketServer}")`, async (t) => { const devServerHost = "127.0.0.1"; const devServerPort = port1; const proxyHost = devServerHost; @@ -606,6 +612,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: ["http://my-test-origin.com"], client: { webSocketURL: { port: port2, @@ -629,7 +636,7 @@ describe("allowed hosts", () => { // Emulation on: { proxyReqWs: (proxyReq) => { - proxyReq.setHeader("origin", "http://my-test-origin.com/"); + proxyReq.setHeader("origin", "http://my-test-origin.com"); }, }, target: `http://${devServerHost}:${devServerPort}`, @@ -676,7 +683,7 @@ describe("allowed hosts", () => { } }); - it(`should connect web socket client using custom hostname to web socket server with the "all" value in array ("${webSocketServer}")`, async (t) => { + it(`should connect an explicitly allowed origin using custom hostname to web socket server with the "all" value in array ("${webSocketServer}")`, async (t) => { const devServerHost = "127.0.0.1"; const devServerPort = port1; const proxyHost = devServerHost; @@ -684,6 +691,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: ["http://my-test-origin.com"], client: { webSocketURL: { port: port2, @@ -707,7 +715,7 @@ describe("allowed hosts", () => { // Emulation on: { proxyReqWs: (proxyReq) => { - proxyReq.setHeader("origin", "http://my-test-origin.com/"); + proxyReq.setHeader("origin", "http://my-test-origin.com"); }, }, target: `http://${devServerHost}:${devServerPort}`, @@ -754,7 +762,7 @@ describe("allowed hosts", () => { } }); - it(`should connect web socket client using custom hostname to web socket server with the custom hostname value ("${webSocketServer}")`, async (t) => { + it(`should connect an explicitly allowed origin using custom hostname to web socket server with the custom hostname value ("${webSocketServer}")`, async (t) => { const devServerHost = "127.0.0.1"; const devServerPort = port1; const proxyHost = devServerHost; @@ -762,6 +770,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: ["http://my-test-origin.com"], client: { webSocketURL: { port: port2, @@ -785,7 +794,7 @@ describe("allowed hosts", () => { // Emulation on: { proxyReqWs: (proxyReq) => { - proxyReq.setHeader("origin", "http://my-test-origin.com/"); + proxyReq.setHeader("origin", "http://my-test-origin.com"); }, }, target: `http://${devServerHost}:${devServerPort}`, @@ -832,7 +841,7 @@ describe("allowed hosts", () => { } }); - it(`should connect web socket client using custom hostname to web socket server with the custom hostname value starting with dot ("${webSocketServer}")`, async (t) => { + it(`should connect an explicitly allowed origin using custom hostname to web socket server with the custom hostname value starting with dot ("${webSocketServer}")`, async (t) => { const devServerHost = "127.0.0.1"; const devServerPort = port1; const proxyHost = devServerHost; @@ -840,6 +849,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: ["http://my-test-origin.com"], client: { webSocketURL: { port: port2, @@ -863,7 +873,7 @@ describe("allowed hosts", () => { // Emulation on: { proxyReqWs: (proxyReq) => { - proxyReq.setHeader("origin", "http://my-test-origin.com/"); + proxyReq.setHeader("origin", "http://my-test-origin.com"); }, }, target: `http://${devServerHost}:${devServerPort}`, @@ -910,7 +920,7 @@ describe("allowed hosts", () => { } }); - it(`should connect web socket client using custom sub hostname to web socket server with the custom hostname value ("${webSocketServer}")`, async (t) => { + it(`should connect an explicitly allowed origin using custom sub hostname to web socket server with the custom hostname value ("${webSocketServer}")`, async (t) => { const devServerHost = "127.0.0.1"; const devServerPort = port1; const proxyHost = devServerHost; @@ -918,6 +928,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: ["http://foo.bar.baz.my-test-origin.com"], client: { webSocketURL: { port: port2, @@ -943,7 +954,7 @@ describe("allowed hosts", () => { proxyReqWs: (proxyReq) => { proxyReq.setHeader( "origin", - "http://foo.bar.baz.my-test-origin.com/", + "http://foo.bar.baz.my-test-origin.com", ); }, }, @@ -991,7 +1002,7 @@ describe("allowed hosts", () => { } }); - it(`should connect web socket client using custom hostname to web socket server with the multiple custom hostname values ("${webSocketServer}")`, async (t) => { + it(`should connect an explicitly allowed origin using custom hostname to web socket server with the multiple custom hostname values ("${webSocketServer}")`, async (t) => { const devServerHost = "127.0.0.1"; const devServerPort = port1; const proxyHost = devServerHost; @@ -999,6 +1010,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: ["http://my-test-origin.com"], client: { webSocketURL: { port: port2, @@ -1022,7 +1034,7 @@ describe("allowed hosts", () => { // Emulation on: { proxyReqWs: (proxyReq) => { - proxyReq.setHeader("origin", "http://my-test-origin.com/"); + proxyReq.setHeader("origin", "http://my-test-origin.com"); }, }, target: `http://${devServerHost}:${devServerPort}`, @@ -1069,7 +1081,7 @@ describe("allowed hosts", () => { } }); - it(`should connect web socket client using origin header containing an IP address with the custom hostname value ("${webSocketServer}")`, async (t) => { + it(`should connect an explicitly allowed origin using origin header containing an IP address with the custom hostname value ("${webSocketServer}")`, async (t) => { const devServerHost = "127.0.0.1"; const devServerPort = port1; const proxyHost = devServerHost; @@ -1077,6 +1089,7 @@ describe("allowed hosts", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: ["http://192.168.1.1"], client: { webSocketURL: { port: port2, @@ -1851,7 +1864,7 @@ describe("allowed hosts", () => { t.assert.snapshot(pageErrors); }); - it("should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch)", async (t) => { + it("should reject cross-origin websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch)", async (t) => { const options = { allowedHosts: "auto", host: "localhost", @@ -1872,16 +1885,15 @@ describe("allowed hosts", () => { pageErrors.push(error); }); - // Simulate: browser opens from localhost, but OS resolved - // 'localhost' to '127.0.0.1' so host header is the IP + // A page on localhost connects to a URL using 127.0.0.1. const headersLocalhostOriginIPv4Host = { host: "127.0.0.1", origin: "http://localhost", }; - if (!server.isSameOrigin(headersLocalhostOriginIPv4Host)) { + if (server.isSameOrigin(headersLocalhostOriginIPv4Host)) { throw new Error( - "isSameOrigin should treat localhost and 127.0.0.1 as equivalent loopback addresses", + "isSameOrigin must distinguish localhost and 127.0.0.1 origins", ); } @@ -1894,7 +1906,7 @@ describe("allowed hosts", () => { t.assert.snapshot(pageErrors); }); - it("should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch)", async (t) => { + it("should reject cross-origin websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch)", async (t) => { const options = { allowedHosts: "auto", host: "localhost", @@ -1924,9 +1936,9 @@ describe("allowed hosts", () => { origin: "http://localhost", }; - if (!server.isSameOrigin(headersLocalhostOriginIPv6Host)) { + if (server.isSameOrigin(headersLocalhostOriginIPv6Host)) { throw new Error( - "isSameOrigin should treat localhost and ::1 as equivalent loopback addresses", + "isSameOrigin must distinguish localhost and ::1 origins", ); } @@ -1939,7 +1951,7 @@ describe("allowed hosts", () => { t.assert.snapshot(pageErrors); }); - it("should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch)", async (t) => { + it("should reject cross-origin websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch)", async (t) => { const options = { allowedHosts: "auto", host: "127.0.0.1", @@ -1967,9 +1979,9 @@ describe("allowed hosts", () => { origin: "http://127.0.0.1", }; - if (!server.isSameOrigin(headersIPv4OriginLocalhostHost)) { + if (server.isSameOrigin(headersIPv4OriginLocalhostHost)) { throw new Error( - "isSameOrigin should treat 127.0.0.1 and localhost as equivalent loopback addresses", + "isSameOrigin must distinguish 127.0.0.1 and localhost origins", ); } diff --git a/test/e2e/allowed-origins.test.js b/test/e2e/allowed-origins.test.js new file mode 100644 index 0000000000..638aefb598 --- /dev/null +++ b/test/e2e/allowed-origins.test.js @@ -0,0 +1,245 @@ +import http from "node:http"; +import { after, before, describe, it } from "node:test"; +import { expect } from "expect"; +import request from "supertest"; +import webpack from "webpack"; +import WebSocket from "ws"; +import Server from "../../lib/Server.js"; +import config from "../fixtures/client-config/webpack.config.js"; +import runBrowser from "../helpers/run-browser.js"; +import portsMap from "../ports-map.js"; + +const [port, pagePort] = portsMap["allowed-origins"]; +const serverOrigin = `http://localhost:${port}`; +const trustedOrigin = `http://127.0.0.1:${pagePort}`; + +async function connect(origin, headers = {}) { + return new Promise((resolve, reject) => { + const client = new WebSocket(`ws://localhost:${port}/ws`, { + origin, + headers, + }); + + client.on("error", reject); + client.on("message", (data) => { + const message = JSON.parse(data); + + if (["error", "sync", "built"].includes(message.action)) { + client.close(); + resolve(message); + } + }); + }); +} + +// Both configurations in GHSA-v53h-4whv-q325 must preserve the HTTP and HMR +// guards, including when the attacking page is served from a raw IP address. +for (const [name, options] of [ + ["explicit localhost", { allowedHosts: ["localhost"] }], + ["all hosts", { allowedHosts: "all" }], + ["wildcard hosts", { allowedHosts: [".example.com"] }], + [ + "custom WebSocket URL", + { client: { webSocketURL: `ws://localhost:${port}/ws` } }, + ], +]) { + describe(`origin protection with ${name}`, () => { + let server; + + before(async () => { + server = new Server({ port, ...options }, webpack(config)); + await server.start(); + await new Promise((resolve) => { + server.middleware.waitUntilValid(resolve); + }); + }); + + after(async () => { + await server.stop(); + }); + + for (const site of ["cross-site", "same-site"]) { + for (const destination of ["script", "image"]) { + it(`should block ${site} no-cors ${destination} requests`, async () => { + await request(serverOrigin) + .get("/main.js") + .set("Sec-Fetch-Site", site) + .set("Sec-Fetch-Mode", "no-cors") + .set("Sec-Fetch-Dest", destination) + .expect(403); + }); + } + } + + it("should retain CORP when Fetch Metadata is absent", async () => { + await request(serverOrigin) + .get("/main.js") + .expect("Cross-Origin-Resource-Policy", "same-origin") + .expect(200); + }); + + it("should reject cross-origin HTTP and HMR requests from an IP address", async () => { + await request(serverOrigin) + .get("/main.js") + .set("Origin", "http://192.168.1.2") + .expect(403); + + expect(await connect("http://192.168.1.2")).toEqual({ + action: "error", + message: "Invalid Host/Origin header", + }); + }); + + it("should keep same-origin HTTP and HMR working", async () => { + await request(serverOrigin) + .get("/main.js") + .set("Origin", serverOrigin) + .expect(200); + + expect((await connect(serverOrigin)).action).toBe("sync"); + }); + + it("should keep state-changing endpoints protected", async () => { + await request(serverOrigin) + .get("/webpack-dev-server/invalidate") + .set("Origin", "http://192.168.1.2") + .expect(403); + }); + + if (name === "explicit localhost" || name === "custom WebSocket URL") { + it("should block an untrusted page in a real browser", async () => { + const pageServer = http.createServer((_req, res) => { + res.setHeader("Content-Type", "text/html"); + res.end(``); + }); + await new Promise((resolve) => { + pageServer.listen(pagePort, "127.0.0.1", resolve); + }); + const { page, browser } = await runBrowser(); + + try { + const scriptResponse = page.waitForResponse( + `${serverOrigin}/main.js`, + ); + await page.goto(trustedOrigin); + expect((await scriptResponse).status()).toBe(403); + + const action = await page.evaluate( + (url) => + new Promise((resolve, reject) => { + const client = new WebSocket(url); + client.onerror = reject; + client.onmessage = (event) => { + const message = JSON.parse(event.data); + client.close(); + resolve(message.action); + }; + }), + `ws://localhost:${port}/ws`, + ); + + expect(action).toBe("error"); + } finally { + await browser.close(); + await new Promise((resolve) => { + pageServer.close(resolve); + }); + } + }); + } + }); +} + +describe("explicit allowedOrigins", () => { + let server; + + before(async () => { + server = new Server( + { port, allowedOrigins: [trustedOrigin] }, + webpack(config), + ); + await server.start(); + await new Promise((resolve) => { + server.middleware.waitUntilValid(resolve); + }); + }); + + after(async () => { + await server.stop(); + }); + + it("should authorize HTTP and HMR and emit CORS headers", async () => { + await request(serverOrigin) + .get("/main.js") + .set("Origin", trustedOrigin) + .expect("Access-Control-Allow-Origin", trustedOrigin) + .expect("Vary", /Origin/) + .expect(200); + + expect((await connect(trustedOrigin)).action).toBe("sync"); + }); + + it("should reject an unlisted origin and an invalid Host", async () => { + await request(serverOrigin) + .get("/main.js") + .set("Origin", "http://192.168.1.2") + .expect(403); + await request(serverOrigin) + .get("/main.js") + .set("Host", "evil.example.com") + .set("Origin", trustedOrigin) + .expect(403); + expect( + (await connect(trustedOrigin, { host: "evil.example.com" })).action, + ).toBe("error"); + }); + + it("should not authorize a no-cors request without an Origin", async () => { + await request(serverOrigin) + .get("/main.js") + .set("Sec-Fetch-Site", "cross-site") + .set("Sec-Fetch-Mode", "no-cors") + .set("Referer", `${trustedOrigin}/`) + .expect(403); + }); + + it("should allow the configured page to load a CORS script and connect HMR", async () => { + const pageServer = http.createServer((_req, res) => { + res.setHeader("Content-Type", "text/html"); + res.end(``); + }); + await new Promise((resolve) => { + pageServer.listen(pagePort, "127.0.0.1", resolve); + }); + const { page, browser } = await runBrowser(); + + try { + const scriptResponse = page.waitForResponse(`${serverOrigin}/main.js`); + await page.goto(trustedOrigin); + expect((await scriptResponse).status()).toBe(200); + + const action = await page.evaluate( + (url) => + new Promise((resolve, reject) => { + const client = new WebSocket(url); + client.onerror = reject; + client.onmessage = (event) => { + const message = JSON.parse(event.data); + if (["error", "sync", "built"].includes(message.action)) { + client.close(); + resolve(message.action); + } + }; + }), + `ws://localhost:${port}/ws`, + ); + + expect(action).toBe("sync"); + } finally { + await browser.close(); + await new Promise((resolve) => { + pageServer.close(resolve); + }); + } + }); +}); diff --git a/test/e2e/cross-origin-request.test.js b/test/e2e/cross-origin-request.test.js index baf058a456..ce1d214a45 100644 --- a/test/e2e/cross-origin-request.test.js +++ b/test/e2e/cross-origin-request.test.js @@ -69,6 +69,7 @@ describe("cross-origin requests", () => { const devServerOptions = { port: devServerPort, allowedHosts: "auto", + allowedOrigins: [`http://${htmlServerHost}:${htmlServerPort}`], headers: { "Access-Control-Allow-Origin": "*", }, @@ -117,11 +118,12 @@ describe("cross-origin requests", () => { } }); - it("should return 200 for cross-origin no-cors non-module script tag requests with the 'allowedHost' option and 'all' value", async () => { + it("should return 200 for cross-origin no-cors non-module script tag requests with the 'allowedOrigins' option and 'all' value", async () => { const compiler = webpack(config); const devServerOptions = { port: devServerPort, allowedHosts: "all", + allowedOrigins: "all", }; const server = new Server(devServerOptions, compiler); @@ -167,7 +169,7 @@ describe("cross-origin requests", () => { } }); - it("should return 200 for cross-origin no-cors non-module script tag requests with the `allowedHost` option and the `localhost` value", async () => { + it("should return 403 for cross-origin no-cors script requests even when localhost is in allowedHosts", async () => { const compiler = webpack(config); const devServerOptions = { port: devServerPort, @@ -209,7 +211,7 @@ describe("cross-origin requests", () => { const response = await scriptTagRequest; - expect(response.status()).toBe(200); + expect(response.status()).toBe(403); } finally { await browser.close(); await server.stop(); @@ -264,7 +266,7 @@ describe("cross-origin resource policy header", () => { expect(res.headers["cross-origin-resource-policy"]).toBe("same-origin"); }); - it("should NOT set CORP header when allowedHosts is 'all'", async () => { + it("should set CORP header even when allowedHosts is 'all'", async () => { const compiler = webpack(config); server = new Server({ port: devServerPort, allowedHosts: "all" }, compiler); @@ -272,10 +274,23 @@ describe("cross-origin resource policy header", () => { const res = await request(`http://localhost:${devServerPort}/main.js`); + expect(res.headers["cross-origin-resource-policy"]).toBe("same-origin"); + }); + + it("should omit CORP only when allowedOrigins is all", async () => { + server = new Server( + { port: devServerPort, allowedOrigins: "all" }, + webpack(config), + ); + await server.start(); + + const res = await request(`http://localhost:${devServerPort}/main.js`); + expect(res.headers["cross-origin-resource-policy"]).toBeUndefined(); + expect(res.headers["access-control-allow-origin"]).toBe("*"); }); - it("should NOT set CORP header when user configures wildcard CORS", async () => { + it("should set CORP header even when user configures wildcard CORS", async () => { const compiler = webpack(config); server = new Server( { @@ -290,7 +305,7 @@ describe("cross-origin resource policy header", () => { const res = await request(`http://localhost:${devServerPort}/main.js`); - expect(res.headers["cross-origin-resource-policy"]).toBeUndefined(); + expect(res.headers["cross-origin-resource-policy"]).toBe("same-origin"); }); it("should set CORP header when user configures a specific-origin Access-Control-Allow-Origin (no-cors embedding is not governed by CORS)", async () => { @@ -336,7 +351,7 @@ describe("cross-origin resource policy header", () => { expect(res.headers["cross-origin-resource-policy"]).toBe("same-origin"); }); - it("should NOT set CORP header when user configures wildcard Access-Control-Allow-Origin via headers array", async () => { + it("should set CORP header even when user configures wildcard Access-Control-Allow-Origin via headers array", async () => { const compiler = webpack(config); server = new Server( { @@ -351,10 +366,10 @@ describe("cross-origin resource policy header", () => { const res = await request(`http://localhost:${devServerPort}/main.js`); - expect(res.headers["cross-origin-resource-policy"]).toBeUndefined(); + expect(res.headers["cross-origin-resource-policy"]).toBe("same-origin"); }); - it("should NOT set CORP header when host is in allowedHosts", async () => { + it("should set CORP header even when host is in allowedHosts", async () => { const compiler = webpack(config); server = new Server( { port: devServerPort, allowedHosts: ["localhost"] }, @@ -366,7 +381,7 @@ describe("cross-origin resource policy header", () => { const res = await request(`http://localhost:${devServerPort}/main.js`); expect(res.status).toBe(200); - expect(res.headers["cross-origin-resource-policy"]).toBeUndefined(); + expect(res.headers["cross-origin-resource-policy"]).toBe("same-origin"); }); }); diff --git a/test/e2e/event-source.test.js b/test/e2e/event-source.test.js index 674a55f10f..8fa2c48ecc 100644 --- a/test/e2e/event-source.test.js +++ b/test/e2e/event-source.test.js @@ -255,7 +255,7 @@ describe("Server-Sent Events transport", () => { }); }); - const { frames } = await readStream( + const { status, frames } = await readStream( rejectPort, { accept: "text/event-stream", @@ -265,19 +265,44 @@ describe("Server-Sent Events transport", () => { 1, ); - // The middleware serves the stream, but who may read it is still this - // package's to say — it is handed the request the client connected with - // and closes the ones it does not want. - expect(JSON.parse(frames[0])).toEqual({ - action: "error", - message: "Invalid Host/Origin header", - }); + // HTTP origin validation rejects the request before the stream opens. + expect(status).toBe(403); + expect(frames).toEqual([]); await waitFor(() => clients.size === 0); expect([...clients]).toHaveLength(0); }); + it("allows a configured origin to receive the event stream", async () => { + server = new Server( + { + port, + client: { webSocketTransport: "sse" }, + allowedOrigins: ["https://app.example.com:3000"], + }, + webpack(reloadConfig), + ); + + await server.start(); + await new Promise((resolve) => { + server.middleware.waitUntilValid(resolve); + }); + + const { status, frames } = await readStream( + port, + { + accept: "text/event-stream", + origin: "https://app.example.com:3000", + }, + (received) => + received.some((frame) => JSON.parse(frame).action === "sync"), + ); + + expect(status).toBe(200); + expect(frames.map((frame) => JSON.parse(frame).action)).toContain("sync"); + }); + it("takes a stream that carries no origin as one of its own", async () => { const compiler = webpack(reloadConfig); diff --git a/test/e2e/options-middleware.test.js b/test/e2e/options-middleware.test.js index ebfa2a0866..5783bb940b 100644 --- a/test/e2e/options-middleware.test.js +++ b/test/e2e/options-middleware.test.js @@ -56,8 +56,8 @@ describe("handle options-request correctly", () => { const server = new Server( { port: portForServer, + allowedOrigins: [`http://localhost:${portForApp}`], headers: { - "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*", }, }, diff --git a/test/e2e/proxy-web-socket-origin.test.js b/test/e2e/proxy-web-socket-origin.test.js index 221467c3a5..6d4cbe3a65 100644 --- a/test/e2e/proxy-web-socket-origin.test.js +++ b/test/e2e/proxy-web-socket-origin.test.js @@ -135,6 +135,43 @@ describe("proxied WebSocket upgrade origin validation", () => { expect(backendConnections).toBe(1); }); + for (const options of [ + { allowedHosts: ["localhost"] }, + { allowedHosts: "all" }, + { client: { webSocketURL: { hostname: "localhost" } } }, + ]) { + // eslint-disable-next-line no-loop-func + it(`should reject an IP origin with ${JSON.stringify(options)}, even after an HTTP request`, async () => { + Object.assign(server.options, options); + + await request(server.server).get("/api/hello").expect(200); + + const result = await upgrade("http://192.168.1.2"); + + expect(result.status).toBe(403); + await setTimeout(100); + expect(backendConnections).toBe(0); + }); + } + + it("should allow a configured origin without allowing it as a Host", async () => { + server.options.allowedOrigins = ["https://app.example.com:3000"]; + + const result = await upgrade("https://app.example.com:3000"); + + expect(result.status).toBe(101); + expect(backendConnections).toBe(1); + }); + + it("should reject a configured origin on another port", async () => { + server.options.allowedOrigins = ["https://app.example.com:3000"]; + + const result = await upgrade("https://app.example.com:3001"); + + expect(result.status).toBe(403); + expect(backendConnections).toBe(0); + }); + it("should not forward a rejected upgrade after an HTTP proxy request", async () => { await request(server.server).get("/api/hello").expect(200); diff --git a/test/e2e/web-socket-server-url.test.js b/test/e2e/web-socket-server-url.test.js index e2adc837ff..5cf4b0c494 100644 --- a/test/e2e/web-socket-server-url.test.js +++ b/test/e2e/web-socket-server-url.test.js @@ -25,6 +25,7 @@ describe("web socket server URL", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: [`http://${proxyHost}:${proxyPort}`], webSocketServer, port: devServerPort, host: devServerHost, @@ -110,6 +111,7 @@ describe("web socket server URL", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: [`http://${proxyHost}:${proxyPort}`], webSocketServer, port: devServerPort, host: devServerHost, @@ -196,6 +198,7 @@ describe("web socket server URL", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: [`http://${proxyHost}:${proxyPort}`], client: { webSocketURL: { hostname: devServerHost, @@ -287,6 +290,7 @@ describe("web socket server URL", () => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: [`http://${proxyHost}:${proxyPort}`], webSocketServer, port: "auto", host: "local-ip", @@ -822,6 +826,7 @@ describe("web socket server URL", () => { it(`should work with "client.webSocketURL.port" and "webSocketServer.options.port" options as string ("${webSocketServer}")`, async (t) => { const compiler = webpack(config); const devServerOptions = { + allowedOrigins: [`http://127.0.0.1:${port1}`], webSocketServer: { type: webSocketServer, options: { diff --git a/test/normalize-options.test.js b/test/normalize-options.test.js index a5591ab139..c0b8530049 100644 --- a/test/normalize-options.test.js +++ b/test/normalize-options.test.js @@ -565,6 +565,24 @@ describe("normalize options", () => { allowedHosts: "all", }, }, + ...[ + ["auto", "auto"], + ["auto from CLI", ["auto"]], + ["all from CLI", ["all"]], + ["empty list", []], + ["string", "https://EXAMPLE.com:443/"], + ["array", ["http://localhost:3000", "https://[::1]:443"]], + ].map(([title, allowedOrigins]) => ({ + title: `allowedOrigins ${title}`, + multiCompiler: false, + options: { allowedOrigins }, + })), + { + title: "invalid origin port", + multiCompiler: false, + options: { allowedOrigins: ["https://example.com:99999"] }, + throws: /Invalid URL/, + }, { title: "allowedHosts is array", multiCompiler: false, diff --git a/test/ports-map.js b/test/ports-map.js index 3a9ba080e6..4f149be5a4 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -87,6 +87,8 @@ const listOfTests = { "universal-target": 1, "proxy-web-socket-origin": 2, "host-header-validation": 1, + "allowed-origins": 2, + "cli-allowed-origins": 1, }; let startPort = 8089; diff --git a/test/schema-check.test.js b/test/schema-check.test.js index ad583789c6..645aec660b 100644 --- a/test/schema-check.test.js +++ b/test/schema-check.test.js @@ -38,6 +38,14 @@ describe("precompiled options validator", () => { { watchFiles: { paths: "src/**", options: { usePolling: true } } }, ], ["allowedHosts as an array", { allowedHosts: ["example.com"] }], + [ + "allowedOrigins as an array", + { allowedOrigins: ["https://example.com:3000"] }, + ], + ["allowedOrigins as a string", { allowedOrigins: "https://example.com" }], + ["allowedOrigins default", { allowedOrigins: "auto" }], + ["allowedOrigins opt out", { allowedOrigins: "all" }], + ["allowedOrigins empty list", { allowedOrigins: [] }], ]; for (const [name, options] of valid) { @@ -55,6 +63,13 @@ describe("precompiled options validator", () => { ["an empty string in an array", { allowedHosts: [""] }], ["a non-function where a function is required", { setupMiddlewares: "x" }], ["null", { port: null }], + ["a bare origin hostname", { allowedOrigins: ["example.com"] }], + ["an opaque origin", { allowedOrigins: ["null"] }], + [ + "an origin with credentials", + { allowedOrigins: ["https://user@example.com"] }, + ], + ["an origin with a path", { allowedOrigins: ["https://example.com/path"] }], ]; for (const [name, options] of invalid) { diff --git a/test/server/allowed-origins.test.js b/test/server/allowed-origins.test.js new file mode 100644 index 0000000000..0a4ae30b93 --- /dev/null +++ b/test/server/allowed-origins.test.js @@ -0,0 +1,136 @@ +import { describe, it } from "node:test"; +import { expect } from "expect"; +import Server from "../../lib/Server.js"; + +describe("origin authorization", () => { + for (const options of [ + {}, + { allowedHosts: "auto" }, + { allowedHosts: "all" }, + { allowedHosts: ["localhost", "192.168.1.2", ".example.com"] }, + { client: { webSocketURL: { hostname: "localhost" } } }, + ]) { + it(`should enforce origin checks with ${JSON.stringify(options)}`, () => { + const server = new Server(options); + const headers = { host: "localhost:8080" }; + + expect( + server.isOriginAllowed({ ...headers, origin: "http://localhost:8080" }), + ).toBe(true); + + for (const origin of [ + "http://192.168.1.2", + "http://evil.example.com", + "http://localhost:8081", + "https://localhost:8080", + "http://127.0.0.1:8080", + "http://[::1]:8080", + "null", + "file:///tmp/index.html", + "chrome-extension://example", + "http://localhost:8080/path", + "http://localhost:8080?query", + "http://localhost:8080#fragment", + "http://user@localhost:8080", + "http://localhost:8080 http://evil.example.com", + "http://localhost:8080\\evil", + "http://localhost:8080/", + "", + undefined, + ]) { + expect(server.isOriginAllowed({ ...headers, origin })).toBe(false); + } + }); + } + + it("should allow an explicit origin without adding it to allowedHosts", () => { + const server = new Server({ + allowedHosts: ["dev.example.com"], + allowedOrigins: ["https://app.example.com:3000"], + }); + + expect( + server.isOriginAllowed({ + host: "dev.example.com:8080", + origin: "https://app.example.com:3000", + }), + ).toBe(true); + expect(server.isValidHost({ host: "app.example.com:3000" }, "host")).toBe( + false, + ); + + for (const origin of [ + "http://app.example.com:3000", + "https://app.example.com:3001", + "https://app.example.com.evil.test:3000", + "https://sub.app.example.com:3000", + ]) { + expect( + server.isOriginAllowed({ host: "dev.example.com:8080", origin }), + ).toBe(false); + } + }); + + it("should compare HTTPS and HTTP/2 origins and normalize default ports", () => { + for (const type of ["https", "http2"]) { + const server = new Server({ server: { type } }); + server.isTlsServer = true; + + expect( + server.isOriginAllowed({ + host: "localhost:443", + origin: "https://localhost", + }), + ).toBe(true); + expect( + server.isOriginAllowed({ + host: "localhost:443", + origin: "http://localhost:443", + }), + ).toBe(false); + expect( + server.isOriginAllowed({ + ":authority": "[::1]:443", + origin: "https://[::1]", + }), + ).toBe(true); + } + + expect( + new Server({}).isOriginAllowed({ + host: "localhost:80", + origin: "http://localhost", + }), + ).toBe(true); + }); + + it("should not trust forwarded headers", () => { + const server = new Server({}); + + expect( + server.isOriginAllowed({ + host: "localhost:8080", + origin: "https://evil.example.com", + "x-forwarded-host": "evil.example.com", + "x-forwarded-proto": "https", + }), + ).toBe(false); + }); + + it("should disable only origin checks with allowedOrigins all", () => { + const server = new Server({ allowedOrigins: "all" }); + + expect( + server.isOriginAllowed({ + host: "localhost", + origin: "https://example.com", + }), + ).toBe(true); + expect(server.isValidHost({ host: "evil.example.com" }, "host")).toBe( + false, + ); + expect( + server.isSameOrigin({ host: "localhost", origin: "https://example.com" }), + ).toBe(false); + }); +}); diff --git a/test/server/proxy-option.test.js b/test/server/proxy-option.test.js index 8ef40f1495..c5f005bbe7 100644 --- a/test/server/proxy-option.test.js +++ b/test/server/proxy-option.test.js @@ -712,7 +712,9 @@ describe("proxy option", () => { it("delivers the HMR control messages and never reaches the proxy target", async () => { const messages = []; - const ws = new WebSocket(`ws://localhost:${port3}/ws`); + const ws = new WebSocket(`ws://localhost:${port3}/ws`, { + origin: `http://localhost:${port3}`, + }); await new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -802,7 +804,9 @@ describe("proxy option", () => { it("does not surface any [HPM] error when the HMR client connects", async () => { const messages = []; - const ws = new WebSocket(`ws://localhost:${port3}/ws`); + const ws = new WebSocket(`ws://localhost:${port3}/ws`, { + origin: `http://localhost:${port3}`, + }); await new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -908,7 +912,9 @@ describe("proxy option", () => { const probe = async (path) => { const before = backendUpgradeCount; - const ws = new WebSocket(`ws://localhost:${port3}${path}`); + const ws = new WebSocket(`ws://localhost:${port3}${path}`, { + origin: `http://localhost:${port3}`, + }); // Resolve as soon as the socket reaches a terminal state instead of // waiting a fixed delay: `open` means the handshake completed, `error` diff --git a/test/validate-options.test.js b/test/validate-options.test.js index 2f0c05b0c8..0b101d6813 100644 --- a/test/validate-options.test.js +++ b/test/validate-options.test.js @@ -174,6 +174,28 @@ const tests = { success: ["auto", "all", ["foo"], "bar"], failure: [true, false, 123, [], [""]], }, + allowedOrigins: { + success: [ + "auto", + "all", + [], + ["all"], + "https://example.com", + ["http://localhost:3000", "https://[::1]:8080"], + ], + failure: [ + true, + false, + 123, + [""], + ["example.com"], + ["null"], + ["https://example.com/path"], + ["https://user@example.com"], + ["https://example.com?query"], + ["https://example.com#fragment"], + ], + }, headers: { success: [{}, { foo: "bar" }, () => {}, [{ key: "foo", value: "bar" }]], failure: [false, 1, [], [{ foo: "bar" }]], diff --git a/types/lib/Server.d.ts b/types/lib/Server.d.ts index f49aefe5fe..bab5a3f1a3 100644 --- a/types/lib/Server.d.ts +++ b/types/lib/Server.d.ts @@ -308,6 +308,7 @@ export type Configuration< devMiddleware?: DevMiddlewareOptions | undefined; compress?: boolean | undefined; allowedHosts?: ("auto" | "all" | string | string[]) | undefined; + allowedOrigins?: ("auto" | "all" | string | string[]) | undefined; historyApiFallback?: (boolean | ConnectHistoryApiFallbackOptions) | undefined; bonjour?: (boolean | Record | BonjourOptions) | undefined; watchFiles?: @@ -393,6 +394,37 @@ declare class Server< type: string; minLength: number; }; + AllowedOrigins: { + anyOf: ( + | { + type: string; + items: { + $ref: string; + }; + $ref?: undefined; + } + | { + $ref: string; + type?: undefined; + items?: undefined; + } + )[]; + description: string; + }; + AllowedOriginsItem: { + anyOf: ( + | { + enum: string[]; + type?: undefined; + pattern?: undefined; + } + | { + type: string; + pattern: string; + enum?: undefined; + } + )[]; + }; Bonjour: { anyOf: ( | { @@ -1394,6 +1426,9 @@ declare class Server< allowedHosts: { $ref: string; }; + allowedOrigins: { + $ref: string; + }; bonjour: { $ref: string; }; @@ -1724,8 +1759,7 @@ declare class Server< * * The middleware hands over each connection and the request it joined with, * and applies no policy of its own. This is that policy: the same - * `allowedHosts`, `Origin` and same-origin rules this server has always - * applied, in the same order, before anything is published to a client. + * host and origin rules, before anything is published to a client. * @private * @returns {void} */ @@ -1817,24 +1851,23 @@ declare class Server< * @returns {boolean} true when host allowed, otherwise false */ private isHostAllowed; - /** - * @private - * @returns {boolean} true when the user has configured a wildcard - * Access-Control-Allow-Origin header (opting into fully open cross-origin access) - */ - private isUserCORSWildcardEnabled; /** * @private * @param {{ [key: string]: string | undefined }} headers headers * @param {string} headerToCheck header to check - * @param {boolean} validateHost need to validate host * @returns {boolean} true when host is valid, otherwise false */ private isValidHost; /** * @private * @param {{ [key: string]: string | undefined }} headers headers - * @returns {boolean} true when is same origin, otherwise false + * @returns {boolean} true when the request origin is allowed + */ + private isOriginAllowed; + /** + * @private + * @param {{ [key: string]: string | undefined }} headers headers + * @returns {boolean} true when scheme, hostname and port match */ private isSameOrigin; /** From 8afe3c75859a8f06417e3bf43734080f2ad71287 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 17 Oct 2026 13:52:08 -0500 Subject: [PATCH 02/13] test: clarify origin configuration coverage --- test/e2e/allowed-origins.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/allowed-origins.test.js b/test/e2e/allowed-origins.test.js index 638aefb598..70fda40fa4 100644 --- a/test/e2e/allowed-origins.test.js +++ b/test/e2e/allowed-origins.test.js @@ -32,8 +32,8 @@ async function connect(origin, headers = {}) { }); } -// Both configurations in GHSA-v53h-4whv-q325 must preserve the HTTP and HMR -// guards, including when the attacking page is served from a raw IP address. +// Host and WebSocket URL configuration must preserve HTTP and HMR origin checks, +// including when a cross-origin page is served from an IP address. for (const [name, options] of [ ["explicit localhost", { allowedHosts: ["localhost"] }], ["all hosts", { allowedHosts: "all" }], From 4a2ed6d3fb0bbe80f28b8a3d411e29ff2cbdb8e3 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 17 Oct 2026 14:06:03 -0500 Subject: [PATCH 03/13] fix: support HMR on a separate port --- .../independent-origin-authorization.md | 4 + examples/allowed-origins/README.md | 14 ++++ examples/allowed-origins/webpack.config.js | 3 + lib/Server.js | 36 +++++++- test/e2e/allowed-origins.test.js | 84 ++++++++++++++++++- test/e2e/proxy-web-socket-origin.test.js | 12 ++- test/e2e/web-socket-server-url.test.js | 1 - 7 files changed, 149 insertions(+), 5 deletions(-) diff --git a/.changeset/independent-origin-authorization.md b/.changeset/independent-origin-authorization.md index e7a38ad04a..73cdae23b0 100644 --- a/.changeset/independent-origin-authorization.md +++ b/.changeset/independent-origin-authorization.md @@ -5,3 +5,7 @@ Add `allowedOrigins` to authorize cross-origin HTTP requests, HMR connections, and WebSocket proxies independently of `allowedHosts` and `client.webSocketURL`. Host configuration no longer disables origin protection, including when `allowedHosts` is `"all"`. Cross-origin frontends must now list their full HTTP(S) origin in `allowedOrigins`. Origins include the protocol and port; loopback hostnames are no longer interchangeable. Use CORS (`crossorigin="anonymous"` on script and image elements) so the server can identify the authorized page. The server supplies CORS headers for authorized origins, while Fetch Metadata and resource-policy checks continue to protect requests without `Origin`. Configuring `headers` alone no longer grants cross-origin access. `allowedOrigins: "all"` explicitly opts out of origin checks without disabling host validation. + +Set `output.crossOriginLoading: "anonymous"` for webpack-generated lazy chunks and hot-update scripts loaded across origins, and ensure `output.publicPath` resolves to the dev server. The initial script's `crossorigin` attribute does not configure those generated scripts. + +HMR on a separate port also accepts the origin of this dev server's HTTP(S) listener on the same hostname, using the actual bound port even with `port: 0`. This exception applies only to HMR; reverse proxies that change the public origin must still configure `allowedOrigins`. diff --git a/examples/allowed-origins/README.md b/examples/allowed-origins/README.md index cc74c408db..f446b61aec 100644 --- a/examples/allowed-origins/README.md +++ b/examples/allowed-origins/README.md @@ -7,6 +7,9 @@ authorize another origin. ```js export default { + output: { + crossOriginLoading: "anonymous", + }, devServer: { allowedHosts: ["localhost"], allowedOrigins: ["http://localhost:3000"], @@ -53,6 +56,12 @@ The CLI accepts repeated flags: npx webpack serve --allowed-origins http://localhost:3000 --allowed-origins https://app.example.com ``` +When HMR listens on a separate port, it also accepts the page served by this +dev server's HTTP(S) listener on the same hostname. This uses the actual bound +port, including with `port: 0`. It applies only to HMR connections; HTTP resources, +WebSocket proxies, and state-changing endpoints keep their existing origin rules. +Reverse proxies that change the public origin still need `allowedOrigins`. + ## Migrating cross-origin setups Previously, an entry in `allowedHosts` or a custom `client.webSocketURL` could @@ -71,6 +80,11 @@ the browser sends `Origin` and applies CORS: ``` +Webpack-generated lazy chunks and hot-update scripts also need +`output.crossOriginLoading: "anonymous"` so that the browser sends `Origin`. +Adding `crossorigin` to the initial script does not configure these generated +scripts. Their `output.publicPath` must also resolve to the dev server. + A cross-origin `no-cors` request without `Origin` cannot be matched to an allowed page, even if it sends a referrer header. Fetch Metadata checks and `Cross-Origin-Resource-Policy: same-origin` continue to protect those requests. diff --git a/examples/allowed-origins/webpack.config.js b/examples/allowed-origins/webpack.config.js index 4e5a6ecde3..99b092fab7 100644 --- a/examples/allowed-origins/webpack.config.js +++ b/examples/allowed-origins/webpack.config.js @@ -3,6 +3,9 @@ import { setup } from "../util.js"; export default setup( { entry: "./app.js", + output: { + crossOriginLoading: "anonymous", + }, devServer: { allowedHosts: ["localhost"], allowedOrigins: ["http://localhost:3000"], diff --git a/lib/Server.js b/lib/Server.js index 1d0e89bdec..de6a73dbe0 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -2765,7 +2765,7 @@ class Server { if ( !headers || !this.isValidHost(headers, "host") || - (!isSameOriginStream && !this.isOriginAllowed(headers)) + (!isSameOriginStream && !this.#isHotOriginAllowed(headers)) ) { // Said to the client before it is closed, so the reason reaches the // page rather than only this server's log — a refusal that closes @@ -3497,6 +3497,40 @@ class Server { ); } + /** + * Authorize HMR from a page served by this server when the hot endpoint + * listens on a separate port, in addition to explicitly allowed origins. + * @param {{ [key: string]: string | undefined }} headers connection headers + * @returns {boolean} true when the page may connect to HMR + */ + #isHotOriginAllowed(headers) { + if (this.isOriginAllowed(headers)) { + return true; + } + + const origin = this.#parseOrigin(headers.origin); + const host = headers[":authority"] || headers.host; + const address = this.server?.address(); + + if (origin === null || !host || !address || typeof address === "string") { + return false; + } + + const protocol = this.isTlsServer ? "https" : "http"; + const hostOrigin = this.#parseOrigin(`${protocol}://${host}`); + + if (hostOrigin === null) { + return false; + } + + // Keep the request hostname and the HTTP listener's protocol. Its bound + // port also handles port: 0; proxy origins still require allowedOrigins. + const httpOrigin = new URL(hostOrigin); + httpOrigin.port = String(address.port); + + return origin === httpOrigin.origin; + } + /** * @param {string | undefined} value Origin header value * @returns {string | null} serialized HTTP(S) origin, or null if invalid diff --git a/test/e2e/allowed-origins.test.js b/test/e2e/allowed-origins.test.js index 70fda40fa4..fec1dba39d 100644 --- a/test/e2e/allowed-origins.test.js +++ b/test/e2e/allowed-origins.test.js @@ -13,9 +13,9 @@ const [port, pagePort] = portsMap["allowed-origins"]; const serverOrigin = `http://localhost:${port}`; const trustedOrigin = `http://127.0.0.1:${pagePort}`; -async function connect(origin, headers = {}) { +async function connect(origin, headers = {}, socketPort = port) { return new Promise((resolve, reject) => { - const client = new WebSocket(`ws://localhost:${port}/ws`, { + const client = new WebSocket(`ws://localhost:${socketPort}/ws`, { origin, headers, }); @@ -243,3 +243,83 @@ describe("explicit allowedOrigins", () => { } }); }); + +for (const listenPort of [port, `${port}`, "auto", 0]) { + describe(`HMR on a separate port with HTTP port ${JSON.stringify(listenPort)}`, () => { + let server; + let httpOrigin; + + before(async () => { + server = new Server( + { + host: "localhost", + port: listenPort, + webSocketServer: { + type: "ws", + options: { host: "localhost", port: pagePort }, + }, + }, + webpack(config), + ); + await server.start(); + await new Promise((resolve) => { + server.middleware.waitUntilValid(resolve); + }); + httpOrigin = `http://localhost:${server.server.address().port}`; + }); + + after(async () => { + await server.stop(); + }); + + it("should allow the bound HTTP origin without allowedOrigins", async () => { + expect((await connect(httpOrigin, {}, pagePort)).action).toBe("sync"); + }); + + it("should reject other hosts, protocols, and ports", async () => { + for (const origin of [ + httpOrigin.replace("localhost", "127.0.0.1"), + httpOrigin.replace("http:", "https:"), + "http://localhost:1", + "http://localhost:0", + "http://192.168.1.2", + undefined, + ]) { + expect((await connect(origin, {}, pagePort)).action).toBe("error"); + } + }); + + it("should still validate the HMR request Host", async () => { + expect( + ( + await connect( + httpOrigin.replace("localhost", "evil.example.com"), + { host: `evil.example.com:${pagePort}` }, + pagePort, + ) + ).action, + ).toBe("error"); + }); + + it("should keep HTTP and state-changing endpoints strict about ports", async () => { + for (const endpoint of [ + "/main.js", + "/webpack-dev-server/invalidate", + "/webpack-dev-server/open-editor", + ]) { + await request(httpOrigin) + .get(endpoint) + .set("Host", `localhost:${pagePort}`) + .set("Origin", httpOrigin) + .expect(403); + } + + expect( + server.isSameOrigin({ + host: `localhost:${pagePort}`, + origin: httpOrigin, + }), + ).toBe(false); + }); + }); +} diff --git a/test/e2e/proxy-web-socket-origin.test.js b/test/e2e/proxy-web-socket-origin.test.js index 6d4cbe3a65..63be121faa 100644 --- a/test/e2e/proxy-web-socket-origin.test.js +++ b/test/e2e/proxy-web-socket-origin.test.js @@ -84,7 +84,7 @@ describe("proxied WebSocket upgrade origin validation", () => { fs.rmSync(tmp, { recursive: true, force: true }); }); - function upgrade(origin) { + function upgrade(origin, headers = {}) { return new Promise((resolve) => { const req = http.request({ host: "localhost", @@ -97,6 +97,7 @@ describe("proxied WebSocket upgrade origin validation", () => { "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", "Sec-WebSocket-Version": "13", Origin: origin, + ...headers, }, }); @@ -135,6 +136,15 @@ describe("proxied WebSocket upgrade origin validation", () => { expect(backendConnections).toBe(1); }); + it("should not apply the separate HMR port exception to a proxy", async () => { + const result = await upgrade(`http://localhost:${devServerPort}`, { + Host: `localhost:${backendPort}`, + }); + + expect(result.status).toBe(403); + expect(backendConnections).toBe(0); + }); + for (const options of [ { allowedHosts: ["localhost"] }, { allowedHosts: "all" }, diff --git a/test/e2e/web-socket-server-url.test.js b/test/e2e/web-socket-server-url.test.js index 5cf4b0c494..d1528455e8 100644 --- a/test/e2e/web-socket-server-url.test.js +++ b/test/e2e/web-socket-server-url.test.js @@ -826,7 +826,6 @@ describe("web socket server URL", () => { it(`should work with "client.webSocketURL.port" and "webSocketServer.options.port" options as string ("${webSocketServer}")`, async (t) => { const compiler = webpack(config); const devServerOptions = { - allowedOrigins: [`http://127.0.0.1:${port1}`], webSocketServer: { type: webSocketServer, options: { From 0086d006e89ddb428df979a6711b80866754554e Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:08:59 +0000 Subject: [PATCH 04/13] feat: keep "auto" origins as they were, deny secrets in static, accept the hot token `allowedOrigins: "auto"` (the default) now keeps the origin rules derived from `allowedHosts` exactly, so nothing changes until the option is set; `allowedHosts: "all"` and an `Access-Control-Allow-Origin: *` header still open every origin under it, with a deprecation warning. A list allows the page's own hostname plus the listed origins, answers them with CORS headers and allows a plain script they load by its `Referer`; "all" allows every origin and keeps the `Host` check. One rule now serves ordinary requests, proxied WebSocket upgrades, hot clients and the internal endpoints. The injected hot client is handed a per-run token the endpoint does not require (webpack-dev-middleware#2486), and a client carrying it may connect from an origin the server does not know; skipped with an older middleware. `static.deny` refuses secrets (`.env*`, keys and certificates, `.npmrc`, `.yarnrc.yml`, `.git`) or patterns of your own in a static directory, and hides them from listings; off by default until the next major. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- .../independent-origin-authorization.md | 8 +- .cspell.json | 3 +- README.md | 84 ++- examples/allowed-origins/README.md | 114 ++-- examples/allowed-origins/webpack.config.js | 9 +- lib/Server.js | 620 +++++++++++++++--- lib/hotOptions.js | 59 +- lib/options.check.js | 2 +- lib/options.json | 23 +- .../normalize-options.test.js.snap.webpack5 | 4 +- .../validate-options.test.js.snap.webpack5 | 13 + .../__snapshots__/basic.test.js.snap.webpack5 | 7 +- .../allowed-hosts.test.js.snap.webpack5 | 128 ++-- test/e2e/allowed-hosts.test.js | 64 +- test/e2e/allowed-origins.test.js | 421 ++++++------ test/e2e/cross-origin-request.test.js | 35 +- test/e2e/event-source.test.js | 41 +- test/e2e/options-middleware.test.js | 2 +- test/e2e/proxy-web-socket-origin.test.js | 49 +- test/e2e/static-deny.test.js | 139 ++++ test/e2e/web-socket-server-url.test.js | 4 - test/hot-options.test.js | 13 + test/ports-map.js | 1 + .../allowed-origins-deprecation.test.js | 50 ++ test/server/allowed-origins.test.js | 248 ++++--- test/server/proxy-option.test.js | 12 +- test/validate-options.test.js | 12 + types/lib/Server.d.ts | 50 +- 28 files changed, 1454 insertions(+), 761 deletions(-) create mode 100644 test/e2e/static-deny.test.js create mode 100644 test/server/allowed-origins-deprecation.test.js diff --git a/.changeset/independent-origin-authorization.md b/.changeset/independent-origin-authorization.md index 73cdae23b0..65f90a1d39 100644 --- a/.changeset/independent-origin-authorization.md +++ b/.changeset/independent-origin-authorization.md @@ -2,10 +2,8 @@ "webpack-dev-server": minor --- -Add `allowedOrigins` to authorize cross-origin HTTP requests, HMR connections, and WebSocket proxies independently of `allowedHosts` and `client.webSocketURL`. Host configuration no longer disables origin protection, including when `allowedHosts` is `"all"`. +Add `allowedOrigins` to say which pages may use the server — its HTTP endpoints, hot module replacement and proxied WebSockets — independently of `allowedHosts`, which goes back to checking `Host`. Nothing changes until it is set: the default, `"auto"`, keeps the rules derived from `allowedHosts`. A list allows the page's own origin plus the listed ones, answering them with CORS headers and allowing a plain ` +``` + +or read it with `fetch`, in that page's console: ```js const response = await fetch("http://localhost:8080/main.js"); console.log(response.status); // 200 ``` -The server supplies `Access-Control-Allow-Origin` for an authorized origin. Other -origins receive 403 for HTTP requests carrying `Origin` and cannot receive HMR -messages or connect through a WebSocket proxy. The host check still applies. +A listed origin's requests get `Access-Control-Allow-Origin`, and a script or +image it loads without `crossorigin` is allowed by the page's `Referer`. Any +other origin gets `403`, cannot receive hot updates and cannot connect through a +WebSocket proxy. The `Host` check still applies. ## Values -- `"auto"` (default) or `[]`: allow only the same origin. -- An HTTP(S) origin or an array of origins: allow those origins in addition to - the same origin. The scheme, hostname, and port must match. Entries may have a - trailing slash, but no path, credentials, query, or fragment. Hostname - wildcards and opaque origins (`"null"`) are not supported. -- `"all"`: explicitly disable origin checks and send - `Access-Control-Allow-Origin: *`. Only use this when you intend to expose the - development server to any website. This does not disable the host check. +- `"auto"` (the default): the rules derived from `allowedHosts`, unchanged. The + page's own hostname on any port is allowed, with `localhost`, `127.0.0.1` and + `[::1]` counted as one, and so is every origin when `allowedHosts` is `"all"` + (deprecated: set `allowedOrigins` instead). +- An HTTP(S) origin, or a list of them: those, plus the page's own origin, and + nothing else, whatever `allowedHosts` says. Scheme, hostname and port must + match. Entries may have a trailing slash, but no path, credentials, query or + fragment. `[]` allows the page's own origin only. +- `"all"`: every origin, with `Access-Control-Allow-Origin: *`. It does not turn + off the `Host` check. -The CLI accepts repeated flags: +The CLI takes the flag more than once: ```console -npx webpack serve --allowed-origins http://localhost:3000 --allowed-origins https://app.example.com +npx webpack serve --allowed-origins http://app.localhost:3000 --allowed-origins https://app.example.com ``` -When HMR listens on a separate port, it also accepts the page served by this -dev server's HTTP(S) listener on the same hostname. This uses the actual bound -port, including with `port: 0`. It applies only to HMR connections; HTTP resources, -WebSocket proxies, and state-changing endpoints keep their existing origin rules. -Reverse proxies that change the public origin still need `allowedOrigins`. +## Tunnels, containers and reverse proxies + +`allowedHosts: "all"` is the usual answer to a `Host` the server does not know. +Under `"auto"` it also lets every website use the server; list the origin your +frontend is served from to keep that closed: + +```js +export default { + devServer: { + allowedHosts: "all", + allowedOrigins: ["https://my-app.example-tunnel.dev"], + }, +}; +``` -## Migrating cross-origin setups +Forwarded headers grant nothing. For TLS terminated at a proxy, list the public +`https` origin. -Previously, an entry in `allowedHosts` or a custom `client.webSocketURL` could -implicitly disable origin protection. Add the frontend's full HTTP(S) origin to -`allowedOrigins`, including for reverse proxies that change the host, port, or -protocol. For TLS terminated at a reverse proxy, list the public HTTPS origin. -Forwarded headers do not grant permission. +Hot module replacement does not need the list for a proxy: the injected client +is handed a token, new on every run, and a client that carries it may connect +from an origin the server does not know. -`localhost`, `127.0.0.1`, and `[::1]` are different origins. Another port on the -same hostname is also a different origin. Add each frontend origin that you use. +## A page that sends no `Referer` -Cross-origin script and image elements should use `crossorigin="anonymous"` so -the browser sends `Origin` and applies CORS: +With `Referrer-Policy: no-referrer`, a plain script load carries nothing to +identify the page, and a cross-site one is refused. Load the bundle with +`crossorigin` so the browser sends `Origin`, and set +`output.crossOriginLoading: "anonymous"` so lazy chunks and hot updates do too: ```html ``` - -Webpack-generated lazy chunks and hot-update scripts also need -`output.crossOriginLoading: "anonymous"` so that the browser sends `Origin`. -Adding `crossorigin` to the initial script does not configure these generated -scripts. Their `output.publicPath` must also resolve to the dev server. - -A cross-origin `no-cors` request without `Origin` cannot be matched to an allowed -page, even if it sends a referrer header. Fetch Metadata checks and -`Cross-Origin-Resource-Policy: same-origin` continue to protect those requests. -Configuring CORS headers through `headers` alone does not authorize an origin; -use `allowedOrigins`. Custom response headers, including preflight headers when -needed, can still be configured through `headers`. - -The internal invalidate and open-editor endpoints retain their same-origin -protection because they change server state. diff --git a/examples/allowed-origins/webpack.config.js b/examples/allowed-origins/webpack.config.js index 99b092fab7..329128e59d 100644 --- a/examples/allowed-origins/webpack.config.js +++ b/examples/allowed-origins/webpack.config.js @@ -3,15 +3,8 @@ import { setup } from "../util.js"; export default setup( { entry: "./app.js", - output: { - crossOriginLoading: "anonymous", - }, devServer: { - allowedHosts: ["localhost"], - allowedOrigins: ["http://localhost:3000"], - client: { - webSocketURL: "ws://localhost:8080/ws", - }, + allowedOrigins: ["http://app.localhost:3000"], }, }, import.meta.url, diff --git a/lib/Server.js b/lib/Server.js index de6a73dbe0..2d58772bf6 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import { EventEmitter } from "node:events"; import { createRequire } from "node:module"; import os from "node:os"; @@ -115,6 +116,7 @@ function loadWebpackPeer() { * @property {(boolean | ServeIndexOptions)=} serveIndex serve index * @property {ServeStaticOptions=} staticOptions static options * @property {(boolean | WatchOptions & { aggregateTimeout?: number, ignored?: WatchOptions["ignored"], poll?: number | boolean })=} watch watch and watch options + * @property {(boolean | string[])=} deny files never to serve from the directory, matched against each segment of the path: `true` for the default list of secrets, or patterns of your own */ /** @@ -124,6 +126,7 @@ function loadWebpackPeer() { * @property {false | ServeIndexOptions} serveIndex * @property {ServeStaticOptions} staticOptions * @property {false | WatchOptions} watch + * @property {string[]=} deny */ /** @@ -367,6 +370,102 @@ function useFn(route, fn) { const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i; +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]); + +// What `static.deny: true` keeps out of reach: environment files, keys and +// certificates, package-manager credentials, and the repository. The dotfiles +// among them are already ignored by `serve-static` unless `staticOptions` says +// otherwise; listed anyway, so that saying otherwise does not expose them. +const DEFAULT_STATIC_DENY = [ + ".env", + ".env.*", + "*.{crt,pem,key,p12,pfx,cer,der}", + ".npmrc", + ".yarnrc.yml", + ".git", +]; + +/** + * One path segment's pattern as a regular expression: `*` and `?` within the + * segment, and `{a,b}` for alternatives. Case-insensitive, because the file + * systems a developer is most likely on are, and `.ENV` must not get past + * `.env` there. + * @param {string} pattern pattern + * @returns {RegExp} regular expression for one path segment + */ +function segmentPatternToRegExp(pattern) { + let source = ""; + let inGroup = false; + + for (const char of pattern) { + if (char === "*") { + source += "[^/]*"; + } else if (char === "?") { + source += "[^/]"; + } else if (char === "{" && !inGroup) { + inGroup = true; + source += "(?:"; + } else if (char === "}" && inGroup) { + inGroup = false; + source += ")"; + } else if (char === "," && inGroup) { + source += "|"; + } else { + source += char.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + } + + return new RegExp(`^${source}$`, "i"); +} + +/** + * Does any segment of a request path match one of the patterns? + * @param {string} urlPath the path, relative to where the directory is mounted + * @param {RegExp[]} patterns patterns + * @returns {boolean} true when the path must not be served + */ +function isPathDenied(urlPath, patterns) { + let decoded; + + try { + decoded = decodeURIComponent(urlPath); + } catch { + // Not a path any file has; `serve-static` would refuse it as well. + return true; + } + + for (let segment of decoded.split(/[\\/]/)) { + if (process.platform === "win32") { + // An alternate data stream (`.env::$DATA`) is the file itself, and + // Windows ignores trailing dots and spaces (`.env.`). + if (segment.includes(":")) { + return true; + } + + segment = segment.replace(/[. ]+$/, ""); + } + + if (segment && patterns.some((pattern) => pattern.test(segment))) { + return true; + } + } + + return false; +} + +// TODO in the next major release remove both: `allowedHosts` will only check +// `Host`, and `headers` will only add headers, with `allowedOrigins` the one +// option that decides which origins may use the server. +const warnAllowedHostsAllOrigins = util.deprecate( + () => {}, + "'allowedHosts: \"all\"' also lets every origin use this server, which is deprecated: in the next major release it will only stop checking the 'Host' header. Set 'allowedOrigins: \"all\"' to keep allowing every origin, or list the ones that need it.", +); + +const warnCorsWildcardOrigins = util.deprecate( + () => {}, + "An 'Access-Control-Allow-Origin: *' entry in 'headers' also turns off this server's 'Cross-Origin-Resource-Policy', which is deprecated: in the next major release 'headers' will only add headers. Set 'allowedOrigins: \"all\"' to keep allowing every origin, or list the ones that need it.", +); + /** * @typedef {object} BasicApplication * @property {typeof useFn} use @@ -846,6 +945,15 @@ class Server { : false : getWatchOptions(optionsForStatic.watch) : def.watch, + // TODO in the next major release default to `true` + ...(optionsForStatic.deny + ? { + deny: + optionsForStatic.deny === true + ? [...DEFAULT_STATIC_DENY] + : optionsForStatic.deny, + } + : {}), }; } @@ -876,6 +984,9 @@ class Server { options.allowedHosts = "all"; } + // "auto" is how origins have always been judged, derived from + // `allowedHosts`. Anything else is a rule of its own: "all", or the + // origins listed, on top of the page's own. if (typeof options.allowedOrigins === "undefined") { options.allowedOrigins = "auto"; } else { @@ -885,6 +996,11 @@ class Server { if (origins.includes("all")) { options.allowedOrigins = "all"; + } else if ( + origins.length > 0 && + origins.every((origin) => origin === "auto") + ) { + options.allowedOrigins = "auto"; } else { options.allowedOrigins = origins .filter((origin) => origin !== "auto") @@ -909,6 +1025,16 @@ class Server { } } + if (options.allowedOrigins === "auto") { + if (options.allowedHosts === "all") { + warnAllowedHostsAllOrigins(); + } + + if (this.isUserCORSWildcardEnabled()) { + warnCorsWildcardOrigins(); + } + } + if (typeof options.bonjour === "undefined") { options.bonjour = false; } else if (typeof options.bonjour === "boolean") { @@ -1838,7 +1964,7 @@ class Server { if ( !headers || !this.isValidHost(headers, "host") || - !this.isOriginAllowed(headers) + !this.#isOriginAllowed(headers, "upgrade") ) { if (!socket.destroyed) { socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); @@ -2002,37 +2128,44 @@ class Server { const headers = /** @type {{ [key: string]: string | undefined }} */ (req.headers); - const hasOrigin = typeof headers.origin === "string"; - const originAllowed = this.isOriginAllowed(headers); + + if (this.options.allowedOrigins !== "auto") { + if (!this.#checkCrossOriginRequest(headers, res)) { + res.statusCode = 403; + res.end("Cross-Origin request blocked"); + return; + } + + next(); + return; + } + + // TODO in the next major release remove: `allowedOrigins` decides + // this alone, and `allowedHosts` stops granting origins. + const headerName = headers[":authority"] ? ":authority" : "host"; + + if (this.isValidHost(headers, headerName, false)) { + next(); + return; + } if ( - !originAllowed && - (hasOrigin || - (headers["sec-fetch-mode"] === "no-cors" && - (headers["sec-fetch-site"] === "cross-site" || - headers["sec-fetch-site"] === "same-site"))) + headers["sec-fetch-mode"] === "no-cors" && + headers["sec-fetch-site"] === "cross-site" ) { res.statusCode = 403; res.end("Cross-Origin request blocked"); return; } - // Also protect browsers that omit Fetch Metadata. A no-cors request - // without Origin cannot be matched against an explicit origin list. - if (!originAllowed) { + // Block cross-origin resource loading when Sec-Fetch-* headers are absent (HTTP origins) + if ( + this.options.allowedHosts !== "all" && + !this.isUserCORSWildcardEnabled() + ) { res.setHeader("Cross-Origin-Resource-Policy", "same-origin"); } - // Cache entries must not reuse a response authorized for another page, - // including when a request without Origin was made first. - res.setHeader("Vary", "Origin"); - - if (this.options.allowedOrigins === "all") { - res.setHeader("Access-Control-Allow-Origin", "*"); - } else if (typeof headers.origin === "string" && originAllowed) { - res.setHeader("Access-Control-Allow-Origin", headers.origin); - } - next(); }, }); @@ -2379,7 +2512,38 @@ class Server { if (staticOptions.length > 0) { for (const staticOption of staticOptions) { + const denied = staticOption.deny + ? staticOption.deny.map(segmentPatternToRegExp) + : undefined; + for (const publicPath of staticOption.publicPath) { + if (denied) { + middlewares.push({ + name: "static-deny", + path: publicPath, + /** + * @param {Request} req request + * @param {Response} res response + * @param {NextFunction} next next function + * @returns {void} + */ + middleware: (req, res, next) => { + const urlPath = (req.url || "/").split("?")[0]; + + if ( + (req.method === "GET" || req.method === "HEAD") && + isPathDenied(urlPath, denied) + ) { + res.statusCode = 403; + res.end("Forbidden"); + return; + } + + next(); + }, + }); + } + middlewares.push({ name: "express-static", path: publicPath, @@ -2453,9 +2617,26 @@ class Server { for (const staticOption of staticOptions) { for (const publicPath of staticOption.publicPath) { if (staticOption.serveIndex) { + const { deny } = staticOption; + /** @type {ServeIndexOptions} */ + let indexOptions = staticOption.serveIndex; + + // Not listed either: a name in the listing is half of reading it. + if (deny) { + const denied = deny.map(segmentPatternToRegExp); + const userFilter = indexOptions.filter; + + indexOptions = { + ...indexOptions, + filter: (filename, index, files, dir) => + !denied.some((pattern) => pattern.test(filename)) && + (!userFilter || userFilter(filename, index, files, dir)), + }; + } + const serveIndexMiddleware = serveIndex( staticOption.directory, - staticOption.serveIndex, + indexOptions, ); middlewares.push({ @@ -2716,7 +2897,8 @@ class Server { * * The middleware hands over each connection and the request it joined with, * and applies no policy of its own. This is that policy: the same - * host and origin rules, before anything is published to a client. + * `allowedHosts`, `Origin` and same-origin rules this server has always + * applied, in the same order, before anything is published to a client. * @private * @returns {void} */ @@ -2762,10 +2944,17 @@ class Server { const isSameOriginStream = isStream && headers && typeof headers.origin === "undefined"; + // The token the injected client was handed stands in for an origin + // this server would not otherwise know — a proxy's, a tunnel's — as + // it does in Vite. Only for the origin: `Host` is what stands between + // a page and a rebound DNS name, and a rebound page is same-origin + // and could read the token out of the bundle. if ( !headers || !this.isValidHost(headers, "host") || - (!isSameOriginStream && !this.#isHotOriginAllowed(headers)) + (!isSameOriginStream && + !this.#isOriginAllowed(headers, "hot") && + !this.#isHotTokenValid(request)) ) { // Said to the client before it is closed, so the reason reaches the // page rather than only this server's log — a refusal that closes @@ -3416,13 +3605,61 @@ class Server { } }; + /** + * @private + * @returns {boolean} true when the user has configured a wildcard + * Access-Control-Allow-Origin header (opting into fully open cross-origin access) + */ + isUserCORSWildcardEnabled() { + const { headers } = this.options; + + if (!headers) { + return false; + } + + if (typeof headers === "function") { + return false; + } + + /** + * @param {string | string[]} value header value + * @returns {boolean} true when value is the "*" wildcard + */ + const isWildcard = (value) => { + if (typeof value === "string") { + return value.trim() === "*"; + } + + if (Array.isArray(value)) { + return value.length === 1 && isWildcard(value[0]); + } + + return false; + }; + + if (Array.isArray(headers)) { + return headers.some( + (header) => + header.key.toLowerCase() === "access-control-allow-origin" && + isWildcard(header.value), + ); + } + + return Object.entries(headers).some( + ([key, value]) => + key.toLowerCase() === "access-control-allow-origin" && + isWildcard(value), + ); + } + /** * @private * @param {{ [key: string]: string | undefined }} headers headers * @param {string} headerToCheck header to check + * @param {boolean} validateHost need to validate host * @returns {boolean} true when host is valid, otherwise false */ - isValidHost(headers, headerToCheck) { + isValidHost(headers, headerToCheck, validateHost = true) { const isOrigin = headerToCheck === "origin"; if (isOrigin && this.options.allowedHosts === "all") { @@ -3464,144 +3701,329 @@ class Server { // For convenience, always allow localhost (hostname === 'localhost') // and its subdomains (hostname.endsWith(".localhost")). // allow hostname of listening address (hostname === this.options.host) - return ( - ipaddr.IPv4.isValid(hostname) || - ipaddr.IPv6.isValid(hostname) || - hostname === "localhost" || - hostname.endsWith(".localhost") || - hostname === this.options.host - ); + const isValidHostname = validateHost + ? ipaddr.IPv4.isValid(hostname) || + ipaddr.IPv6.isValid(hostname) || + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === this.options.host + : false; + + return isValidHostname; } /** * @private * @param {{ [key: string]: string | undefined }} headers headers - * @returns {boolean} true when the request origin is allowed + * @returns {boolean} true when is same origin, otherwise false */ - isOriginAllowed(headers) { - const { allowedOrigins } = this.options; + isSameOrigin(headers) { + if (this.options.allowedHosts === "all") { + return true; + } - if (allowedOrigins === "all") { + const originHeader = headers.origin; + + if (!originHeader) { + return this.options.allowedHosts === "all"; + } + + if (DEFAULT_ALLOWED_PROTOCOLS.test(originHeader)) { return true; } - const origin = this.#parseOrigin(headers.origin); + const origin = this.#parseHostnameFromHeader(originHeader); if (origin === null) { return false; } - return ( - this.isSameOrigin(headers) || - (Array.isArray(allowedOrigins) && allowedOrigins.includes(origin)) - ); + if (this.isHostAllowed(origin)) { + return true; + } + + const hostHeader = headers.host; + + if (!hostHeader) { + return this.options.allowedHosts === "all"; + } + + const host = this.#parseHostnameFromHeader(hostHeader); + + if (host === null) { + return false; + } + + if (this.isHostAllowed(host)) { + return true; + } + + // Treat all loopback aliases as equivalent: localhost may resolve to + // 127.0.0.1 or ::1 depending on the OS, causing a false mismatch. + // Only widen when allowedHosts is "auto" (default) or already permits a + // loopback alias, so an explicit allow-list excluding loopback is honored. + const loopbacks = new Set(["localhost", "127.0.0.1", "::1"]); + const loopbackPermitted = + this.options.allowedHosts === "auto" || + [...loopbacks].some((alias) => this.isHostAllowed(alias)); + if (loopbacks.has(origin) && loopbacks.has(host) && loopbackPermitted) { + return true; + } + + return origin === host; } /** - * Authorize HMR from a page served by this server when the hot endpoint - * listens on a separate port, in addition to explicitly allowed origins. - * @param {{ [key: string]: string | undefined }} headers connection headers - * @returns {boolean} true when the page may connect to HMR + * Determines whether a request was initiated from the dev server's own + * origin, to reject cross-site request forgery on state-changing endpoints. + * @param {Request} req request + * @returns {boolean} true when the request can be trusted as same-origin */ - #isHotOriginAllowed(headers) { - if (this.isOriginAllowed(headers)) { + #isSameOriginRequest(req) { + const headers = + /** @type {{ [key: string]: string | undefined }} */ + (req.headers); + + if (this.options.allowedOrigins !== "auto") { + return this.#isOriginAllowed(headers, "endpoint"); + } + + const secFetchSite = headers["sec-fetch-site"]; + + // Prefer `Sec-Fetch-Site`: browsers send it even for same-origin GET + // `fetch`es (which omit `Origin`), and a cross-site page cannot forge it. + // `none` is a user-initiated navigation. + if (typeof secFetchSite === "string") { + return secFetchSite === "same-origin" || secFetchSite === "none"; + } + + // Without `Sec-Fetch-*` metadata (non-browser/legacy clients): a request + // with no `Origin` header was not initiated by another origin's script; + // otherwise fall back to the `Origin`/`Host` comparison. + if (typeof headers.origin !== "string") { return true; } - const origin = this.#parseOrigin(headers.origin); - const host = headers[":authority"] || headers.host; - const address = this.server?.address(); + return this.isSameOrigin(headers); + } + + /** + * Whether a request's origin may use this server, by `allowedOrigins` — or + * by `allowedHosts`, as it always was, when that is "auto". + * @private + * @param {{ [key: string]: string | undefined }} headers request headers + * @returns {boolean} true when allowed + */ + isOriginAllowed(headers) { + return this.#isOriginAllowed(headers, "upgrade"); + } + + /** + * The one origin rule, for every way into this server: an ordinary request + * (through `#checkCrossOriginRequest`), a proxied WebSocket upgrade, a hot + * client, and the endpoints that act on the developer's machine. + * + * With `allowedOrigins: "auto"`, the rules this server has always applied, + * derived from `allowedHosts`, unchanged. Otherwise `allowedOrigins` alone: + * the page's own origin, plus the ones it lists. + * @param {{ [key: string]: string | undefined }} headers request headers + * @param {"upgrade" | "hot" | "endpoint"} kind what the request is for + * @returns {boolean} true when the request's origin may use this server + */ + #isOriginAllowed(headers, kind) { + if (this.options.allowedOrigins === "auto") { + // TODO in the next major release remove: `allowedHosts` will only check + // `Host`, and `allowedOrigins` will decide this alone. + return this.isValidHost(headers, "origin") && this.isSameOrigin(headers); + } + + if (kind === "endpoint") { + const secFetchSite = headers["sec-fetch-site"]; + + // As with "auto": a cross-site page cannot forge `Sec-Fetch-Site`, + // and a same-origin `fetch` sends no `Origin` to compare. + if (secFetchSite === "same-origin" || secFetchSite === "none") { + return true; + } + + if (typeof headers.origin !== "string") { + return typeof secFetchSite !== "string"; + } + } + + return this.#isListedOrSameOrigin(headers.origin, headers); + } + + /** + * Is the origin one `allowedOrigins` allows: every one with "all", else the + * page's own — its hostname, on any port, with the loopback names counted as + * one, as "auto" has always counted them — or one it lists? + * @param {string | undefined} originHeader the origin to judge + * @param {{ [key: string]: string | undefined }} headers request headers, for the `Host` it is compared with + * @returns {boolean} true when allowed + */ + #isListedOrSameOrigin(originHeader, headers) { + const { allowedOrigins } = this.options; + + if (allowedOrigins === "all") { + return true; + } - if (origin === null || !host || !address || typeof address === "string") { + if (typeof originHeader !== "string") { return false; } - const protocol = this.isTlsServer ? "https" : "http"; - const hostOrigin = this.#parseOrigin(`${protocol}://${host}`); + // A page of an Electron app or a browser extension, as with "auto". + if (DEFAULT_ALLOWED_PROTOCOLS.test(originHeader)) { + return true; + } + + const origin = this.#parseOrigin(originHeader); - if (hostOrigin === null) { + if (origin === null) { return false; } - // Keep the request hostname and the HTTP listener's protocol. Its bound - // port also handles port: 0; proxy origins still require allowedOrigins. - const httpOrigin = new URL(hostOrigin); - httpOrigin.port = String(address.port); + if (Array.isArray(allowedOrigins) && allowedOrigins.includes(origin)) { + return true; + } + + const originHostname = this.#parseHostnameFromHeader(origin); + const hostHeader = headers[":authority"] || headers.host; + const hostname = hostHeader + ? this.#parseHostnameFromHeader(hostHeader) + : null; - return origin === httpOrigin.origin; + if (originHostname === null || hostname === null) { + return false; + } + + return ( + originHostname === hostname || + (LOOPBACK_HOSTNAMES.has(originHostname) && + LOOPBACK_HOSTNAMES.has(hostname)) + ); } /** - * @param {string | undefined} value Origin header value - * @returns {string | null} serialized HTTP(S) origin, or null if invalid + * @param {string | undefined} value an `Origin` header, or a `Referer` + * @returns {string | null} its serialized HTTP(S) origin, or null when it has none */ #parseOrigin(value) { - if ( - typeof value !== "string" || - !/^https?:\/\/[^/?#\s@\\]+$/i.test(value) - ) { + if (typeof value !== "string") { return null; } try { const url = new URL(value); - // Origin is a single serialized origin, never a URL with credentials, - // a path, or a list. Opaque origins ("null") cannot identify a page. - return url.origin; + // An opaque origin ("null") identifies no page. + return /^https?:$/.test(url.protocol) ? url.origin : null; } catch { return null; } } /** - * @private - * @param {{ [key: string]: string | undefined }} headers headers - * @returns {boolean} true when scheme, hostname and port match + * Judge an ordinary request by `allowedOrigins`, and say so in the CORS and + * resource-policy headers of its response. + * + * A request that carries `Origin` is judged by it. One that does not — a + * ``); - }); - await new Promise((resolve) => { - pageServer.listen(pagePort, "127.0.0.1", resolve); - }); - const { page, browser } = await runBrowser(); - - try { - const scriptResponse = page.waitForResponse( - `${serverOrigin}/main.js`, - ); - await page.goto(trustedOrigin); - expect((await scriptResponse).status()).toBe(403); - - const action = await page.evaluate( - (url) => - new Promise((resolve, reject) => { - const client = new WebSocket(url); - client.onerror = reject; - client.onmessage = (event) => { - const message = JSON.parse(event.data); - client.close(); - resolve(message.action); - }; - }), - `ws://localhost:${port}/ws`, - ); - - expect(action).toBe("error"); - } finally { - await browser.close(); - await new Promise((resolve) => { - pageServer.close(resolve); - }); - } - }); - } + await server.start(); + await new Promise((resolve) => { + server.middleware.waitUntilValid(resolve); }); + + return server; } -describe("explicit allowedOrigins", () => { +describe("allowedOrigins as a list", () => { let server; before(async () => { - server = new Server( - { port, allowedOrigins: [trustedOrigin] }, - webpack(config), - ); - await server.start(); - await new Promise((resolve) => { - server.middleware.waitUntilValid(resolve); + // `allowedHosts: "all"` would let every origin in under "auto"; with a + // list, it only stops checking `Host`. + server = await startServer({ + allowedHosts: "all", + allowedOrigins: [listedOrigin], }); }); @@ -168,73 +67,112 @@ describe("explicit allowedOrigins", () => { await server.stop(); }); - it("should authorize HTTP and HMR and emit CORS headers", async () => { + it("answers a listed origin with CORS headers", async () => { await request(serverOrigin) .get("/main.js") - .set("Origin", trustedOrigin) - .expect("Access-Control-Allow-Origin", trustedOrigin) + .set("Origin", listedOrigin) + .expect("Access-Control-Allow-Origin", listedOrigin) .expect("Vary", /Origin/) .expect(200); - - expect((await connect(trustedOrigin)).action).toBe("sync"); }); - it("should reject an unlisted origin and an invalid Host", async () => { - await request(serverOrigin) - .get("/main.js") - .set("Origin", "http://192.168.1.2") - .expect(403); + it("refuses an origin it does not list", async () => { await request(serverOrigin) .get("/main.js") - .set("Host", "evil.example.com") - .set("Origin", trustedOrigin) + .set("Origin", unlistedOrigin) .expect(403); - expect( - (await connect(trustedOrigin, { host: "evil.example.com" })).action, - ).toBe("error"); }); - it("should not authorize a no-cors request without an Origin", async () => { + it("allows a script load from a listed page by its Referer", async () => { await request(serverOrigin) .get("/main.js") .set("Sec-Fetch-Site", "cross-site") .set("Sec-Fetch-Mode", "no-cors") - .set("Referer", `${trustedOrigin}/`) + .set("Referer", `${listedOrigin}/page.html`) + .expect("Cross-Origin-Resource-Policy", "cross-origin") + .expect(200); + }); + + it("refuses a cross-site script load from any other page", async () => { + for (const referer of [`${unlistedOrigin}/`, undefined]) { + const req = request(serverOrigin) + .get("/main.js") + .set("Sec-Fetch-Site", "cross-site") + .set("Sec-Fetch-Mode", "no-cors"); + + if (referer) { + req.set("Referer", referer); + } + + await req.expect(403); + } + }); + + it("keeps a same-origin resource policy without Fetch Metadata", async () => { + await request(serverOrigin) + .get("/main.js") + .expect("Cross-Origin-Resource-Policy", "same-origin") + .expect(200); + }); + + it("lets the page's own origin and the listed one connect for HMR", async () => { + expect((await connect(serverOrigin)).action).toBe("sync"); + expect((await connect(listedOrigin)).action).toBe("sync"); + }); + + it("refuses HMR to any other origin", async () => { + expect(await connect(unlistedOrigin)).toEqual({ + action: "error", + message: "Invalid Host/Origin header", + }); + }); + + it("opens the state-changing endpoints to the listed origin only", async () => { + await request(serverOrigin) + .get("/webpack-dev-server/invalidate") + .set("Sec-Fetch-Site", "same-site") + .set("Origin", listedOrigin) + .expect(200); + await request(serverOrigin) + .get("/webpack-dev-server/invalidate") + .set("Sec-Fetch-Site", "same-site") + .set("Origin", unlistedOrigin) .expect(403); }); - it("should allow the configured page to load a CORS script and connect HMR", async () => { + it("loads a script into a listed page and keeps it out of another", async () => { const pageServer = http.createServer((_req, res) => { res.setHeader("Content-Type", "text/html"); - res.end(``); + res.end("page"); }); + await new Promise((resolve) => { pageServer.listen(pagePort, "127.0.0.1", resolve); }); - const { page, browser } = await runBrowser(); - try { - const scriptResponse = page.waitForResponse(`${serverOrigin}/main.js`); - await page.goto(trustedOrigin); - expect((await scriptResponse).status()).toBe(200); - - const action = await page.evaluate( - (url) => - new Promise((resolve, reject) => { - const client = new WebSocket(url); - client.onerror = reject; - client.onmessage = (event) => { - const message = JSON.parse(event.data); - if (["error", "sync", "built"].includes(message.action)) { - client.close(); - resolve(message.action); - } - }; + const { page, browser } = await runBrowser(); + const loadScript = (url) => + page.evaluate( + (src) => + new Promise((resolve) => { + const script = document.createElement("script"); + + // A plain `