diff --git a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js index 4b756d7..83a9d20 100644 --- a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js @@ -10,8 +10,9 @@ import { ui } from "../environment/userInteraction.js"; */ export function tunnelRequest(req, clientSocket, head) { const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; + const noProxy = process.env.NO_PROXY || process.env.no_proxy; - if (httpsProxy) { + if (httpsProxy && !shouldBypassProxy(req.url, noProxy)) { // If an HTTPS proxy is set, tunnel the request via the proxy // This is the system proxy, not the safe-chain proxy // The package manager will run via the safe-chain proxy @@ -85,14 +86,23 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { proxySocket.on("connect", () => { // Send CONNECT request to proxy - const connectRequest = [ + const headers = [ `CONNECT ${hostname}:${port || 443} HTTP/1.1`, `Host: ${hostname}:${port || 443}`, - "", - "", - ].join("\r\n"); + ]; - proxySocket.write(connectRequest); + if (proxy.username || proxy.password) { + const auth = Buffer.from( + `${decodeURIComponent(proxy.username)}:${decodeURIComponent( + proxy.password + )}` + ).toString("base64"); + headers.push(`Proxy-Authorization: Basic ${auth}`); + } + + headers.push("", ""); + + proxySocket.write(headers.join("\r\n")); }); let isConnected = false; @@ -145,3 +155,36 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { } }); } + +/** + * @param {string | undefined} url + * @param {string | undefined} noProxy + * @returns {boolean} + */ +function shouldBypassProxy(url, noProxy) { + if (!url || !noProxy) { + return false; + } + + if (noProxy === "*") { + return true; + } + + try { + const { hostname } = new URL(`http://${url}`); + const noProxyList = noProxy.split(",").map((s) => s.trim().toLowerCase()); + + return noProxyList.some((noProxyItem) => { + if (!noProxyItem) return false; + if (noProxyItem === hostname) return true; + // Handle domain matching (e.g. .example.com matches sub.example.com) + if (noProxyItem.startsWith(".") && hostname.endsWith(noProxyItem)) + return true; + // Handle implicit domain matching (e.g. example.com matches sub.example.com) + if (hostname.endsWith(`.${noProxyItem}`)) return true; + return false; + }); + } catch { + return false; + } +} diff --git a/test/e2e/proxy-tunneling.e2e.spec.js b/test/e2e/proxy-tunneling.e2e.spec.js new file mode 100644 index 0000000..c1c1216 --- /dev/null +++ b/test/e2e/proxy-tunneling.e2e.spec.js @@ -0,0 +1,85 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: Safe chain proxy tunneling", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + it("should tunnel non-registry traffic via upstream proxy with authentication", async () => { + // 1. Configure TinyProxy with Basic Auth + const proxySetup = await container.openShell("zsh"); + await proxySetup.runCommand( + `echo 'BasicAuth user password' >> /etc/tinyproxy/tinyproxy.conf` + ); + await proxySetup.runCommand("service tinyproxy restart || tinyproxy"); + + // 2. Setup a test script that makes a non-registry request (using curl) + const setupShell = await container.openShell("zsh"); + // We use www.example.com as a stable target + await setupShell.runCommand('npm pkg set scripts.test-curl="curl -v -I https://www.example.com"'); + + // 3. Run the test script with HTTPS_PROXY set to the authenticated upstream proxy + const testShell = await container.openShell("zsh"); + // Set the upstream proxy with credentials + await testShell.runCommand('export HTTPS_PROXY="http://user:password@localhost:8888"'); + + // Run the script via npm (which is wrapped by safe-chain) + // safe-chain should inject its own proxy, which then tunnels to the upstream proxy + const { output, command } = await testShell.runCommand("npm run test-curl"); + + // 4. Verify the result + // If safe-chain fails to authenticate with upstream, we expect a failure + // curl -I returns HTTP 200 OK if successful + + const success = output.includes("HTTP/2 200") || output.includes("HTTP/1.1 200"); + + if (!success) { + console.log("Test failed. Output:", output); + } + + assert.ok(success, "curl should successfully connect to example.com via the authenticated proxy"); + }); + + it("should respect NO_PROXY and bypass upstream proxy", async () => { + // 1. Setup a test script + const setupShell = await container.openShell("zsh"); + await setupShell.runCommand('npm pkg set scripts.test-curl="curl -v -I https://www.example.com"'); + + // 2. Set a BROKEN upstream proxy, but exclude example.com via NO_PROXY + const testShell = await container.openShell("zsh"); + await testShell.runCommand('export HTTPS_PROXY="http://non-existent-proxy:1234"'); + await testShell.runCommand('export NO_PROXY="www.example.com"'); + + // 3. Run the script + // If safe-chain ignores NO_PROXY, it will try to use the broken proxy and fail + // If it respects NO_PROXY, it will connect directly (which works in the container) + const { output } = await testShell.runCommand("npm run test-curl"); + + const success = output.includes("HTTP/2 200") || output.includes("HTTP/1.1 200"); + + if (!success) { + console.log("NO_PROXY Test failed. Output:", output); + } + + assert.ok(success, "curl should bypass the broken proxy for NO_PROXY domains"); + }); +});