mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Add tests and clarifying comments
This commit is contained in:
parent
22b93e91f6
commit
57a0e88fa4
4 changed files with 188 additions and 32 deletions
2
.github/workflows/build-and-release.yml
vendored
2
.github/workflows/build-and-release.yml
vendored
|
|
@ -66,7 +66,7 @@ jobs:
|
||||||
- name: Publish to npm
|
- name: Publish to npm
|
||||||
run: |
|
run: |
|
||||||
echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM"
|
echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM"
|
||||||
npm publish --workspace=packages/safe-chain --access public --provenance --tag beta
|
npm publish --workspace=packages/safe-chain --access public --provenance
|
||||||
|
|
||||||
- name: Download all binary artifacts
|
- name: Download all binary artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
|
|
||||||
13
packages/safe-chain/src/registryProxy/isImdsEndpoint.js
Normal file
13
packages/safe-chain/src/registryProxy/isImdsEndpoint.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Instance Metadata Service (IMDS) endpoints used by cloud providers.
|
||||||
|
// Cloud SDK tools probe these to detect environment and retrieve credentials.
|
||||||
|
// When outside cloud environments, connections timeout - we reduce timeout (3s vs 30s)
|
||||||
|
// and suppress error logging since this is expected behavior.
|
||||||
|
const imdsEndpoints = [
|
||||||
|
"metadata.google.internal",
|
||||||
|
"metadata.goog",
|
||||||
|
"169.254.169.254",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isImdsEndpoint(/** @type {string} */ host) {
|
||||||
|
return imdsEndpoints.includes(host);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,28 @@
|
||||||
import { before, after, describe, it } from "node:test";
|
import { before, after, describe, it, mock } from "node:test";
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
import tls from "tls";
|
import tls from "tls";
|
||||||
import {
|
|
||||||
createSafeChainProxy,
|
// Mock isImdsEndpoint BEFORE any other imports that might use it
|
||||||
mergeSafeChainProxyEnvironmentVariables,
|
// This allows us to use TEST-NET-1 (192.0.2.1) as a test IMDS endpoint
|
||||||
} from "./registryProxy.js";
|
mock.module("./isImdsEndpoint.js", {
|
||||||
|
namedExports: {
|
||||||
|
isImdsEndpoint: (host) => {
|
||||||
|
// 192.0.2.1 is TEST-NET-1, reserved for testing (RFC 5737)
|
||||||
|
if (host === "192.0.2.1") return true;
|
||||||
|
// Real IMDS endpoints
|
||||||
|
return [
|
||||||
|
"metadata.google.internal",
|
||||||
|
"metadata.goog",
|
||||||
|
"169.254.169.254",
|
||||||
|
].includes(host);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use dynamic import AFTER mocking to ensure mock is applied
|
||||||
|
const { createSafeChainProxy, mergeSafeChainProxyEnvironmentVariables } =
|
||||||
|
await import("./registryProxy.js");
|
||||||
|
|
||||||
describe("registryProxy.connectTunnel", () => {
|
describe("registryProxy.connectTunnel", () => {
|
||||||
let proxy, proxyHost, proxyPort;
|
let proxy, proxyHost, proxyPort;
|
||||||
|
|
@ -62,15 +79,21 @@ describe("registryProxy.connectTunnel", () => {
|
||||||
|
|
||||||
// Verify the certificate is NOT issued by our safe-chain CA
|
// Verify the certificate is NOT issued by our safe-chain CA
|
||||||
// Our self-signed CA would have issuer: "Safe-Chain Proxy CA"
|
// Our self-signed CA would have issuer: "Safe-Chain Proxy CA"
|
||||||
assert.ok(certInfo.issuer !== undefined, "Certificate should have an issuer");
|
assert.ok(
|
||||||
|
certInfo.issuer !== undefined,
|
||||||
|
"Certificate should have an issuer"
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!certInfo.issuer.includes("Safe-Chain"),
|
!certInfo.issuer.includes("Safe-Chain"),
|
||||||
`Tunnel should use destination's real certificate, not safe-chain CA. Issuer: ${certInfo.issuer}`
|
`Tunnel should use destination's real certificate, not safe-chain CA. Issuer: ${certInfo.issuer}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify it's a real certificate with proper hostname
|
// Verify it's a real certificate with proper hostname
|
||||||
assert.strictEqual(certInfo.subject.includes("postman-echo.com"), true,
|
assert.strictEqual(
|
||||||
`Certificate subject should include postman-echo.com, got: ${certInfo.subject}`);
|
certInfo.subject.includes("postman-echo.com"),
|
||||||
|
true,
|
||||||
|
`Certificate subject should include postman-echo.com, got: ${certInfo.subject}`
|
||||||
|
);
|
||||||
|
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
});
|
});
|
||||||
|
|
@ -105,7 +128,6 @@ describe("registryProxy.connectTunnel", () => {
|
||||||
assert.ok(true);
|
assert.ok(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
it("should handle socket errors without crashing", async () => {
|
it("should handle socket errors without crashing", async () => {
|
||||||
const socket = await connectToProxy(proxyHost, proxyPort);
|
const socket = await connectToProxy(proxyHost, proxyPort);
|
||||||
|
|
||||||
|
|
@ -125,7 +147,94 @@ describe("registryProxy.connectTunnel", () => {
|
||||||
// Test passes if no unhandled error crashes the process
|
// Test passes if no unhandled error crashes the process
|
||||||
assert.ok(true);
|
assert.ok(true);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Connection Timeout", () => {
|
||||||
|
it("should timeout quickly when connecting to IMDS endpoint (3s)", async () => {
|
||||||
|
// We need to make sure we're not running behind an existing safe-chain installation to allow this test to work
|
||||||
|
const https_proxy = process.env.HTTPS_PROXY;
|
||||||
|
delete process.env.HTTPS_PROXY;
|
||||||
|
const socket = await connectToProxy(proxyHost, proxyPort);
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// 192.0.2.1 is TEST-NET-1 (RFC 5737), guaranteed to never route
|
||||||
|
const connectRequest = `CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: 192.0.2.1:443\r\n\r\n`;
|
||||||
|
socket.write(connectRequest);
|
||||||
|
|
||||||
|
let responseData = "";
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
socket.once("data", (data) => {
|
||||||
|
responseData += data.toString();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Should return 502 Bad Gateway
|
||||||
|
assert.ok(
|
||||||
|
responseData.includes("HTTP/1.1 502 Bad Gateway"),
|
||||||
|
"Should return 502 for timeout"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should timeout around 3 seconds for IMDS endpoints (allow some margin)
|
||||||
|
assert.ok(
|
||||||
|
duration >= 2800 && duration < 5000,
|
||||||
|
`IMDS timeout should be ~3s, got ${duration}ms`
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.destroy();
|
||||||
|
if (https_proxy) {
|
||||||
|
process.env.HTTPS_PROXY = https_proxy;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should cache timed-out endpoints and fail immediately on retry", async () => {
|
||||||
|
// We need to make sure we're not running behind an existing safe-chain installation to allow this test to work
|
||||||
|
const https_proxy = process.env.HTTPS_PROXY;
|
||||||
|
delete process.env.HTTPS_PROXY;
|
||||||
|
// First connection - will timeout
|
||||||
|
const socket1 = await connectToProxy(proxyHost, proxyPort);
|
||||||
|
const connectRequest = `CONNECT 192.0.2.1:80 HTTP/1.1\r\nHost: 192.0.2.1:80\r\n\r\n`;
|
||||||
|
socket1.write(connectRequest);
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
socket1.once("data", () => resolve());
|
||||||
|
});
|
||||||
|
socket1.destroy();
|
||||||
|
|
||||||
|
// Second connection - should fail immediately (cached)
|
||||||
|
const socket2 = await connectToProxy(proxyHost, proxyPort);
|
||||||
|
const startTime = Date.now();
|
||||||
|
socket2.write(connectRequest);
|
||||||
|
|
||||||
|
let responseData = "";
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
socket2.once("data", (data) => {
|
||||||
|
responseData += data.toString();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Should return 502 immediately (cached timeout)
|
||||||
|
assert.ok(
|
||||||
|
responseData.includes("HTTP/1.1 502 Bad Gateway"),
|
||||||
|
"Should return 502 for cached timeout"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should be nearly instant (< 100ms) since it's cached
|
||||||
|
assert.ok(
|
||||||
|
duration < 100,
|
||||||
|
`Cached timeout should be instant, got ${duration}ms`
|
||||||
|
);
|
||||||
|
|
||||||
|
socket2.destroy();
|
||||||
|
if (https_proxy) {
|
||||||
|
process.env.HTTPS_PROXY = https_proxy;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -167,7 +276,12 @@ function establishHttpsTunnel(socket, targetHost, targetPort) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendHttpsRequestThroughTunnel(socket, verb, url, rejectUnauthorized = false) {
|
function sendHttpsRequestThroughTunnel(
|
||||||
|
socket,
|
||||||
|
verb,
|
||||||
|
url,
|
||||||
|
rejectUnauthorized = false
|
||||||
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tlsSocket = tls.connect(
|
const tlsSocket = tls.connect(
|
||||||
{
|
{
|
||||||
|
|
@ -214,12 +328,16 @@ function getTlsCertificateInfo(socket, url) {
|
||||||
const cert = tlsSocket.getPeerCertificate();
|
const cert = tlsSocket.getPeerCertificate();
|
||||||
|
|
||||||
// Extract issuer and subject information
|
// Extract issuer and subject information
|
||||||
const issuer = cert.issuer ?
|
const issuer = cert.issuer
|
||||||
Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(", ") :
|
? Object.entries(cert.issuer)
|
||||||
"unknown";
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
const subject = cert.subject ?
|
.join(", ")
|
||||||
Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(", ") :
|
: "unknown";
|
||||||
"unknown";
|
const subject = cert.subject
|
||||||
|
? Object.entries(cert.subject)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(", ")
|
||||||
|
: "unknown";
|
||||||
|
|
||||||
tlsSocket.end();
|
tlsSocket.end();
|
||||||
resolve({ issuer, subject });
|
resolve({ issuer, subject });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as net from "net";
|
import * as net from "net";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
import { isImdsEndpoint } from "./isImdsEndpoint.js";
|
||||||
|
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
let timedoutEndpoints = [];
|
let timedoutEndpoints = [];
|
||||||
|
|
@ -40,12 +41,19 @@ export function tunnelRequest(req, clientSocket, head) {
|
||||||
*/
|
*/
|
||||||
function tunnelRequestToDestination(req, clientSocket, head) {
|
function tunnelRequestToDestination(req, clientSocket, head) {
|
||||||
const { port, hostname } = new URL(`http://${req.url}`);
|
const { port, hostname } = new URL(`http://${req.url}`);
|
||||||
|
const isImds = isImdsEndpoint(hostname);
|
||||||
|
|
||||||
if (timedoutEndpoints.includes(hostname)) {
|
if (timedoutEndpoints.includes(hostname)) {
|
||||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
|
if (isImds) {
|
||||||
|
ui.writeVerbose(
|
||||||
|
`Safe-chain: Closing connection because previously timedout connect to ${hostname}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
ui.writeError(
|
ui.writeError(
|
||||||
`Safe-chain: Closing connection because previously timedout connect to ${hostname}`
|
`Safe-chain: Closing connection because previously timedout connect to ${hostname}`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,13 +68,24 @@ function tunnelRequestToDestination(req, clientSocket, head) {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Set explicit connection timeout to avoid waiting for OS default (~2 minutes).
|
||||||
|
// IMDS endpoints get shorter timeout (3s) since they're commonly unreachable outside cloud environments.
|
||||||
const connectTimeout = getConnectTimeout(hostname);
|
const connectTimeout = getConnectTimeout(hostname);
|
||||||
serverSocket.setTimeout(connectTimeout);
|
serverSocket.setTimeout(connectTimeout);
|
||||||
|
|
||||||
serverSocket.on("timeout", () => {
|
serverSocket.on("timeout", () => {
|
||||||
timedoutEndpoints.push(hostname);
|
timedoutEndpoints.push(hostname);
|
||||||
ui.writeError(
|
// Suppress error logging for IMDS endpoints - timeouts are expected when not in cloud
|
||||||
`Safe-chain: connect to ${hostname}:${port} timed out after ${connectTimeout}ms`
|
if (isImdsEndpoint(hostname)) {
|
||||||
|
ui.writeVerbose(
|
||||||
|
`Safe-chain: connect to ${hostname}:${port || 443} timed out after ${connectTimeout}ms`
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
ui.writeError(
|
||||||
|
`Safe-chain: connect to ${hostname}:${port || 443} timed out after ${connectTimeout}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
serverSocket.destroy(); // Clean up socket to prevent event loop hanging
|
||||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -79,9 +98,15 @@ function tunnelRequestToDestination(req, clientSocket, head) {
|
||||||
});
|
});
|
||||||
|
|
||||||
serverSocket.on("error", (err) => {
|
serverSocket.on("error", (err) => {
|
||||||
|
if (isImdsEndpoint(hostname)) {
|
||||||
|
ui.writeVerbose(
|
||||||
|
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
ui.writeError(
|
ui.writeError(
|
||||||
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
|
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if (clientSocket.writable) {
|
if (clientSocket.writable) {
|
||||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
}
|
}
|
||||||
|
|
@ -167,13 +192,13 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const imdsEndpoints = [
|
/**
|
||||||
"metadata.google.internal",
|
* Returns appropriate connection timeout for a host.
|
||||||
"metadata.goog",
|
* - IMDS endpoints: 3s (fail fast when outside cloud, reduce 5min delay to ~20s)
|
||||||
"169.254.169.254",
|
* - Other endpoints: 30s (allow for slow networks while preventing indefinite hangs)
|
||||||
];
|
*/
|
||||||
function getConnectTimeout(/** @type {string} */ host) {
|
function getConnectTimeout(/** @type {string} */ host) {
|
||||||
if (imdsEndpoints.includes(host)) {
|
if (isImdsEndpoint(host)) {
|
||||||
return 3000;
|
return 3000;
|
||||||
}
|
}
|
||||||
return 30000;
|
return 30000;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue