Skip to content

Commit 4d616a2

Browse files
ThierryRakotomananabjohansebas
authored andcommitted
fix: treat loopback aliases as equivalent in isSameOrigin
- When host: localhost is configured, the OS decides at bind time whether localhost resolves to 127.0.0.1 (IPv4) or ::1 (IPv6). - This causes isSameOrigin() to reject valid WebSocket connections because the final string comparison localhost === ::1 fails, triggering an infinite reconnection loop in the browser console.
1 parent a0cf97c commit 4d616a2

2 files changed

Lines changed: 178 additions & 0 deletions

File tree

lib/Server.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3091,6 +3091,13 @@ class Server {
30913091
return true;
30923092
}
30933093

3094+
// Treat all loopback aliases as equivalent, localhost may resolve to
3095+
// 127.0.0.1 or ::1 depending on the OS, causing a false mismatch.
3096+
const loopbacks = new Set(["localhost", "127.0.0.1", "::1"]);
3097+
if (loopbacks.has(origin) && loopbacks.has(host)) {
3098+
return true;
3099+
}
3100+
30943101
return origin === host;
30953102
}
30963103

test/e2e/allowed-hosts.test.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,5 +1850,176 @@ describe("allowed hosts", () => {
18501850

18511851
t.assert.snapshot(pageErrors);
18521852
});
1853+
1854+
it("should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch)", async (t) => {
1855+
const options = {
1856+
allowedHosts: "auto",
1857+
host: "localhost",
1858+
port: port1,
1859+
};
1860+
1861+
server = new Server(options, compiler);
1862+
1863+
await server.start();
1864+
1865+
({ page, browser } = await runBrowser());
1866+
1867+
page
1868+
.on("console", (message) => {
1869+
consoleMessages.push(message);
1870+
})
1871+
.on("pageerror", (error) => {
1872+
pageErrors.push(error);
1873+
});
1874+
1875+
// Simulate: browser opens from localhost, but OS resolved
1876+
// 'localhost' to '127.0.0.1' so host header is the IP
1877+
const headersLocalhostOriginIPv4Host = {
1878+
host: "127.0.0.1",
1879+
origin: "http://localhost",
1880+
};
1881+
1882+
if (!server.isSameOrigin(headersLocalhostOriginIPv4Host)) {
1883+
throw new Error(
1884+
"isSameOrigin should treat localhost and 127.0.0.1 as equivalent loopback addresses",
1885+
);
1886+
}
1887+
1888+
const response = await page.goto(`http://localhost:${port1}/main.js`, {
1889+
waitUntil: "networkidle0",
1890+
});
1891+
1892+
t.assert.snapshot(response.status());
1893+
t.assert.snapshot(consoleMessages.map((message) => message.text()));
1894+
t.assert.snapshot(pageErrors);
1895+
});
1896+
1897+
it("should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch)", async (t) => {
1898+
const options = {
1899+
allowedHosts: "auto",
1900+
host: "localhost",
1901+
port: port1,
1902+
};
1903+
1904+
server = new Server(options, compiler);
1905+
1906+
await server.start();
1907+
1908+
({ page, browser } = await runBrowser());
1909+
1910+
page
1911+
.on("console", (message) => {
1912+
consoleMessages.push(message);
1913+
})
1914+
.on("pageerror", (error) => {
1915+
pageErrors.push(error);
1916+
});
1917+
1918+
// Simulate: browser opens from localhost, but OS resolved
1919+
// 'localhost' to '::1' (IPv6) so host header is the IPv6 address
1920+
const headersLocalhostOriginIPv6Host = {
1921+
host: "::1",
1922+
origin: "http://localhost",
1923+
};
1924+
1925+
if (!server.isSameOrigin(headersLocalhostOriginIPv6Host)) {
1926+
throw new Error(
1927+
"isSameOrigin should treat localhost and ::1 as equivalent loopback addresses",
1928+
);
1929+
}
1930+
1931+
const response = await page.goto(`http://localhost:${port1}/main.js`, {
1932+
waitUntil: "networkidle0",
1933+
});
1934+
1935+
t.assert.snapshot(response.status());
1936+
t.assert.snapshot(consoleMessages.map((message) => message.text()));
1937+
t.assert.snapshot(pageErrors);
1938+
});
1939+
1940+
it("should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch)", async (t) => {
1941+
const options = {
1942+
allowedHosts: "auto",
1943+
host: "127.0.0.1",
1944+
port: port1,
1945+
};
1946+
1947+
server = new Server(options, compiler);
1948+
1949+
await server.start();
1950+
1951+
({ page, browser } = await runBrowser());
1952+
1953+
page
1954+
.on("console", (message) => {
1955+
consoleMessages.push(message);
1956+
})
1957+
.on("pageerror", (error) => {
1958+
pageErrors.push(error);
1959+
});
1960+
1961+
// Reverse of above: server bound to 127.0.0.1, but browser
1962+
// sent origin header using 'localhost' name
1963+
const headersIPv4OriginLocalhostHost = {
1964+
host: "localhost",
1965+
origin: "http://127.0.0.1",
1966+
};
1967+
1968+
if (!server.isSameOrigin(headersIPv4OriginLocalhostHost)) {
1969+
throw new Error(
1970+
"isSameOrigin should treat 127.0.0.1 and localhost as equivalent loopback addresses",
1971+
);
1972+
}
1973+
1974+
const response = await page.goto(`http://127.0.0.1:${port1}/main.js`, {
1975+
waitUntil: "networkidle0",
1976+
});
1977+
1978+
t.assert.snapshot(response.status());
1979+
t.assert.snapshot(consoleMessages.map((message) => message.text()));
1980+
t.assert.snapshot(pageErrors);
1981+
});
1982+
1983+
it("should NOT allow websocket connection when origin is a non-loopback address mismatching host (loopback fix must not widen trust)", async (t) => {
1984+
const options = {
1985+
allowedHosts: "auto",
1986+
host: "localhost",
1987+
port: port1,
1988+
};
1989+
1990+
server = new Server(options, compiler);
1991+
1992+
await server.start();
1993+
1994+
({ page, browser } = await runBrowser());
1995+
1996+
page
1997+
.on("console", (message) => {
1998+
consoleMessages.push(message);
1999+
})
2000+
.on("pageerror", (error) => {
2001+
pageErrors.push(error);
2002+
});
2003+
2004+
// A real external origin must never pass as loopback equivalent.
2005+
const headersExternalOrigin = {
2006+
host: "localhost",
2007+
origin: "http://evil.example.com",
2008+
};
2009+
2010+
if (server.isSameOrigin(headersExternalOrigin)) {
2011+
throw new Error(
2012+
"isSameOrigin must NOT allow external origins to match loopback host",
2013+
);
2014+
}
2015+
2016+
const response = await page.goto(`http://localhost:${port1}/main.js`, {
2017+
waitUntil: "networkidle0",
2018+
});
2019+
2020+
t.assert.snapshot(response.status());
2021+
t.assert.snapshot(consoleMessages.map((message) => message.text()));
2022+
t.assert.snapshot(pageErrors);
2023+
});
18532024
});
18542025
});

0 commit comments

Comments
 (0)