const parseUrl = require('url').parse; const https = require('node:https'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { interpolateString } = require('../ipc/network/interpolate-string'); const { SocksProxyAgent } = require('socks-proxy-agent'); const { HttpProxyAgent } = require('http-proxy-agent'); const { preferencesUtil } = require('../store/preferences'); const { isEmpty, get, isUndefined, isNull } = require('lodash'); const DEFAULT_PORTS = { ftp: 21, gopher: 70, http: 80, https: 443, ws: 80, wss: 443 }; /** * check for proxy bypass, copied form 'proxy-from-env' */ const shouldUseProxy = (url, proxyBypass) => { if (proxyBypass === '*') { return false; // Never proxy if wildcard is set. } // use proxy if no proxyBypass is set if (!proxyBypass || typeof proxyBypass !== 'string' || isEmpty(proxyBypass.trim())) { return true; } const parsedUrl = typeof url === 'string' ? parseUrl(url) : url || {}; let proto = parsedUrl.protocol; let hostname = parsedUrl.host; let port = parsedUrl.port; if (typeof hostname !== 'string' || !hostname || typeof proto !== 'string') { return false; // Don't proxy URLs without a valid scheme or host. } proto = proto.split(':', 1)[0]; // Stripping ports in this way instead of using parsedUrl.hostname to make // sure that the brackets around IPv6 addresses are kept. hostname = hostname.replace(/:\d*$/, ''); port = parseInt(port) || DEFAULT_PORTS[proto] || 0; return proxyBypass.split(/[,;\s]/).every(function (dontProxyFor) { if (!dontProxyFor) { return true; // Skip zero-length hosts. } const parsedProxy = dontProxyFor.match(/^(.+):(\d+)$/); let parsedProxyHostname = parsedProxy ? parsedProxy[1] : dontProxyFor; const parsedProxyPort = parsedProxy ? parseInt(parsedProxy[2]) : 0; if (parsedProxyPort && parsedProxyPort !== port) { return true; // Skip if ports don't match. } if (!/^[.*]/.test(parsedProxyHostname)) { // No wildcards, so stop proxying if there is an exact match. return hostname !== parsedProxyHostname; } if (parsedProxyHostname.charAt(0) === '*') { // Remove leading wildcard. parsedProxyHostname = parsedProxyHostname.slice(1); } // Stop proxying if the hostname ends with the no_proxy host. return !hostname.endsWith(parsedProxyHostname); }); }; /** * Patched version of HttpsProxyAgent to get around a bug that ignores options * such as ca and rejectUnauthorized when upgrading the proxied socket to TLS: * https://github.com/TooTallNate/proxy-agents/issues/194 */ class PatchedHttpsProxyAgent extends HttpsProxyAgent { constructor(proxy, opts) { super(proxy, opts); this.constructorOpts = opts; } async connect(req, opts) { const combinedOpts = { ...this.constructorOpts, ...opts }; return super.connect(req, combinedOpts); } } function createTimelineAgentClass(BaseAgentClass) { return class extends BaseAgentClass { constructor(options, timeline) { let caCertificateDetails = options.caCertificateDetails || {}; delete options.caCertificateDetails; // For proxy agents, the first argument is the proxy URI and the second is options if (options?.proxy) { const { proxy: proxyUri, ...agentOptions } = options; // Ensure TLS options are properly set const tlsOptions = { ...agentOptions, rejectUnauthorized: agentOptions.rejectUnauthorized ?? true, }; super(proxyUri, tlsOptions); this.timeline = Array.isArray(timeline) ? timeline : []; this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1']; this.caProvided = !!tlsOptions.ca; // Log TLS verification status this.timeline.push({ timestamp: new Date(), type: 'info', message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`, }); // Log the proxy details this.timeline.push({ timestamp: new Date(), type: 'info', message: `Using proxy: ${proxyUri}`, }); } else { // This is a regular HTTPS agent case const tlsOptions = { ...options, rejectUnauthorized: options.rejectUnauthorized ?? true, }; super(tlsOptions); this.timeline = Array.isArray(timeline) ? timeline : []; this.alpnProtocols = options.ALPNProtocols || ['h2', 'http/1.1']; this.caProvided = !!options.ca; // Log TLS verification status this.timeline.push({ timestamp: new Date(), type: 'info', message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`, }); } this.caCertificateDetails = caCertificateDetails; } createConnection(options, callback) { const { host, port } = options; // Log ALPN protocols offered if (this.alpnProtocols && this.alpnProtocols.length > 0) { this.timeline.push({ timestamp: new Date(), type: 'tls', message: `ALPN: offers ${this.alpnProtocols.join(', ')}`, }); } const bundledCerts = this.caCertificateDetails.bundled || 0; const systemCerts = this.caCertificateDetails.system || 0; const extraCerts = this.caCertificateDetails.extra || 0; const customCerts = this.caCertificateDetails.custom || 0; this.timeline.push({ timestamp: new Date(), type: 'tls', message: `CA Certificates: ${bundledCerts} bundled, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`, }); // Log "Trying host:port..." this.timeline.push({ timestamp: new Date(), type: 'info', message: `Trying ${host}:${port}...`, }); let socket; try { socket = super.createConnection(options, callback); } catch (error) { this.timeline.push({ timestamp: new Date(), type: 'error', message: `Error creating connection: ${error.message}`, }); error.timeline = this.timeline; throw error; } // Attach event listeners to the socket socket?.on('lookup', (err, address, family, host) => { if (err) { this.timeline.push({ timestamp: new Date(), type: 'error', message: `DNS lookup error for ${host}: ${err.message}`, }); } else { this.timeline.push({ timestamp: new Date(), type: 'info', message: `DNS lookup: ${host} -> ${address}`, }); } }); socket?.on('connect', () => { const address = socket.remoteAddress || host; const remotePort = socket.remotePort || port; this.timeline.push({ timestamp: new Date(), type: 'info', message: `Connected to ${host} (${address}) port ${remotePort}`, }); }); socket?.on('secureConnect', () => { const protocol = socket.getProtocol() || 'SSL/TLS'; const cipher = socket.getCipher(); const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher'; this.timeline.push({ timestamp: new Date(), type: 'tls', message: `SSL connection using ${protocol} / ${cipherSuite}`, }); // ALPN protocol const alpnProtocol = socket.alpnProtocol || 'None'; this.timeline.push({ timestamp: new Date(), type: 'tls', message: `ALPN: server accepted ${alpnProtocol}`, }); // Server certificate const cert = socket.getPeerCertificate(true); if (cert) { this.timeline.push({ timestamp: new Date(), type: 'tls', message: `Server certificate:`, }); if (cert.subject) { this.timeline.push({ timestamp: new Date(), type: 'tls', message: ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`, }); } if (cert.valid_from) { this.timeline.push({ timestamp: new Date(), type: 'tls', message: ` start date: ${cert.valid_from}`, }); } if (cert.valid_to) { this.timeline.push({ timestamp: new Date(), type: 'tls', message: ` expire date: ${cert.valid_to}`, }); } if (cert.subjectaltname) { this.timeline.push({ timestamp: new Date(), type: 'tls', message: ` subjectAltName: ${cert.subjectaltname}`, }); } if (cert.issuer) { this.timeline.push({ timestamp: new Date(), type: 'tls', message: ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`, }); } // SSL certificate verify ok this.timeline.push({ timestamp: new Date(), type: 'tls', message: `SSL certificate verify ok.`, }); } }); socket?.on('error', (err) => { this.timeline.push({ timestamp: new Date(), type: 'error', message: `Socket error: ${err.message}`, }); }); return socket; } }; } function setupProxyAgents({ requestConfig, proxyMode = 'off', proxyConfig, httpsAgentRequestFields, interpolationOptions, timeline, }) { // Ensure TLS options are properly set const tlsOptions = { ...httpsAgentRequestFields, // Enable all secure protocols by default secureProtocol: undefined, // Allow Node.js to choose the protocol minVersion: 'TLSv1', rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true, }; if (proxyMode === 'on') { const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', '')); if (shouldProxy) { const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions); const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions); const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions); const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false); const socksEnabled = proxyProtocol.includes('socks'); let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`; let proxyUri; if (proxyAuthEnabled) { const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions)); const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions)); proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`; } else { proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`; } if (socksEnabled) { const TimelineSocksProxyAgent = createTimelineAgentClass(SocksProxyAgent); requestConfig.httpAgent = new TimelineSocksProxyAgent({ proxy: proxyUri }, timeline); requestConfig.httpsAgent = new TimelineSocksProxyAgent({ proxy: proxyUri, ...tlsOptions }, timeline); } else { const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent); requestConfig.httpAgent = new HttpProxyAgent(proxyUri); // For http, no need for timeline requestConfig.httpsAgent = new TimelineHttpsProxyAgent( { proxy: proxyUri, ...tlsOptions }, timeline ); } } else { // If proxy should not be used, set default HTTPS agent const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline); } } else if (proxyMode === 'system') { const { http_proxy, https_proxy, no_proxy } = preferencesUtil.getSystemProxyEnvVariables(); const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || ''); if (shouldUseSystemProxy) { try { if (http_proxy?.length) { new URL(http_proxy); requestConfig.httpAgent = new HttpProxyAgent(http_proxy); } } catch (error) { throw new Error('Invalid system http_proxy'); } try { if (https_proxy?.length) { new URL(https_proxy); const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent); requestConfig.httpsAgent = new TimelineHttpsProxyAgent( { proxy: https_proxy,...tlsOptions }, timeline ); } else { const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline); } } catch (error) { throw new Error('Invalid system https_proxy'); } } else { const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline); } } else { const TimelineHttpsAgent = createTimelineAgentClass(https.Agent); requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline); } } module.exports = { shouldUseProxy, PatchedHttpsProxyAgent, setupProxyAgents };