Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/independent-origin-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"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 `<script>` they load by its `Referer`; `"all"` allows every origin without turning off the `Host` check. Under `"auto"`, `allowedHosts: "all"` and an `Access-Control-Allow-Origin: *` entry in `headers` still open the server to every origin, and are deprecated for doing so.
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 origin (the host and port it reached the server by, exactly), answering them with CORS headers and allowing a plain `<script>` they load by its `Referer`; `"all"` allows every origin without turning off the `Host` check. Under `"auto"`, `allowedHosts: "all"` and an `Access-Control-Allow-Origin: *` entry in `headers` still open the server to every origin, and are deprecated for doing so.

Once `allowedOrigins` is set, the injected hot client is also handed a per-run token, and a client carrying it may connect from an origin the server does not otherwise know, such as a proxy's; the `Host` check still applies. webpack-dev-middleware is now required at `^8.5.0`, the first release that hands over a token without requiring it.

Expand Down
5 changes: 5 additions & 0 deletions .changeset/open-editor-network-csrf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-server": patch
---

Refuse a request to open the editor that shows no page it came from, when the server is reached through a network address. Over `http:` anywhere but `localhost` a browser sends neither `Sec-Fetch-Site` nor `Origin`, so any page could have the developer's editor open a file of its choosing. The overlay's own request carries its `Referer` and keeps working, as does every request through `localhost`.
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ Options:
--no-watch-options-stdin Negative 'watch-options-stdin' option.
--allowed-hosts <value...> 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 <value...> Which origins may use the dev server: its HTTP endpoints, the hot module replacement connection and proxied WebSockets. 'auto' (the default) keeps the rules derived from 'allowedHosts'. A list of HTTP(S) origins allows those plus the page's own hostname, on any port, independently of 'allowedHosts'. 'all' allows every origin without turning off the 'Host' check.
--allowed-origins-reset Clear all items provided in 'allowedOrigins' configuration. Which origins may use the dev server: its HTTP endpoints, the hot module replacement connection and proxied WebSockets. 'auto' (the default) keeps the rules derived from 'allowedHosts'. A list of HTTP(S) origins allows those plus the page's own hostname, on any port, independently of 'allowedHosts'. 'all' allows every origin without turning off the 'Host' check.
--allowed-origins <value...> Which origins may use the dev server: its HTTP endpoints, the hot module replacement connection and proxied WebSockets. 'auto' (the default) keeps the rules derived from 'allowedHosts'. A list of HTTP(S) origins allows those plus the page's own origin, the host and port it reached the server by, independently of 'allowedHosts'. 'all' allows every origin without turning off the 'Host' check.
--allowed-origins-reset Clear all items provided in 'allowedOrigins' configuration. Which origins may use the dev server: its HTTP endpoints, the hot module replacement connection and proxied WebSockets. 'auto' (the default) keeps the rules derived from 'allowedHosts'. A list of HTTP(S) origins allows those plus the page's own origin, the host and port it reached the server by, independently of 'allowedHosts'. 'all' allows every origin without turning off the 'Host' check.
--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.
Expand Down Expand Up @@ -287,12 +287,13 @@ Default: `"auto"`
`"auto"`, `allowedHosts: "all"` and an `Access-Control-Allow-Origin: *` entry in
`headers` still open the server to every origin — both are **deprecated** and
will stop doing so in the next major release, so set `allowedOrigins` instead.
- A list of HTTP(S) origins allows those, plus the page's own hostname — on any
port, with `localhost`, `127.0.0.1` and `[::1]` counted as one, as with `"auto"`
— and `file:` and browser-extension pages, whatever `allowedHosts` says. A
listed origin's scheme, hostname and port must match. An empty list allows only
the page's own hostname, so another page on `localhost`, on any port, can still
use a server on `localhost`.
- A list of HTTP(S) origins allows those, plus the page's own origin — the host
and port it reached the server by, exactly — and `file:` and browser-extension
pages, whatever `allowedHosts` says. A listed origin's scheme, hostname and port
must match. An empty list allows only the page's own origin: a page another
server on `localhost` serves, on another port or through `127.0.0.1`, must be
listed. The injected client is handed a token, so its hot module replacement
connection gets in even when `client.webSocketURL` points it elsewhere.
- `"all"` allows every origin. It does not turn off the `Host` check.

```js
Expand Down
12 changes: 6 additions & 6 deletions examples/allowed-origins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ WebSocket proxy. The `Host` check still applies.
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`.
- An HTTP(S) origin, or a list of them: those, plus the page's own origin (the
host and port it reached the server by, exactly), 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 origin only — not another server's page on `localhost`, on
another port or through `127.0.0.1`.
- `"all"`: every origin, with `Access-Control-Allow-Origin: *`. It does not turn
off the `Host` check.

Expand Down
89 changes: 73 additions & 16 deletions lib/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2242,7 +2242,10 @@ class Server {
return;
}

