From 660506e54aa02c7c94386705ae654fd238fce606 Mon Sep 17 00:00:00 2001 From: Mateusz Wronski Date: Wed, 7 Oct 2026 18:32:04 +0200 Subject: [PATCH] feat: add proxy support for HTTP requests and update README --- README.md | 14 +++++++ package-lock.json | 66 +++++++++++++++++++++++++++++++ package.json | 3 ++ src/support/http/index.js | 3 +- src/support/http/proxy-agent.js | 25 ++++++++++++ test/support/proxy-agent.test.js | 67 ++++++++++++++++++++++++++++++++ 6 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/support/http/proxy-agent.js create mode 100644 test/support/proxy-agent.test.js diff --git a/README.md b/README.md index ee2785c4..2f4003c8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,20 @@ $ swaggerhub configure Environment variables take precedence over the configuration file created by this command. +# Using a proxy + +The CLI honors the standard proxy environment variables (lowercase variants also work): + +``` +export HTTPS_PROXY=http://proxy.example.com:8080 # used for https:// URLs, including the default https://api.swaggerhub.com +export HTTP_PROXY=http://proxy.example.com:8080 # used for http:// URLs, e.g. an on-premise instance served over HTTP +export NO_PROXY=localhost,.internal.example.com # hosts that should bypass the proxy +``` + +Proxy credentials can be included in the URL: `http://user:password@proxy.example.com:8080`. Always include the scheme (`http://` or `https://`) in the proxy URL. + +HTTPS requests are tunnelled through the proxy using `CONNECT`. If your proxy intercepts TLS, see `NODE_EXTRA_CA_CERTS` below. SOCKS proxies are not supported. + # Additional configuration for SwaggerHub On-Premise If your SwaggerHub On-Premise instance uses a **self-signed or privately signed SSL certificate**, there are additional steps required to make the SwaggerHub CLI trust this certificate. diff --git a/package-lock.json b/package-lock.json index cd892c09..e1c11a04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,13 @@ "archiver": "^7.0.1", "deep-extend": "^0.6.0", "fs-extra": "^11.1.1", + "http-proxy-agent": "^9.1.0", + "https-proxy-agent": "^9.1.0", "inquirer": "^8.2.5", "js-yaml": "^5.2.2", "json-templates": "^5.0.0", "node-fetch": "^2.6.12", + "proxy-from-env": "^2.1.0", "semver": "^7.6.0", "unzipper": "^0.12.3" }, @@ -1630,6 +1633,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -3681,6 +3693,34 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -7112,6 +7152,32 @@ "node": ">= 8" } }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index bd08a9f7..be9ded18 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,13 @@ "archiver": "^7.0.1", "deep-extend": "^0.6.0", "fs-extra": "^11.1.1", + "http-proxy-agent": "^9.1.0", + "https-proxy-agent": "^9.1.0", "inquirer": "^8.2.5", "js-yaml": "^5.2.2", "json-templates": "^5.0.0", "node-fetch": "^2.6.12", + "proxy-from-env": "^2.1.0", "semver": "^7.6.0", "unzipper": "^0.12.3" }, diff --git a/src/support/http/index.js b/src/support/http/index.js index 1c7401db..21f91e85 100644 --- a/src/support/http/index.js +++ b/src/support/http/index.js @@ -2,6 +2,7 @@ const fetch = require('node-fetch') const qs = require('querystring') const headerTemplates = require('./header-templates') const defaultHeaders = require('./default-headers') +const { agentFor } = require('./proxy-agent') const { pipe, pick, omit } = require('../../utils/general') @@ -34,7 +35,7 @@ const parseHeaders = options => { } } -const request = ({ url, ...options }) => fetch(url, options) +const request = ({ url, ...options }) => fetch(url, { agent: agentFor, ...options }) module.exports = options => ( pipe(parseQuery, parseUrl, parseHeaders, request)(options) diff --git a/src/support/http/proxy-agent.js b/src/support/http/proxy-agent.js new file mode 100644 index 00000000..7c2ea7f9 --- /dev/null +++ b/src/support/http/proxy-agent.js @@ -0,0 +1,25 @@ +const { getProxyForUrl } = require('proxy-from-env') +const { HttpsProxyAgent } = require('https-proxy-agent') +const { HttpProxyAgent } = require('http-proxy-agent') + +const normalizeProxy = proxy => { + try { + return new URL(proxy).href + } catch { + throw new Error('Invalid proxy URL in HTTP_PROXY/HTTPS_PROXY environment variable') + } +} + +const agentFor = parsedUrl => { + const proxy = getProxyForUrl(parsedUrl.href) + if (!proxy) { + return undefined + } + + const proxyUrl = normalizeProxy(proxy) + return parsedUrl.protocol === 'https:' ? + new HttpsProxyAgent(proxyUrl) : + new HttpProxyAgent(proxyUrl) +} + +module.exports = { agentFor } diff --git a/test/support/proxy-agent.test.js b/test/support/proxy-agent.test.js new file mode 100644 index 00000000..54db86b0 --- /dev/null +++ b/test/support/proxy-agent.test.js @@ -0,0 +1,67 @@ +const { expect } = require('@oclif/test') +const { HttpsProxyAgent } = require('https-proxy-agent') +const { HttpProxyAgent } = require('http-proxy-agent') +const { agentFor } = require('../../src/support/http/proxy-agent') + +const proxyKeys = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy'] + +describe('proxy-agent', () => { + let saved + + beforeEach(() => { + saved = {} + proxyKeys.forEach(key => { + saved[key] = process.env[key] + delete process.env[key] + }) + }) + + afterEach(() => { + proxyKeys.forEach(key => { + if (saved[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = saved[key] + } + }) + }) + + it('should return undefined when no proxy is configured', () => { + expect(agentFor(new URL('https://api.swaggerhub.com/apis'))).to.equal(undefined) + }) + + it('should use HTTPS_PROXY for https URLs', () => { + process.env.HTTPS_PROXY = 'http://proxy.test:8080' + const agent = agentFor(new URL('https://api.swaggerhub.com/apis')) + expect(agent).to.be.instanceOf(HttpsProxyAgent) + expect(agent.proxy.href).to.equal('http://proxy.test:8080/') + }) + + it('should use HTTP_PROXY for http URLs', () => { + process.env.HTTP_PROXY = 'http://proxy.test:8080' + expect(agentFor(new URL('http://swaggerhub.test/v1/apis'))).to.be.instanceOf(HttpProxyAgent) + }) + + it('should not use HTTP_PROXY for https URLs', () => { + process.env.HTTP_PROXY = 'http://proxy.test:8080' + expect(agentFor(new URL('https://api.swaggerhub.com/apis'))).to.equal(undefined) + }) + + it('should bypass the proxy for hosts matching NO_PROXY', () => { + process.env.HTTPS_PROXY = 'http://proxy.test:8080' + process.env.NO_PROXY = 'swaggerhub.internal' + expect(agentFor(new URL('https://swaggerhub.internal/v1/apis'))).to.equal(undefined) + }) + + it('should preserve proxy credentials', () => { + process.env.HTTPS_PROXY = 'http://user:pass@proxy.test:8080' + const { proxy } = agentFor(new URL('https://api.swaggerhub.com/apis')) + expect(proxy.username).to.equal('user') + expect(proxy.password).to.equal('pass') + }) + + it('should throw a clear error for an invalid proxy URL', () => { + process.env.HTTPS_PROXY = 'http://' + expect(() => agentFor(new URL('https://api.swaggerhub.com/apis'))).to.throw(/Invalid proxy URL/) + }) +})