Merge pull request #423 from xandervr/security/proxy-loopback-only

Bind registry proxy to loopback only
This commit is contained in:
Sander Declerck 2026-04-30 23:46:02 -07:00 committed by GitHub
commit 00be33aa10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 73 additions and 3 deletions

View file

@ -42,7 +42,7 @@ function getSafeChainProxyEnvironmentVariables() {
return {};
}
const proxyUrl = `http://localhost:${state.port}`;
const proxyUrl = `http://127.0.0.1:${state.port}`;
const caCertPath = getCombinedCaBundlePath();
return {
@ -95,8 +95,11 @@ function createProxyServer() {
*/
function startServer(server) {
return new Promise((resolve, reject) => {
// Passing port 0 makes the OS assign an available port
server.listen(0, () => {
// Bind to loopback only. Without an explicit host, Node listens on every
// interface, turning the proxy into an unauthenticated forward proxy that
// anyone reachable on the network can use to hit the victim's localhost,
// intranet, or cloud metadata endpoints. Port 0 lets the OS pick a port.
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address && typeof address === "object") {
state.port = address.port;

View file

@ -0,0 +1,67 @@
import { before, after, describe, it } from "node:test";
import assert from "node:assert";
import net from "node:net";
import os from "node:os";
import {
createSafeChainProxy,
mergeSafeChainProxyEnvironmentVariables,
} from "./registryProxy.js";
describe("registryProxy loopback binding", () => {
let proxy, proxyPort;
before(async () => {
proxy = createSafeChainProxy();
await proxy.startServer();
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
proxyPort = parseInt(new URL(envVars.HTTPS_PROXY).port, 10);
});
after(async () => {
await proxy.stopServer();
});
it("advertises a loopback HTTPS_PROXY URL", () => {
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
const hostname = new URL(envVars.HTTPS_PROXY).hostname;
assert.ok(
hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost",
`expected loopback hostname, got ${hostname}`
);
});
it("refuses connections on non-loopback interfaces", async () => {
const externalAddrs = Object.values(os.networkInterfaces())
.flat()
.filter((iface) => iface && iface.family === "IPv4" && !iface.internal)
.map((iface) => iface.address);
if (externalAddrs.length === 0) {
// No non-loopback interface available (e.g. locked-down CI) - skip.
return;
}
for (const addr of externalAddrs) {
await new Promise((resolve, reject) => {
const sock = net.createConnection({ host: addr, port: proxyPort });
const timer = setTimeout(() => {
sock.destroy();
resolve(); // Filtered / dropped is also fine - we just don't want success.
}, 500);
sock.once("connect", () => {
clearTimeout(timer);
sock.destroy();
reject(
new Error(
`proxy accepted a connection on non-loopback ${addr}:${proxyPort}`
)
);
});
sock.once("error", () => {
clearTimeout(timer);
resolve();
});
});
}
});
});