diff --git a/.changeset/independent-origin-authorization.md b/.changeset/independent-origin-authorization.md
new file mode 100644
index 0000000000..1abba02cea
--- /dev/null
+++ b/.changeset/independent-origin-authorization.md
@@ -0,0 +1,9 @@
+---
+"webpack-dev-server": minor
+---
+
+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 listed origins plus the page's own hostname (any port, loopback names counted as one, as before), 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
+```
+
+A listed origin's requests get `Access-Control-Allow-Origin` and
+`Access-Control-Allow-Credentials`, its preflight requests are answered with the methods
+and headers it asks for (and Private Network Access), 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"` (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 hostname (on
+ any port, loopback names counted as one, as with `"auto"`), whatever
+ `allowedHosts` says. A listed origin's scheme, hostname and port must match.
+ Entries may have a trailing slash, but no path, credentials, query or
+ fragment. `[]` allows the page's own hostname only — which still includes
+ every other page on `localhost` when the server is on `localhost`.
+- `"all"`: every origin, with `Access-Control-Allow-Origin: *`. It does not turn
+ off the `Host` check.
+
+The CLI takes the flag more than once:
+
+```console
+npx webpack serve --allowed-origins http://app.localhost:3000 --allowed-origins https://app.example.com
+```
+
+## 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"],
+ },
+};
+```
+
+Forwarded headers grant nothing. For TLS terminated at a proxy, list the public
+`https` origin.
+
+Hot module replacement does not need the list for a proxy once `allowedOrigins`
+is set: 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.
+
+## A page that sends no `Referer`
+
+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
+
+```
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..329128e59d
--- /dev/null
+++ b/examples/allowed-origins/webpack.config.js
@@ -0,0 +1,11 @@
+import { setup } from "../util.js";
+
+export default setup(
+ {
+ entry: "./app.js",
+ devServer: {
+ allowedOrigins: ["http://app.localhost:3000"],
+ },
+ },
+ import.meta.url,
+);
diff --git a/lib/Server.js b/lib/Server.js
index d4914aa113..56835a9562 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
*/
/**
@@ -247,6 +250,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
@@ -366,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
@@ -845,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,
+ }
+ : {}),
};
}
@@ -875,6 +984,45 @@ 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.
+ //
+ // TODO in the next major release remove "auto" and default to `[]`, the
+ // page's own origin only, so `allowedHosts` never grants an origin.
+ 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 if (
+ origins.length > 0 &&
+ origins.every((origin) => origin === "auto")
+ ) {
+ options.allowedOrigins = "auto";
+ } else {
+ options.allowedOrigins = origins
+ .filter((origin) => origin !== "auto")
+ // The schema has already refused anything but a bare HTTP(S)
+ // origin; this is it serialized, as a browser sends it.
+ .map((origin) => new URL(origin).origin);
+ }
+ }
+
+ 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") {
@@ -1804,8 +1952,7 @@ class Server {
if (
!headers ||
!this.isValidHost(headers, "host") ||
- !this.isValidHost(headers, "origin") ||
- !this.isSameOrigin(headers)
+ !this.#isOriginAllowed(headers, "upgrade")
) {
if (!socket.destroyed) {
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
@@ -1969,6 +2116,20 @@ class Server {
const headers =
/** @type {{ [key: string]: string | undefined }} */
(req.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)) {
@@ -2339,7 +2500,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,
@@ -2413,9 +2605,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({
@@ -2723,12 +2932,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.isValidHost(headers, "origin") ||
- !this.isSameOrigin(headers)))
+ !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
@@ -3335,6 +3549,8 @@ class Server {
}
// Also allow if `client.webSocketURL.hostname` provided
+ // TODO in the next major release remove: the hostname a client connects
+ // to says nothing about which hosts the server answers.
if (
this.options.client &&
typeof (
@@ -3380,6 +3596,8 @@ class Server {
};
/**
+ * TODO in the next major release remove: `headers` will only add headers,
+ * and `allowedOrigins: "all"` is how every origin is allowed.
* @private
* @returns {boolean} true when the user has configured a wildcard
* Access-Control-Allow-Origin header (opting into fully open cross-origin access)
@@ -3487,6 +3705,10 @@ class Server {
}
/**
+ * The origin rule of `allowedOrigins: "auto"`.
+ *
+ * TODO in the next major release remove, with "auto": `#isListedOrSameOrigin`
+ * is the rule then, and `allowedHosts` stops granting origins.
* @private
* @param {{ [key: string]: string | undefined }} headers headers
* @returns {boolean} true when is same origin, otherwise false
@@ -3558,6 +3780,12 @@ class Server {
/** @type {{ [key: string]: string | undefined }} */
(req.headers);
+ if (this.options.allowedOrigins !== "auto") {
+ return this.#isOriginAllowed(headers, "endpoint");
+ }
+
+ // TODO in the next major release remove the rest, with "auto".
+
const secFetchSite = headers["sec-fetch-site"];
// Prefer `Sec-Fetch-Site`: browsers send it even for same-origin GET
@@ -3577,6 +3805,274 @@ class Server {
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 (typeof originHeader !== "string") {
+ return false;
+ }
+
+ // 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 (origin === null) {
+ return false;
+ }
+
+ 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;
+
+ if (originHostname === null || hostname === null) {
+ return false;
+ }
+
+ return (
+ originHostname === hostname ||
+ (LOOPBACK_HOSTNAMES.has(originHostname) &&
+ LOOPBACK_HOSTNAMES.has(hostname))
+ );
+ }
+
+ /**
+ * @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") {
+ return null;
+ }
+
+ try {
+ const url = new URL(value);
+
+ // An opaque origin ("null") identifies no page.
+ return /^https?:$/.test(url.protocol) ? url.origin : null;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * 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
+ * `