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.
52 changes: 38 additions & 14 deletions lib/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2230,7 +2230,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 @@ -3321,7 +3331,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 @@ -3333,14 +3345,24 @@ 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;
}

// use the node url-parser to retrieve the hostname from the host-header.
let hostname;

try {
// Validate authority syntax before the legacy parser, which accepts invalid ports.
if (!isOrigin && !new URL(`http://${header}`).hostname) {
return false;
}

// if header doesn't have scheme, add // for parsing.
// TODO resolve me in the next major release
// eslint-disable-next-line n/no-deprecated-api
Expand All @@ -3357,7 +3379,7 @@ class Server {
return false;
}

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

Expand Down Expand Up @@ -3419,17 +3441,19 @@ class Server {
return this.options.allowedHosts === "all";
}

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

// eslint-disable-next-line n/no-deprecated-api
const host = url.parse(
// if hostHeader doesn't have scheme, add // for parsing.
/^(.+:)?\/\//.test(hostHeader) ? hostHeader : `//${hostHeader}`,
false,
true,
).hostname;
try {
// eslint-disable-next-line n/no-deprecated-api
({ hostname: host } = url.parse(
// if hostHeader doesn't have scheme, add // for parsing.
/^(.+:)?\/\//.test(hostHeader) ? hostHeader : `//${hostHeader}`,
false,
true,
));
} catch {
return false;
}

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

let startPort = 8089;
Expand Down
184 changes: 184 additions & 0 deletions test/server/host-header-validation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"use strict";

const launchEditor = require("launch-editor");
const request = require("supertest");
const webpack = require("webpack");
const Server = require("../../lib/Server");
const config = require("../fixtures/simple-config/webpack.config");
const port = require("../ports-map")["host-header-validation"];

jest.mock("launch-editor", () => jest.fn());

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 }, webpack(config));

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" }, webpack(config)).isValidHost(
{ host: `${host}:80` },
"host",
),
).toBe(false);
expect(
new Server({ allowedHosts: [host] }, webpack(config)).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" }, webpack(config));

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 }, webpack(config));

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://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", () => {
beforeEach(() => {
launchEditor.mockClear();
});

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

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

afterAll(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 pass the requested file name to the editor", 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(fileName);
});
});
}
});
Loading