if (!this.#isSameOriginRequest(req)) {
if (
!this.#isSameOriginRequest(req) ||
!this.#isEditorRequestFromThisPage(req)
) {
res.statusCode = 403;
res.end("Cross-Origin request blocked");
return;
Expand Down Expand Up @@ -3805,6 +3808,56 @@ class Server {
return this.isSameOrigin(headers);
}

/**
* A request to open the editor that carries neither `Sec-Fetch-Site` nor
* `Origin` says nothing of the page that sent it, and a browser sends
* neither for a page that is not a secure context — any `http:` one but
* `localhost`'s. So one the server is reached by another name for — an
* address on the network — must show the page it came from: the overlay's
* `fetch` sends its own `Referer`, and, once a hot token is handed over,
* the token. Without this, any `http:` page could have the developer's
* editor open a file of its choosing, by loading the endpoint as an image.
*
* Through `localhost` it is let in as before: browsers send `Sec-Fetch-Site`
* there, so it comes from a client that is not a browser.
*
* TODO in the next major release require the token here, as on every
* endpoint that acts on the developer's machine, and drop the rest.
* @param {Request} req request
* @returns {boolean} true when it can be trusted to come from this server's own page
*/
#isEditorRequestFromThisPage(req) {
const headers =
/** @type {{ [key: string]: string | undefined }} */
(req.headers);

// Already judged by `#isSameOriginRequest`.
if (
typeof headers["sec-fetch-site"] === "string" ||
typeof headers.origin === "string"
) {
return true;
}

const hostHeader = headers[":authority"] || headers.host;
const hostname =
typeof hostHeader === "string"
? this.#parseHostnameFromHeader(hostHeader)
: null;

if (hostname !== null && LOOPBACK_HOSTNAMES.has(hostname)) {
return true;
}

const refererOrigin = this.#parseOrigin(headers.referer);

return (
(refererOrigin !== null &&
this.#isListedOrSameOrigin(refererOrigin, headers)) ||
this.#isHotTokenValid(req)
);
}

/**
* Whether a request's origin may use this server, by `allowedOrigins` — or
* by `allowedHosts`, as it always was, when that is "auto".
Expand Down Expand Up @@ -3854,8 +3907,11 @@ class Server {

/**
* 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?
* page's own — the host and port of `Host`, exactly — or one it lists?
*
* Not the hostname on any port, as "auto" counts it: every other server on
* the machine shares `localhost`, and a page it serves must not read this
* one's code, or its hot updates, unless it is listed.
* @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
Expand Down Expand Up @@ -3886,21 +3942,24 @@ class Server {
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) {
// A bare `host[:port]`, not one a URL would read part of as a path or
// credentials.
if (typeof hostHeader !== "string" || !/^[^\s/\\?#@]+$/.test(hostHeader)) {
return false;
}

return (
originHostname === hostname ||
(LOOPBACK_HOSTNAMES.has(originHostname) &&
LOOPBACK_HOSTNAMES.has(hostname))
);
const { protocol, host } = new URL(origin);

try {
// `Host` read as the origin's scheme reads it, so a default port given
// or left out compares the same. The scheme itself is not compared: a
// proxy that ends TLS in front of this server is still this server.
return new URL(`${protocol}//${hostHeader}`).host === host;
} catch {
return false;
}
}

/**
Expand Down Expand Up @@ -3966,9 +4025,7 @@ class Server {

response.setHeader("Access-Control-Allow-Origin", headers.origin);
// Credentials only for an origin the developer listed by name: the
// page's own hostname is allowed on any port, and cookies do not tell
// ports apart, so extending them to it would let any other page on
// `localhost` read a cookie-authenticated answer.
// page's own needs none, and a `file:` or extension page is not one.
const origin = this.#parseOrigin(headers.origin);

if (
Expand Down
2 changes: 1 addition & 1 deletion lib/options.check.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/options.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"$ref": "#/definitions/AllowedOriginsItem"
}
],
"description": "Which origins may use the dev server: its HTTP endpoints, the hot module replacement connection and proxied WebSockets. 'auto' (the default) keeps the rules derived from 'allowedHosts'. A list of HTTP(S) origins allows those plus the page's own hostname, on any port, independently of 'allowedHosts'. 'all' allows every origin without turning off the 'Host' check.",
"description": "Which origins may use the dev server: its HTTP endpoints, the hot module replacement connection and proxied WebSockets. 'auto' (the default) keeps the rules derived from 'allowedHosts'. A list of HTTP(S) origins allows those plus the page's own origin, the host and port it reached the server by, independently of 'allowedHosts'. 'all' allows every origin without turning off the 'Host' check.",
"link": "https://github.com/webpack/webpack-dev-server#allowedorigins"
},
"AllowedOriginsItem": {
Expand Down
4 changes: 2 additions & 2 deletions test/cli/__snapshots__/basic.test.js.snap.webpack5
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ Options
--watch-options-stdin Stop watching when stdin stream has ended.
--allowed-hosts <value...> 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 <value...> Which origins may use the dev server: its HTTP endpoints, the hot module replacement connection and proxied WebSockets. 'auto' (the default) keeps the rules derived from 'allowedHosts'. A list of HTTP(S) origins allows those plus the page's own hostname, on any port, independently of 'allowedHosts'. 'all' allows every origin without turning off the 'Host' check.
--allowed-origins-reset Clear all items provided in 'allowedOrigins' configuration. Which origins may use the dev server: its HTTP endpoints, the hot module replacement connection and proxied WebSockets. 'auto' (the default) keeps the rules derived from 'allowedHosts'. A list of HTTP(S) origins allows those plus the page's own hostname, on any port, independently of 'allowedHosts'. 'all' allows every origin without turning off the 'Host' check.
--allowed-origins <value...> Which origins may use the dev server: its HTTP endpoints, the hot module replacement connection and proxied WebSockets. 'auto' (the default) keeps the rules derived from 'allowedHosts'. A list of HTTP(S) origins allows those plus the page's own origin, the host and port it reached the server by, independently of 'allowedHosts'. 'all' allows every origin without turning off the 'Host' check.
--allowed-origins-reset Clear all items provided in 'allowedOrigins' configuration. Which origins may use the dev server: its HTTP endpoints, the hot module replacement connection and proxied WebSockets. 'auto' (the default) keeps the rules derived from 'allowedHosts'. A list of HTTP(S) origins allows those plus the page's own origin, the host and port it reached the server by, independently of 'allowedHosts'. 'all' allows every origin without turning off the 'Host' check.
--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.
Expand Down
31 changes: 28 additions & 3 deletions test/e2e/allowed-origins.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,36 @@ describe("allowedOrigins as a list", () => {
.expect(200);
});

it("gives credentials to a listed origin only, not to another port of its own hostname", async () => {
it("refuses another server's page on the same machine, as it does not list it", async () => {
for (const origin of [
`http://localhost:${pagePort}`,
`http://127.0.0.1:${port}`,
]) {
const response = await request(serverOrigin)
.get("/main.js")
.set("Origin", origin)
.expect(403);

expect(response.headers["access-control-allow-origin"]).toBeUndefined();
expect(await connect(origin)).toEqual({
action: "error",
message: "Invalid Host/Origin header",
});
}

await request(serverOrigin)
.get("/main.js")
.set("Sec-Fetch-Site", "same-site")
.set("Sec-Fetch-Mode", "no-cors")
.set("Referer", `http://localhost:${pagePort}/`)
.expect("Cross-Origin-Resource-Policy", "same-origin");
});

it("answers the page's own origin without credentials", async () => {
const response = await request(serverOrigin)
.get("/main.js")
.set("Origin", `http://localhost:${pagePort}`)
.expect("Access-Control-Allow-Origin", `http://localhost:${pagePort}`)
.set("Origin", serverOrigin)
.expect("Access-Control-Allow-Origin", serverOrigin)
.expect(200);

expect(
Expand Down
53 changes: 51 additions & 2 deletions test/server/allowed-origins.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,20 @@ describe("allowedOrigins", () => {
for (const origin of [
"https://app.example.com:3000",
"http://localhost:8080",
"http://localhost:3000",
"http://127.0.0.1:8080",
// Through a proxy that ends TLS in front of the server.
"https://localhost:8080",
"file:///tmp/index.html",
"chrome-extension://abcdef",
]) {
expect(server.isOriginAllowed({ ...host, origin })).toBe(true);
}

for (const origin of [
// Another server's page on the same machine, unless listed.
"http://localhost:3000",
"http://127.0.0.1:8080",
"http://[::1]:8080",
"http://app.localhost:8080",
"http://app.example.com:3000",
"https://app.example.com:3001",
"https://app.example.com.evil.test:3000",
Expand Down Expand Up @@ -123,9 +128,53 @@ describe("allowedOrigins", () => {
expect(
server.isOriginAllowed({
host: "dev.example.com:8080",
origin: "https://dev.example.com:8080",
}),
).toBe(true);
});

it("compares the page's own origin with `Host` by host and port exactly", async () => {
const server = await createServer({ allowedOrigins: [] });

expect(
server.isOriginAllowed({
host: "localhost:80",
origin: "http://localhost",
}),
).toBe(true);
expect(
server.isOriginAllowed({
host: "dev.example.com",
origin: "https://dev.example.com",
}),
).toBe(true);
expect(
server.isOriginAllowed({
host: "LOCALHOST:8080",
origin: "http://localhost:8080",
}),
).toBe(true);
expect(
server.isOriginAllowed({
host: "dev.example.com",
origin: "https://dev.example.com:8443",
}),
).toBe(false);
expect(
server.isOriginAllowed({
host: "local host",
origin: "http://localhost",
}),
).toBe(false);

for (const hostHeader of ["evil@localhost", "localhost/x", ""]) {
expect(
server.isOriginAllowed({
host: hostHeader,
origin: "http://localhost",
}),
).toBe(false);
}
});

it("serializes each entry and drops an 'auto' among them", async () => {
Expand Down
Loading
Loading