Add tests and clarifying comments

This commit is contained in:
Sander Declerck 2025-12-05 12:09:19 +01:00
parent 22b93e91f6
commit 57a0e88fa4
No known key found for this signature in database
4 changed files with 188 additions and 32 deletions

View file

@ -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

View 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);
}

View file

@ -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 });

View file

@ -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");
ui.writeError( if (isImds) {
`Safe-chain: Closing connection because previously timedout connect to ${hostname}` ui.writeVerbose(
); `Safe-chain: Closing connection because previously timedout connect to ${hostname}`
);
} else {
ui.writeError(
`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) => {
ui.writeError( if (isImdsEndpoint(hostname)) {
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}` ui.writeVerbose(
); `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
);
} else {
ui.writeError(
`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;