Bind registry proxy to loopback only

Without an explicit host, `server.listen(0)` binds to every interface,
turning safe-chain's unauthenticated forward proxy into an open relay
while `aikido-*` commands are running. Anyone reachable on the network
can use it to hit the victim's localhost, intranet, or cloud metadata
endpoints. The advertised HTTPS_PROXY URL already used `localhost`
(loopback), but the listener itself was wide open.

Bind to 127.0.0.1 explicitly and update the advertised URL to match.
Add a regression test that verifies the listener refuses connections
on non-loopback interfaces.
This commit is contained in:
Xander Van Raemdonck 2026-04-22 21:11:27 +02:00
parent cbf830a637
commit 19d2dee5c9
No known key found for this signature in database
2 changed files with 73 additions and 3 deletions

View file

@ -42,7 +42,7 @@ function getSafeChainProxyEnvironmentVariables() {
return {}; return {};
} }
const proxyUrl = `http://localhost:${state.port}`; const proxyUrl = `http://127.0.0.1:${state.port}`;
const caCertPath = getCombinedCaBundlePath(); const caCertPath = getCombinedCaBundlePath();
return { return {
@ -95,8 +95,11 @@ function createProxyServer() {
*/ */
function startServer(server) { function startServer(server) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Passing port 0 makes the OS assign an available port // Bind to loopback only. Without an explicit host, Node listens on every
server.listen(0, () => { // 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(); const address = server.address();
if (address && typeof address === "object") { if (address && typeof address === "object") {
state.port = address.port; 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();
});
});
}
});
});