diff --git a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js new file mode 100644 index 0000000..e5f5902 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js @@ -0,0 +1,172 @@ +import { before, after, describe, it } from "node:test"; +import assert from "node:assert"; +import net from "net"; +import tls from "tls"; +import { + createSafeChainProxy, + mergeSafeChainProxyEnvironmentVariables, +} from "./registryProxy.js"; + +describe("registryProxy.connectTunnel", () => { + let proxy, proxyHost, proxyPort; + + before(async () => { + proxy = createSafeChainProxy(); + await proxy.startServer(); + const envVars = mergeSafeChainProxyEnvironmentVariables([]); + const proxyUrl = new URL(envVars.HTTPS_PROXY); + proxyHost = proxyUrl.hostname; + proxyPort = parseInt(proxyUrl.port, 10); + }); + + after(async () => { + await proxy.stopServer(); + }); + + it("should establish a tunnel for HTTP connect", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + const tunnelResponse = await establishHttpsTunnel( + socket, + "postman-echo.com", + 443 + ); + + assert.ok(tunnelResponse.includes("HTTP/1.1 200 Connection Established")); + socket.destroy(); + }); + + it("should send HTTPS request through the established tunnel", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + await establishHttpsTunnel(socket, "postman-echo.com", 443); + const httpsResponse = await sendHttpsRequestThroughTunnel( + socket, + "GET", + new URL("https://postman-echo.com/status/200") + ); + + assert.ok(httpsResponse.includes("HTTP/1.1 200 OK")); + + socket.destroy(); + }); + + describe("Error Handling", () => { + it("should return 502 Bad Gateway for invalid hostname", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + const connectRequest = `CONNECT invalid.hostname.that.does.not.exist:443 HTTP/1.1\r\nHost: invalid.hostname.that.does.not.exist:443\r\n\r\n`; + socket.write(connectRequest); + + let responseData = ""; + await new Promise((resolve) => { + socket.once("data", (data) => { + responseData += data.toString(); + resolve(); + }); + }); + + assert.ok(responseData.includes("HTTP/1.1 502 Bad Gateway")); + socket.destroy(); + }); + + it("should handle client disconnect during tunnel establishment", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + const connectRequest = `CONNECT postman-echo.com:443 HTTP/1.1\r\nHost: postman-echo.com:443\r\n\r\n`; + socket.write(connectRequest); + + // Immediately destroy the socket before tunnel is fully established + socket.destroy(); + + // If no crash occurs, the test passes + assert.ok(true); + }); + + + it("should handle socket errors without crashing", async () => { + const socket = await connectToProxy(proxyHost, proxyPort); + + socket.on("error", () => { + // Error handler is set to prevent crashes + }); + + const connectRequest = `CONNECT postman-echo.com:443 HTTP/1.1\r\nHost: postman-echo.com:443\r\n\r\n`; + socket.write(connectRequest); + + // Force an error by destroying the socket + socket.destroy(); + + // Wait a bit to ensure error handling completes + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Test passes if no unhandled error crashes the process + assert.ok(true); + }); + + }); +}); + +function connectToProxy(host, port) { + return new Promise((resolve, reject) => { + const socket = net.connect({ host, port }, () => { + resolve(socket); + }); + + socket.on("error", (err) => { + reject(err); + }); + }); +} + +function establishHttpsTunnel(socket, targetHost, targetPort) { + return new Promise((resolve, reject) => { + const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n\r\n`; + socket.write(connectRequest); + + let responseData = ""; + const onData = (data) => { + responseData += data.toString(); + if (responseData.includes("\r\n\r\n")) { + socket.removeListener("data", onData); + socket.removeListener("error", onError); + resolve(responseData); + } + }; + + const onError = (err) => { + socket.removeListener("data", onData); + socket.removeListener("error", onError); + reject(err); + }; + + socket.on("data", onData); + socket.on("error", onError); + }); +} + +function sendHttpsRequestThroughTunnel(socket, verb, url) { + return new Promise((resolve, reject) => { + const tlsSocket = tls.connect( + { + socket: socket, + servername: url.hostname, + }, + () => { + tlsSocket.write( + `${verb} ${url.pathname} HTTP/1.1\r\nHost: ${url.hostname}\r\nConnection: close\r\n\r\n` + ); + } + ); + + let tlsData = ""; + + tlsSocket.on("data", (data) => { + tlsData += data.toString(); + }); + + tlsSocket.on("end", () => { + resolve(tlsData); + }); + + tlsSocket.on("error", (err) => { + reject(err); + }); + }); +} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.http-proxy.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.http-proxy.spec.js new file mode 100644 index 0000000..970543c --- /dev/null +++ b/packages/safe-chain/src/registryProxy/registryProxy.http-proxy.spec.js @@ -0,0 +1,225 @@ +import { before, after, describe, it } from "node:test"; +import assert from "node:assert"; +import http from "http"; +import { + createSafeChainProxy, + mergeSafeChainProxyEnvironmentVariables, +} from "./registryProxy.js"; + +describe("registryProxy.httpProxy", () => { + let proxy, proxyHost, proxyPort; + let testHttpServer, testHttpServerPort; + + before(async () => { + // Start safe-chain proxy + proxy = createSafeChainProxy(); + await proxy.startServer(); + const envVars = mergeSafeChainProxyEnvironmentVariables([]); + const proxyUrl = new URL(envVars.HTTPS_PROXY); + proxyHost = proxyUrl.hostname; + proxyPort = parseInt(proxyUrl.port, 10); + + // Start a test HTTP server to forward requests to + testHttpServer = http.createServer((req, res) => { + if (req.url === "/test") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("HTTP test response"); + } else if (req.url === "/echo-headers") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(req.headers)); + } else if (req.url === "/echo-method") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(req.method); + } else if (req.url === "/post-echo") { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(body); + }); + } else if (req.url === "/404") { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + } else { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("OK"); + } + }); + + testHttpServerPort = await new Promise((resolve) => { + testHttpServer.listen(0, () => { + resolve(testHttpServer.address().port); + }); + }); + }); + + after(async () => { + await proxy.stopServer(); + await new Promise((resolve) => { + testHttpServer.close(() => resolve()); + setTimeout(resolve, 1000); + }); + }); + + it("should forward HTTP GET requests", async () => { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + `http://localhost:${testHttpServerPort}/test`, + "GET" + ); + + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body, "HTTP test response"); + }); + + it("should forward HTTP POST requests with body", async () => { + const postData = "test post data"; + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + `http://localhost:${testHttpServerPort}/post-echo`, + "POST", + postData + ); + + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body, postData); + }); + + it("should preserve request headers", async () => { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + `http://localhost:${testHttpServerPort}/echo-headers`, + "GET", + null, + { + "X-Custom-Header": "test-value", + "User-Agent": "test-agent/1.0", + } + ); + + assert.strictEqual(response.statusCode, 200); + const headers = JSON.parse(response.body); + assert.strictEqual(headers["x-custom-header"], "test-value"); + assert.strictEqual(headers["user-agent"], "test-agent/1.0"); + }); + + it("should preserve HTTP methods", async () => { + const methods = ["GET", "POST", "PUT", "DELETE"]; + + for (const method of methods) { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + `http://localhost:${testHttpServerPort}/echo-method`, + method + ); + + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body, method); + } + }); + + it("should forward 404 responses correctly", async () => { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + `http://localhost:${testHttpServerPort}/404`, + "GET" + ); + + assert.strictEqual(response.statusCode, 404); + assert.strictEqual(response.body, "Not Found"); + }); + + it("should handle invalid host with 502 Bad Gateway", async () => { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + "http://invalid-host-that-does-not-exist.test:9999/test", + "GET" + ); + + assert.strictEqual(response.statusCode, 502); + assert.ok(response.body.includes("Bad Gateway")); + }); + + it("should handle HTTPS URLs sent to HTTP proxy", async () => { + // Some clients incorrectly send https:// URLs to the HTTP proxy handler + // instead of using CONNECT. The proxy should handle this gracefully. + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + "https://registry.npmjs.org/lodash", + "GET" + ); + + // Should successfully forward the HTTPS request + assert.strictEqual(response.statusCode, 200); + assert.ok(response.body.includes("lodash")); + }); + + it("should handle unsupported protocols with 502", async () => { + const response = await makeHttpProxyRequest( + proxyHost, + proxyPort, + "ftp://example.com/file.txt", + "GET" + ); + + assert.strictEqual(response.statusCode, 502); + assert.ok(response.body.includes("Unsupported protocol")); + }); +}); + +function makeHttpProxyRequest( + proxyHost, + proxyPort, + targetUrl, + method = "GET", + body = null, + extraHeaders = {} +) { + return new Promise((resolve, reject) => { + const options = { + hostname: proxyHost, + port: proxyPort, + path: targetUrl, + method: method, + headers: { + Host: new URL(targetUrl).host, + ...extraHeaders, + }, + }; + + const req = http.request(options, (res) => { + let responseBody = ""; + + res.on("data", (chunk) => { + responseBody += chunk.toString(); + }); + + res.on("end", () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body: responseBody, + }); + }); + }); + + req.on("error", (err) => { + reject(err); + }); + + if (body) { + req.write(body); + } + + req.end(); + }); +} diff --git a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js new file mode 100644 index 0000000..515284c --- /dev/null +++ b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js @@ -0,0 +1,261 @@ +import { before, after, describe, it } from "node:test"; +import assert from "node:assert"; +import net from "net"; +import tls from "tls"; +import { + createSafeChainProxy, + mergeSafeChainProxyEnvironmentVariables, +} from "./registryProxy.js"; +import { getCaCertPath } from "./certUtils.js"; +import fs from "fs"; + +describe("registryProxy.mitm", () => { + let proxy, proxyHost, proxyPort; + + before(async () => { + proxy = createSafeChainProxy(); + await proxy.startServer(); + const envVars = mergeSafeChainProxyEnvironmentVariables([]); + const proxyUrl = new URL(envVars.HTTPS_PROXY); + proxyHost = proxyUrl.hostname; + proxyPort = parseInt(proxyUrl.port, 10); + }); + + after(async () => { + await proxy.stopServer(); + }); + + it("should intercept HTTPS requests to npm registry", async () => { + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash" + ); + + assert.strictEqual(response.statusCode, 200); + assert.ok(response.body.includes("lodash")); + }); + + it("should allow non-malicious package downloads", async () => { + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash/-/lodash-4.17.21.tgz" + ); + + // Should get a response (200 or redirect, but not 403 blocked) + assert.notStrictEqual(response.statusCode, 403); + }); + + it("should handle 404 responses correctly", async () => { + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/this-package-definitely-does-not-exist-12345" + ); + + assert.strictEqual(response.statusCode, 404); + }); + + it("should handle query parameters in URL", async () => { + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash?write=true" + ); + + assert.strictEqual(response.statusCode, 200); + }); + + it("should generate valid certificates for yarn registry", async () => { + const response = await makeRegistryRequest( + proxyHost, + proxyPort, + "registry.yarnpkg.com", + "/lodash" + ); + + assert.strictEqual(response.statusCode, 200); + }); + + it("should generate certificate with correct hostname in CN", async () => { + const { cert } = await makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash" + ); + + // Check certificate common name matches the target hostname + assert.strictEqual(cert.subject.CN, "registry.npmjs.org"); + + // Check Subject Alternative Name includes the hostname + const san = cert.subjectaltname; + assert.ok(san.includes("registry.npmjs.org")); + + // Check certificate is issued by safe-chain CA + assert.strictEqual(cert.issuer.CN, "safe-chain proxy"); + }); + + it("should generate different certificates for different hostnames", async () => { + const { cert: cert1 } = await makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash" + ); + + const { cert: cert2 } = await makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + "registry.yarnpkg.com", + "/lodash" + ); + + // Different hostnames should have different certificates + assert.notStrictEqual(cert1.fingerprint, cert2.fingerprint); + assert.strictEqual(cert1.subject.CN, "registry.npmjs.org"); + assert.strictEqual(cert2.subject.CN, "registry.yarnpkg.com"); + }); + + it("should cache generated certificates for same hostname", async () => { + const { cert: cert1 } = await makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/lodash" + ); + + const { cert: cert2 } = await makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + "registry.npmjs.org", + "/package/lodash" + ); + + // Same hostname should get the same certificate (fingerprint) + assert.strictEqual(cert1.fingerprint, cert2.fingerprint); + }); +}); + +async function makeRegistryRequest(proxyHost, proxyPort, targetHost, path) { + // Step 1: Connect to proxy + const socket = await new Promise((resolve, reject) => { + const sock = net.connect({ host: proxyHost, port: proxyPort }, () => { + resolve(sock); + }); + sock.on("error", reject); + }); + + // Step 2: Send CONNECT request + await new Promise((resolve) => { + const connectRequest = `CONNECT ${targetHost}:443 HTTP/1.1\r\nHost: ${targetHost}:443\r\n\r\n`; + socket.write(connectRequest); + socket.once("data", resolve); + }); + + // Step 3: Upgrade to TLS using the proxy's CA cert + const tlsSocket = tls.connect({ + socket: socket, + servername: targetHost, + ca: fs.readFileSync(getCaCertPath()), + rejectUnauthorized: true, + }); + + await new Promise((resolve) => { + tlsSocket.on("secureConnect", resolve); + }); + + // Step 4: Send HTTP request over TLS + const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\n\r\n`; + tlsSocket.write(httpRequest); + + // Step 5: Read response + return new Promise((resolve, reject) => { + let data = ""; + + tlsSocket.on("data", (chunk) => { + data += chunk.toString(); + }); + + tlsSocket.on("end", () => { + const lines = data.split("\r\n"); + const statusLine = lines[0]; + const statusCode = parseInt(statusLine.split(" ")[1]); + + // Find body after empty line + const emptyLineIndex = lines.findIndex(line => line === ""); + const body = lines.slice(emptyLineIndex + 1).join("\r\n"); + + resolve({ statusCode, body }); + }); + + tlsSocket.on("error", reject); + }); +} + +async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, path) { + // Step 1: Connect to proxy + const socket = await new Promise((resolve, reject) => { + const sock = net.connect({ host: proxyHost, port: proxyPort }, () => { + resolve(sock); + }); + sock.on("error", reject); + }); + + // Step 2: Send CONNECT request + await new Promise((resolve) => { + const connectRequest = `CONNECT ${targetHost}:443 HTTP/1.1\r\nHost: ${targetHost}:443\r\n\r\n`; + socket.write(connectRequest); + socket.once("data", resolve); + }); + + // Step 3: Upgrade to TLS and capture certificate + const tlsSocket = tls.connect({ + socket: socket, + servername: targetHost, + ca: fs.readFileSync(getCaCertPath()), + rejectUnauthorized: true, + }); + + let peerCert; + await new Promise((resolve) => { + tlsSocket.on("secureConnect", () => { + peerCert = tlsSocket.getPeerCertificate(); + resolve(); + }); + }); + + // Step 4: Send HTTP request over TLS + const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\n\r\n`; + tlsSocket.write(httpRequest); + + // Step 5: Read response + const response = await new Promise((resolve, reject) => { + let data = ""; + + tlsSocket.on("data", (chunk) => { + data += chunk.toString(); + }); + + tlsSocket.on("end", () => { + const lines = data.split("\r\n"); + const statusLine = lines[0]; + const statusCode = parseInt(statusLine.split(" ")[1]); + + // Find body after empty line + const emptyLineIndex = lines.findIndex(line => line === ""); + const body = lines.slice(emptyLineIndex + 1).join("\r\n"); + + resolve({ statusCode, body }); + }); + + tlsSocket.on("error", reject); + }); + + return { cert: peerCert, response }; +}