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 + * `