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
18 changes: 17 additions & 1 deletion lib/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3343,10 +3343,20 @@ class Server {
return true;
}

const loopbacks = new Set(["localhost", "127.0.0.1", "::1"]);

// url.parse cannot handle bare IPv6 like "::1", need to bracket it first
const hostForParsing =
loopbacks.has(hostHeader) && hostHeader.includes(":")
? `[${hostHeader}]`
: hostHeader;

// 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}`,
/^(.+:)?\/\//.test(hostForParsing)
? hostForParsing
: `//${hostForParsing}`,
false,
true,
).hostname;
Expand All @@ -3359,6 +3369,12 @@ class Server {
return true;
}

// Treat all loopback aliases as equivalent, localhost may resolve to
// 127.0.0.1 or ::1 depending on the OS, causing a false mismatch.
if (loopbacks.has(origin) && loopbacks.has(host)) {
return true;
}

return origin === host;
}

Expand Down
24 changes: 24 additions & 0 deletions test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`allowed hosts check host headers should NOT allow websocket connection when origin is a non-loopback address mismatching host (loopback fix must not widen trust): console messages 1`] = `[]`;

exports[`allowed hosts check host headers should NOT allow websocket connection when origin is a non-loopback address mismatching host (loopback fix must not widen trust): page errors 1`] = `[]`;

exports[`allowed hosts check host headers should NOT allow websocket connection when origin is a non-loopback address mismatching host (loopback fix must not widen trust): response status 1`] = `200`;

exports[`allowed hosts check host headers should allow hosts in allowedHosts: console messages 1`] = `[]`;

exports[`allowed hosts check host headers should allow hosts in allowedHosts: page errors 1`] = `[]`;
Expand All @@ -12,6 +18,24 @@ exports[`allowed hosts check host headers should allow hosts that pass a wildcar

exports[`allowed hosts check host headers should allow hosts that pass a wildcard in allowedHosts: response status 1`] = `200`;

exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch): console messages 1`] = `[]`;

exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch): page errors 1`] = `[]`;

exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch): response status 1`] = `200`;

exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch): console messages 1`] = `[]`;

exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch): page errors 1`] = `[]`;

exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch): response status 1`] = `200`;

exports[`allowed hosts check host headers should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch): console messages 1`] = `[]`;

exports[`allowed hosts check host headers should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch): page errors 1`] = `[]`;

exports[`allowed hosts check host headers should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch): response status 1`] = `200`;

exports[`allowed hosts check host headers should always allow \`localhost\` if options.allowedHosts is auto: console messages 1`] = `[]`;

exports[`allowed hosts check host headers should always allow \`localhost\` if options.allowedHosts is auto: page errors 1`] = `[]`;
Expand Down
179 changes: 179 additions & 0 deletions test/e2e/allowed-hosts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1874,5 +1874,184 @@ describe("allowed hosts", () => {

expect(pageErrors).toMatchSnapshot("page errors");
});

it("should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch)", async () => {
const options = {
allowedHosts: "auto",
host: "localhost",
port: port1,
};

server = new Server(options, compiler);

await server.start();

({ page, browser } = await runBrowser());

page
.on("console", (message) => {
consoleMessages.push(message);
})
.on("pageerror", (error) => {
pageErrors.push(error);
});

// Simulate: browser opens from localhost, but OS resolved
// 'localhost' to '127.0.0.1' so host header is the IP
const headersLocalhostOriginIPv4Host = {
host: "127.0.0.1",
origin: "http://localhost",
};

if (!server.isSameOrigin(headersLocalhostOriginIPv4Host)) {
throw new Error(
"isSameOrigin should treat localhost and 127.0.0.1 as equivalent loopback addresses",
);
}

const response = await page.goto(`http://localhost:${port1}/main.js`, {
waitUntil: "networkidle0",
});

expect(response.status()).toMatchSnapshot("response status");
expect(consoleMessages.map((message) => message.text())).toMatchSnapshot(
"console messages",
);
expect(pageErrors).toMatchSnapshot("page errors");
});

it("should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch)", async () => {
const options = {
allowedHosts: "auto",
host: "localhost",
port: port1,
};

server = new Server(options, compiler);

await server.start();

({ page, browser } = await runBrowser());

page
.on("console", (message) => {
consoleMessages.push(message);
})
.on("pageerror", (error) => {
pageErrors.push(error);
});

// Simulate: browser opens from localhost, but OS resolved
// 'localhost' to '::1' (IPv6) so host header is the IPv6 address
const headersLocalhostOriginIPv6Host = {
host: "::1",
origin: "http://localhost",
};

if (!server.isSameOrigin(headersLocalhostOriginIPv6Host)) {
throw new Error(
"isSameOrigin should treat localhost and ::1 as equivalent loopback addresses",
);
}

const response = await page.goto(`http://localhost:${port1}/main.js`, {
waitUntil: "networkidle0",
});

expect(response.status()).toMatchSnapshot("response status");
expect(consoleMessages.map((message) => message.text())).toMatchSnapshot(
"console messages",
);
expect(pageErrors).toMatchSnapshot("page errors");
});

it("should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch)", async () => {
const options = {
allowedHosts: "auto",
host: "127.0.0.1",
port: port1,
};

server = new Server(options, compiler);

await server.start();

({ page, browser } = await runBrowser());

page
.on("console", (message) => {
consoleMessages.push(message);
})
.on("pageerror", (error) => {
pageErrors.push(error);
});

// Reverse of above: server bound to 127.0.0.1, but browser
// sent origin header using 'localhost' name
const headersIPv4OriginLocalhostHost = {
host: "localhost",
origin: "http://127.0.0.1",
};

if (!server.isSameOrigin(headersIPv4OriginLocalhostHost)) {
throw new Error(
"isSameOrigin should treat 127.0.0.1 and localhost as equivalent loopback addresses",
);
}

const response = await page.goto(`http://127.0.0.1:${port1}/main.js`, {
waitUntil: "networkidle0",
});

expect(response.status()).toMatchSnapshot("response status");
expect(consoleMessages.map((message) => message.text())).toMatchSnapshot(
"console messages",
);
expect(pageErrors).toMatchSnapshot("page errors");
});

it("should NOT allow websocket connection when origin is a non-loopback address mismatching host (loopback fix must not widen trust)", async () => {
const options = {
allowedHosts: "auto",
host: "localhost",
port: port1,
};

server = new Server(options, compiler);

await server.start();

({ page, browser } = await runBrowser());

page
.on("console", (message) => {
consoleMessages.push(message);
})
.on("pageerror", (error) => {
pageErrors.push(error);
});

// A real external origin must never pass as loopback equivalent.
const headersExternalOrigin = {
host: "localhost",
origin: "http://evil.example.com",
};

if (server.isSameOrigin(headersExternalOrigin)) {
throw new Error(
"isSameOrigin must NOT allow external origins to match loopback host",
);
}

const response = await page.goto(`http://localhost:${port1}/main.js`, {
waitUntil: "networkidle0",
});

expect(response.status()).toMatchSnapshot("response status");
expect(consoleMessages.map((message) => message.text())).toMatchSnapshot(
"console messages",
);
expect(pageErrors).toMatchSnapshot("page errors");
});
});
});