Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/tidy-host-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-server": patch
---

Validate Host and HTTP/2 authority headers independently of Origin protocol exceptions, including when `allowedHosts` is `"all"`. Handle malformed open-editor URLs with a controlled response and parse their query without relying on the Host header.
29 changes: 21 additions & 8 deletions lib/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2092,7 +2092,17 @@ class Server {
return;
}

const resolveUrl = new URL(req.url, `http://${req.headers.host}`);
let resolveUrl;

try {
// Only the query is needed; parsing must not depend on the Host header.
resolveUrl = new URL(req.url, "http://localhost");
} catch {
res.statusCode = 400;
res.end("Invalid URL");
return;
}

const params = new URLSearchParams(resolveUrl.search);
const fileName = params.get("fileName");

Expand Down Expand Up @@ -3424,7 +3434,9 @@ class Server {
* @returns {boolean} true when host is valid, otherwise false
*/
isValidHost(headers, headerToCheck, validateHost = true) {
if (this.options.allowedHosts === "all") {
const isOrigin = headerToCheck === "origin";

if (isOrigin && this.options.allowedHosts === "all") {
return true;
}

Expand All @@ -3436,17 +3448,22 @@ class Server {
return false;
}

if (DEFAULT_ALLOWED_PROTOCOLS.test(header)) {
if (isOrigin && DEFAULT_ALLOWED_PROTOCOLS.test(header)) {
return true;
}

// Host and :authority contain a hostname and optional port, never a URL.
if (!isOrigin && /[/\\?#@\s]/.test(header)) {
return false;
}

const hostname = this.#parseHostnameFromHeader(header);

if (hostname === null) {
return false;
}

if (this.isHostAllowed(hostname)) {
if (this.options.allowedHosts === "all" || this.isHostAllowed(hostname)) {
return true;
}

Expand Down Expand Up @@ -3505,10 +3522,6 @@ class Server {
return this.options.allowedHosts === "all";
}

if (DEFAULT_ALLOWED_PROTOCOLS.test(hostHeader)) {
return true;
}

const host = this.#parseHostnameFromHeader(hostHeader);

if (host === null) {
Expand Down
1 change: 1 addition & 0 deletions test/ports-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const listOfTests = {
"react-refresh": 1,
"universal-target": 1,
"proxy-web-socket-origin": 2,
"host-header-validation": 1,
};

let startPort = 8089;
Expand Down
204 changes: 204 additions & 0 deletions test/server/host-header-validation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import path from "node:path";
import { after, before, beforeEach, describe, it, mock } from "node:test";
import { expect } from "expect";
import { fn } from "jest-mock";
import request from "supertest";
import webpack from "webpack";
import Server from "../../lib/Server.js";
import config from "../fixtures/simple-config/webpack.config.js";
import portsMap from "../ports-map.js";

const port = portsMap["host-header-validation"];
const allowedHostsOptions = ["auto", ["trusted.example"], "all"];
const invalidHosts = [
"file:invalid",
"chrome-extension:invalid",
"moz-extension:invalid",
"localhost:invalid",
"localhost:65536",
"http://localhost",
"//localhost",
"user@localhost",
"localhost/path",
"localhost\\path",
"localhost?query",
"localhost#fragment",
" localhost",
"localhost ",
];

describe("Host header validation", () => {
for (const allowedHosts of allowedHostsOptions) {
describe(`with allowedHosts ${JSON.stringify(allowedHosts)}`, () => {
const server = new Server({ allowedHosts });

for (const headerName of ["host", ":authority"]) {
for (const value of invalidHosts) {
it(`should reject ${headerName} ${JSON.stringify(value)}`, () => {
expect(
server.isValidHost({ [headerName]: value }, headerName),
).toBe(false);
});
}

for (const value of [
"localhost",
"localhost:8080",
"127.0.0.1:8080",
"[::1]:8080",
]) {
it(`should accept ${headerName} ${value}`, () => {
expect(
server.isValidHost({ [headerName]: value }, headerName),
).toBe(true);
});
}
}
});
}

it("should check hosts named file or chrome-extension against allowedHosts", () => {
for (const host of ["file", "chrome-extension"]) {
expect(
new Server({ allowedHosts: "auto" }).isValidHost(
{ host: `${host}:80` },
"host",
),
).toBe(false);
expect(
new Server({ allowedHosts: [host] }).isValidHost(
{ host: `${host}:80` },
"host",
),
).toBe(true);
}
});

it("should not treat a Host with an Origin protocol as same-origin", () => {
const server = new Server({ allowedHosts: "auto" });

for (const host of [
"file:invalid",
"chrome-extension:invalid",
"file:80",
]) {
expect(server.isSameOrigin({ host, origin: "http://example.com" })).toBe(
false,
);
}
});
});

describe("Origin header validation", () => {
for (const allowedHosts of allowedHostsOptions) {
describe(`with allowedHosts ${JSON.stringify(allowedHosts)}`, () => {
const server = new Server({ allowedHosts });

for (const origin of [
"file:///index.html",
"chrome-extension://extension-id",
"moz-extension://extension-id",
"http://localhost:8080",
"http://127.0.0.1:8080",
"http://[::1]:8080",
]) {
it(`should still accept Origin ${origin}`, () => {
expect(server.isValidHost({ origin }, "origin")).toBe(true);
});
}

for (const origin of [
undefined,
"null",
"http://localhost:invalid",
"http://untrusted.example",
]) {
it(`should accept Origin ${origin} only when allowedHosts is all`, () => {
expect(server.isValidHost({ origin }, "origin")).toBe(
allowedHosts === "all",
);
});
}

it("should honor the configured allowed hostname for Origin", () => {
expect(
server.isValidHost(
{ origin: "http://trusted.example:8080" },
"origin",
),
).toBe(allowedHosts !== "auto");
});
});
}
});

describe("Open editor URL handling", () => {
const launchEditor = fn();
let launchEditorMock;

before(() => {
launchEditorMock = mock.module("launch-editor", {
defaultExport: launchEditor,
});
});

after(() => {
launchEditorMock.restore();
});

beforeEach(() => {
launchEditor.mockClear();
});

for (const allowedHosts of allowedHostsOptions) {
describe(`with allowedHosts ${JSON.stringify(allowedHosts)}`, () => {
const server = new Server({ allowedHosts, port }, webpack(config));

before(async () => {
await server.start();
});

after(async () => {
await server.stop();
});

for (const host of ["file:invalid", "chrome-extension:invalid"]) {
for (const route of ["/webpack-dev-server/open-editor", "/main.js"]) {
it(`should reject Host ${host} on ${route} without exposing a stack trace`, async () => {
const response = await request(server.app)
.get(route)
.set("Host", host);

expect(response.status).toBe(403);
expect(response.text).toBe("Invalid Host header");
expect(launchEditor).not.toHaveBeenCalled();
});
}
}

it("should reject malformed request URLs without exposing a stack trace", async () => {
const response = await request(server.app)
.get("/webpack-dev-server/open-editor//[")
.set("Host", "localhost");

expect(response.status).toBe(400);
expect(response.text).toBe("Invalid URL");
expect(launchEditor).not.toHaveBeenCalled();
});

it("should still open the requested file relative to the compiler context", async () => {
const fileName = "./src/file with spaces.js:3:7";
const response = await request(server.app)
.get("/webpack-dev-server/open-editor")
.query({ fileName })
.set("Host", "localhost");

expect(response.status).toBe(200);
expect(launchEditor).toHaveBeenCalledTimes(1);
expect(launchEditor).toHaveBeenCalledWith(
path.resolve(config.context, fileName),
);
});
});
}
});
Loading