From 538bc9fae1233a77ef36495ffb775309aaa18a96 Mon Sep 17 00:00:00 2001 From: alexweininger Date: Wed, 1 Jul 2026 11:00:34 -0400 Subject: [PATCH] azureutils: honor VS Code proxy settings in Azure clients (#1536) The Azure SDK's built-in proxy policy reads only proxy environment variables (HTTPS_PROXY/HTTP_PROXY/NO_PROXY) and never consults VS Code's `http.proxy` / `http.proxyStrictSSL` / `http.noProxy` settings. Its proxy agent also discards TLS settings. On corporate networks that require an explicit VS Code proxy, ARM and generic requests therefore bypass the proxy and can fail. Add a shared ProxyAgentPolicy and two exported helpers that resolve proxy/TLS configuration from VS Code's http settings, falling back to the standard proxy env vars: - getProxyAgent(requestUrl): an http/https Agent for clients that accept a raw agent (also carries an http.proxyStrictSSL:false TLS override). - getProxySettings(requestUrl): a ProxySettings object for pipeline-based Azure SDK data-plane clients that accept proxyOptions (e.g. Storage's BlobServiceClient/ShareServiceClient); no TLS override since proxyOptions cannot express one. The policy is wired into addAzExtPipeline so it applies to createGenericClient, createAzureClient, and createAzureSubscriptionClient. TLS validation is only relaxed when http.proxyStrictSSL is explicitly false, and http.proxySupport: off makes the policy a no-op. CA trust continues to rely on NODE_EXTRA_CA_CERTS (documented separately in vscode-azureresourcegroups#1537), which Node honors globally including through proxy agents. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- azure/index.d.ts | 32 +- azure/package-lock.json | 2 + azure/package.json | 2 + azure/src/createAzureClient.ts | 10 +- azure/src/index.ts | 2 + azure/src/utils/ProxyAgentPolicy.ts | 234 +++++++++++++ azure/test/proxyAgent.test.ts | 492 ++++++++++++++++++++++++++++ 7 files changed, 772 insertions(+), 2 deletions(-) create mode 100644 azure/src/utils/ProxyAgentPolicy.ts create mode 100644 azure/test/proxyAgent.test.ts diff --git a/azure/index.d.ts b/azure/index.d.ts index 1017c960fc..f2fc060aff 100644 --- a/azure/index.d.ts +++ b/azure/index.d.ts @@ -13,7 +13,7 @@ import type { StorageAccount } from '@azure/arm-storage'; import { type StorageManagementClient } from '@azure/arm-storage'; import type { CommonClientOptions, ServiceClient, ServiceClientOptions } from '@azure/core-client'; import type { PagedAsyncIterableIterator } from '@azure/core-paging'; -import type { PipelineRequestOptions, PipelineResponse } from '@azure/core-rest-pipeline'; +import type { PipelineRequestOptions, PipelineResponse, ProxySettings as ProxySettingsBase } from '@azure/core-rest-pipeline'; import type { Environment } from '@azure/ms-rest-azure-env'; import type { AzExtParentTreeItem, AzExtServiceClientCredentials, AzExtServiceClientCredentialsT2, AzExtTreeItem, AzureNameStep, AzureWizardExecuteStep, AzureWizardExecuteStepWithActivityOutput, AzureWizardPromptStep, IActionContext, IAzureNamingRules, IAzureQuickPickItem, IAzureQuickPickOptions, IAzureUserInput, IRelatedNameWizardContext, ISubscriptionActionContext, ISubscriptionContext, IWizardOptions, TreeElementBase, UIExtensionVariables } from '@microsoft/vscode-azext-utils'; import type { AzureSubscription } from '@microsoft/vscode-azureresources-api'; @@ -556,6 +556,36 @@ export function createAuthorizationManagementClient(context: AzExtClientContext) export type AzExtRequestPrepareOptions = PipelineRequestOptions & { rejectUnauthorized?: boolean } export type AzExtPipelineResponse = PipelineResponse & { parsedBody?: any } +/** + * Returns an `http`/`https` `Agent` configured from VS Code's `http.proxy` / `http.proxyStrictSSL` + * settings (falling back to the standard `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` environment + * variables) for the given request URL, or `undefined` when no proxy or TLS override applies. + * + * Azure clients created via this package already apply this configuration automatically. This + * helper is exported so extensions can apply the same proxy behavior to their own HTTP clients + * that don't go through the Azure SDK pipeline. + */ +export declare function getProxyAgent(requestUrl: string): import('http').Agent | undefined; + +/** + * Proxy configuration (host/port/username/password) accepted by pipeline-based Azure SDK clients + * via their `proxyOptions`/`proxySettings` option. Re-exported from `@azure/core-rest-pipeline`. + */ +export type ProxySettings = ProxySettingsBase; + +/** + * Returns a `ProxySettings` object configured from VS Code's `http.proxy` setting (falling back to + * the standard `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` environment variables) for the given request + * URL, or `undefined` when no proxy applies (unset, `http.noProxy` bypass, or + * `http.proxySupport: off`). + * + * Intended for pipeline-based Azure SDK data-plane clients that accept a `proxyOptions` option + * (e.g. Storage's `BlobServiceClient`/`ShareServiceClient`) rather than a raw `http.Agent`. Unlike + * {@link getProxyAgent}, it does not carry a TLS (`http.proxyStrictSSL: false`) override, since + * `proxyOptions` cannot express one. + */ +export declare function getProxySettings(requestUrl: string): ProxySettings | undefined; + /** * Send request with a timeout specified. Retries are disabled (because retrying could take a lot longer) * and `addStatusCodePolicy` is enabled; both override any matching fields in `genericClientOptions`. diff --git a/azure/package-lock.json b/azure/package-lock.json index 45e52a4152..277cacc0c8 100644 --- a/azure/package-lock.json +++ b/azure/package-lock.json @@ -23,6 +23,8 @@ "@microsoft/vscode-azext-azureauth": "^6.1.0-alpha.2", "@microsoft/vscode-azext-utils": "^4.1.1", "@microsoft/vscode-azureresources-api": "^3.1.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "semver": "^7.7.4" }, "devDependencies": { diff --git a/azure/package.json b/azure/package.json index 1e74a3f48c..347df684a8 100644 --- a/azure/package.json +++ b/azure/package.json @@ -50,6 +50,8 @@ "@microsoft/vscode-azext-azureauth": "^6.1.0-alpha.2", "@microsoft/vscode-azext-utils": "^4.1.1", "@microsoft/vscode-azureresources-api": "^3.1.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "semver": "^7.7.4" }, "peerDependencies": { diff --git a/azure/src/createAzureClient.ts b/azure/src/createAzureClient.ts index d06072c94f..22be20baa2 100644 --- a/azure/src/createAzureClient.ts +++ b/azure/src/createAzureClient.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ServiceClient } from '@azure/core-client'; -import { createHttpHeaders, createPipelineRequest, Pipeline, PipelinePolicy, PipelineRequest, PipelineResponse, RestError, RetryPolicyOptions, SendRequest, userAgentPolicy } from '@azure/core-rest-pipeline'; +import { createHttpHeaders, createPipelineRequest, Pipeline, PipelinePolicy, PipelineRequest, PipelineResponse, proxyPolicyName, RestError, RetryPolicyOptions, SendRequest, userAgentPolicy } from '@azure/core-rest-pipeline'; import { BearerChallengePolicy } from '@microsoft/vscode-azext-azureauth'; import { appendExtensionUserAgent, AzExtServiceClientCredentialsT2, AzExtTreeItem, IActionContext, ISubscriptionActionContext, ISubscriptionContext, parseError } from '@microsoft/vscode-azext-utils'; import { randomUUID } from 'crypto'; @@ -12,6 +12,7 @@ import { Agent as HttpsAgent } from 'https'; import * as vscode from "vscode"; import * as types from '../index'; import { FeedMirrorPolicy } from './utils/FeedMirrorPolicy'; +import { ProxyAgentPolicy } from './utils/ProxyAgentPolicy'; import { parseJson, removeBom } from './utils/parseJson'; export type InternalAzExtClientContext = ISubscriptionActionContext | [IActionContext, ISubscriptionContext | AzExtTreeItem]; @@ -175,6 +176,13 @@ function addAzExtPipeline(context: IActionContext, pipeline: Pipeline, endpoint? pipeline.addPolicy(new AllowInsecureConnectionPolicy()); + // Honor VS Code's `http.proxy` / `http.proxyStrictSSL` / `http.noProxy` settings, running before + // the SDK's built-in proxy policy (which only reads env vars). With `http.proxySupport: off` this + // no-ops and env-var proxies still flow through the built-in policy (matching VS Code's default). + // Note: `http.noProxy` only bypasses proxies this policy applies, not env-var-only proxies (the + // built-in policy consults only `NO_PROXY`). + pipeline.addPolicy(new ProxyAgentPolicy(), { beforePolicies: [proxyPolicyName] }); + if (bearerChallengePolicy) { pipeline.addPolicy(bearerChallengePolicy, { phase: 'Sign' }); } diff --git a/azure/src/index.ts b/azure/src/index.ts index 1d14f2d9eb..6c01c6ca90 100644 --- a/azure/src/index.ts +++ b/azure/src/index.ts @@ -13,6 +13,8 @@ export * from './tree/RoleDefinitionsItem'; export * from './tree/SubscriptionTreeItemBase'; export * from './utils/createPortalUri'; export * from './utils/parseAzureResourceId'; +export { getProxyAgent, getProxySettings } from './utils/ProxyAgentPolicy'; +export type { ProxySettings } from '@azure/core-rest-pipeline'; export * from './utils/setupAzureLogger'; export * from './utils/uiUtils'; export { LocationCache } from './wizard/LocationCache'; diff --git a/azure/src/utils/ProxyAgentPolicy.ts b/azure/src/utils/ProxyAgentPolicy.ts new file mode 100644 index 0000000000..53c561141b --- /dev/null +++ b/azure/src/utils/ProxyAgentPolicy.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getDefaultProxySettings, type PipelinePolicy, type PipelineRequest, type PipelineResponse, type ProxySettings, type SendRequest } from '@azure/core-rest-pipeline'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { Agent as HttpsAgent } from 'https'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import * as vscode from 'vscode'; + +/** Proxy/TLS configuration resolved from VS Code's `http.*` settings. */ +interface HttpProxyConfig { + /** `false` when `http.proxySupport` is `off` (proxy handling disabled entirely). */ + proxySupportEnabled: boolean; + proxyUrl?: string; + /** `http.proxyStrictSSL`; defaults to `true`. */ + strictSSL: boolean; + /** `http.noProxy` merged with the `NO_PROXY` env var. */ + noProxy: string[]; +} + +function getHttpProxyConfig(): HttpProxyConfig { + const config = vscode.workspace.getConfiguration('http'); + // Default `'override'` matches VS Code; `'off'` disables proxy handling. + const proxySupportEnabled = config.get('proxySupport', 'override') !== 'off'; + const proxyUrl = config.get('proxy')?.trim() || undefined; + // Default `true`: only relax TLS validation when the user explicitly opts out. + const strictSSL = config.get('proxyStrictSSL', true); + const noProxy = [...(config.get('noProxy') ?? []), ...parseNoProxyEnv()] + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return { proxySupportEnabled, proxyUrl, strictSSL, noProxy }; +} + +function parseNoProxyEnv(): string[] { + const value = process.env.NO_PROXY ?? process.env.no_proxy; + return value ? value.split(',') : []; +} + +/** + * Resolves the proxy URL, preferring VS Code's `http.proxy` and falling back to the standard proxy + * env vars (case-insensitive). Protocol-aware: `http:` prefers `HTTP_PROXY`, `https:` prefers + * `HTTPS_PROXY`, with `ALL_PROXY` as a cross-protocol fallback. + */ +function resolveProxyUrl(config: HttpProxyConfig, isTls: boolean): string | undefined { + if (config.proxyUrl) { + return config.proxyUrl; + } + const protocolProxy = isTls + ? (process.env.HTTPS_PROXY ?? process.env.https_proxy) + : (process.env.HTTP_PROXY ?? process.env.http_proxy); + return protocolProxy + ?? process.env.ALL_PROXY ?? process.env.all_proxy + ?? undefined; +} + +function isProxyableProtocol(protocol: string): boolean { + return protocol === 'http:' || protocol === 'https:'; +} + +/** + * Returns `true` when `host` matches an entry in `noProxyList` and should therefore bypass the + * proxy. An entry that starts with `.` (or `*.`) matches the domain and any subdomain; otherwise + * an exact host match is required. + */ +export function isHostBypassed(host: string, noProxyList: string[]): boolean { + if (!host || noProxyList.length === 0) { + return false; + } + const lowerHost = host.toLowerCase(); + for (const rawPattern of noProxyList) { + // Treat "*", "*.foo.com" and ".foo.com" as domain suffixes. + let pattern = rawPattern.toLowerCase(); + if (pattern === '*') { + return true; + } + if (pattern.startsWith('*')) { + pattern = pattern.slice(1); + } + if (pattern.startsWith('.')) { + if (lowerHost === pattern.slice(1) || lowerHost.endsWith(pattern)) { + return true; + } + } else if (lowerHost === pattern) { + return true; + } + } + return false; +} + +// Caches so repeated requests reuse a single Agent (and its socket pool) per configuration. +const proxyAgentCache = new Map | HttpProxyAgent>(); +let insecureTlsAgent: HttpsAgent | undefined; + +function getProxyAgentForUrl(proxyUrl: string, isTls: boolean, rejectUnauthorized: boolean): HttpsProxyAgent | HttpProxyAgent { + const key = `${isTls ? 'https' : 'http'}|${proxyUrl}|${rejectUnauthorized}`; + let agent = proxyAgentCache.get(key); + if (!agent) { + if (isTls) { + const httpsAgent = new HttpsProxyAgent(proxyUrl, { rejectUnauthorized }); + // https-proxy-agent keeps `rejectUnauthorized` only in `connectOpts` (proxy-side TLS) and + // resets `.options`, which is what Node merges into the destination handshake through the + // CONNECT tunnel. Set it on `.options` so `proxyStrictSSL: false` reaches the server cert check. + httpsAgent.options = { ...httpsAgent.options, rejectUnauthorized }; + agent = httpsAgent; + } else { + agent = new HttpProxyAgent(proxyUrl); + } + proxyAgentCache.set(key, agent); + } + return agent; +} + +function getInsecureTlsAgent(): HttpsAgent { + return (insecureTlsAgent ??= new HttpsAgent({ keepAlive: true, rejectUnauthorized: false })); +} + +/** Result of resolving a request URL against VS Code's proxy configuration. */ +interface ResolvedProxy { + url: URL; + config: HttpProxyConfig; + /** Proxy URL to use, or `undefined` when bypassed or no proxy is configured. */ + proxyUrl?: string; +} + +/** + * Shared resolution for a request URL. Returns `undefined` for invalid/non-http(s) URLs or when + * `http.proxySupport` is `off`; otherwise resolves the proxy URL (honoring the `http.noProxy` bypass). + */ +function resolveProxyForRequest(requestUrl: string): ResolvedProxy | undefined { + let url: URL; + try { + url = new URL(requestUrl); + } catch { + return undefined; + } + + // Leave non-http(s) schemes (e.g. `ftp:`, `file:`) untouched. + if (!isProxyableProtocol(url.protocol)) { + return undefined; + } + + const config = getHttpProxyConfig(); + if (!config.proxySupportEnabled) { + return undefined; + } + const isTls = url.protocol === 'https:'; + const bypassed = isHostBypassed(url.hostname, config.noProxy); + const proxyUrl = bypassed ? undefined : resolveProxyUrl(config, isTls); + return { url, config, proxyUrl }; +} + +/** + * Returns an `http`/`https` `Agent` configured from VS Code's `http.proxy` / `http.proxyStrictSSL` + * settings (falling back to the proxy env vars), or `undefined` when none applies. Lets extensions + * apply the same proxy behavior to HTTP clients that bypass the Azure SDK pipeline (whose built-in + * proxy policy only reads env vars and ignores VS Code's `http.*` settings). + */ +export function getProxyAgent(requestUrl: string): HttpsAgent | HttpProxyAgent | HttpsProxyAgent | undefined { + const resolved = resolveProxyForRequest(requestUrl); + if (!resolved) { + return undefined; + } + + const { url, config, proxyUrl } = resolved; + const isTls = url.protocol === 'https:'; + const insecure = isTls && config.strictSSL === false; + + if (proxyUrl) { + // Own the agent when a TLS override is needed, since the SDK's proxy agent drops TLS options. + return getProxyAgentForUrl(proxyUrl, isTls, /* rejectUnauthorized */ !insecure); + } + if (insecure) { + return getInsecureTlsAgent(); + } + return undefined; +} + +/** + * Returns a {@link ProxySettings} object for the given request URL, or `undefined` when no proxy + * applies. Intended for pipeline-based Azure SDK data-plane clients that accept `proxyOptions` + * (e.g. Storage's `BlobServiceClient`/`ShareServiceClient`). Unlike {@link getProxyAgent}, it carries + * no TLS override (`http.proxyStrictSSL: false`), since `ProxySettings` cannot express one. + */ +export function getProxySettings(requestUrl: string): ProxySettings | undefined { + const proxyUrl = resolveProxyForRequest(requestUrl)?.proxyUrl; + return proxyUrl ? getDefaultProxySettings(proxyUrl) : undefined; +} + +/** + * Pipeline policy that applies VS Code's proxy configuration to Azure SDK requests, running before + * the SDK's built-in `proxyPolicy` (which only reads env vars and drops TLS settings). It either + * injects `request.proxySettings` for a secure proxy (letting the built-in policy build/cache the + * agent and honor `NODE_EXTRA_CA_CERTS`) or sets `request.agent` when a `http.proxyStrictSSL: false` + * override is needed. Honors `http.noProxy` and `http.proxySupport: off`, and never overrides an + * agent a caller set explicitly. + */ +export class ProxyAgentPolicy implements PipelinePolicy { + public static readonly Name = 'azExtProxyAgentPolicy'; + public readonly name = ProxyAgentPolicy.Name; + + public async sendRequest(request: PipelineRequest, next: SendRequest): Promise { + // Respect an agent a caller set explicitly. + if (!request.agent) { + this.applyProxy(request); + } + return next(request); + } + + private applyProxy(request: PipelineRequest): void { + const resolved = resolveProxyForRequest(request.url); + if (!resolved) { + return; + } + const { url, config, proxyUrl } = resolved; + const isTls = url.protocol === 'https:'; + const insecure = isTls && config.strictSSL === false; + + // Secure proxy from VS Code config: hand off to the built-in policy so it builds/caches the + // agent and honors NODE_EXTRA_CA_CERTS. Env-var proxies are already handled by that policy. + if (config.proxyUrl && proxyUrl && !insecure) { + request.proxySettings ??= getDefaultProxySettings(config.proxyUrl); + return; + } + + // A TLS override is needed (strictSSL: false), so own the agent, with or without a proxy. + if (insecure) { + request.agent = proxyUrl + ? getProxyAgentForUrl(proxyUrl, isTls, /* rejectUnauthorized */ false) + : getInsecureTlsAgent(); + } + } +} diff --git a/azure/test/proxyAgent.test.ts b/azure/test/proxyAgent.test.ts new file mode 100644 index 0000000000..277a745192 --- /dev/null +++ b/azure/test/proxyAgent.test.ts @@ -0,0 +1,492 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createPipelineRequest, type PipelineResponse } from '@azure/core-rest-pipeline'; +import * as assert from 'assert'; +import { createServer as createHttpServer, type Server as HttpServer } from 'http'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { Agent as HttpsAgent, createServer as createHttpsServer, get as httpsGet, type Server as HttpsServer } from 'https'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { type AddressInfo, connect as netConnect, type Socket } from 'net'; +import * as vscode from 'vscode'; +import { ProxyAgentPolicy, getProxyAgent, getProxySettings, isHostBypassed } from '../src/utils/ProxyAgentPolicy'; + +const proxyEnvVars = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy']; + +// A long-lived self-signed certificate (CN=localhost, SAN IP:127.0.0.1) used by the TLS integration +// tests below. Embedded so the tests need no external tooling (e.g. openssl) and are CI-portable. +const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIUVNxIkVKNPsJkZCWqmm7slz/9tKUwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcwMTE5MDgyMVoYDzIxMjYw +NjA3MTkwODIxWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDWqH+g1XNPbjnuGjbwmSWq9tFLQUKkU3tlhvJD5Khk +3ww2QScubd3BTfE9GdXG7vbD+c3B8obLmloIj7VFA01UlPmlIlTR38jTzg9cprF/ +H8cypwAgOdOU99zKxlC4F20id9p1MeTmgwWoj6xh+3wnZWPk/yhVg7V5RoBr0B21 +PNEFg0k4ahzMMy5e1GWJRTN2mKvaXMVP3AJyPXQ8m31HANbNIkhulShiYT8In9X8 +IviYdDd0NGb/CXVoEOVKN+5Is51FtjRa6FQ0hr0ymc2gQIy8lGlzHXTxqicKZEp3 +DxRqRaIOh7rjG0jcQAiqtKLRw94luTXI4RVzRkkdAW+BAgMBAAGjbzBtMB0GA1Ud +DgQWBBR2vt36I8fIZVDk5ovl/2Mx5oGDoTAfBgNVHSMEGDAWgBR2vt36I8fIZVDk +5ovl/2Mx5oGDoTAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGHBH8AAAGCCWxv +Y2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAtIBk6d0vqTJ6lHJ4CNX6NjBMQiTG +cUs4CWcd6UzoJkLcgrioGWOLvPwXc8MSPU2NBJoZG0jwpHM2qpoBo6ZwsdmOTBXG +ujwwKLiWV1AaCyLFOlf7hwkVqCI409Hxc5kMZl04pwWtcZEEzzGR1rWYm0IyJ6Q/ +TbiUp2+kzKJdP73aBvF+4pqiDflgwJxsX/QDiw1zQi23/xQyVSDC8e2JAFxZ/Hnb +ROoau60V4Y8ol6Np/Xw6vwmLM6VAIB6KJzIf6huPF1jLC5IdrfFy5+sCjUwOccaz +/cZMXIDdWkdGiFWmoznNTDW8hZ4AbVWWnebRcbhX7zfnAvDase2avqDY+w== +-----END CERTIFICATE----- +`; + +const SELF_SIGNED_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDWqH+g1XNPbjnu +GjbwmSWq9tFLQUKkU3tlhvJD5Khk3ww2QScubd3BTfE9GdXG7vbD+c3B8obLmloI +j7VFA01UlPmlIlTR38jTzg9cprF/H8cypwAgOdOU99zKxlC4F20id9p1MeTmgwWo +j6xh+3wnZWPk/yhVg7V5RoBr0B21PNEFg0k4ahzMMy5e1GWJRTN2mKvaXMVP3AJy +PXQ8m31HANbNIkhulShiYT8In9X8IviYdDd0NGb/CXVoEOVKN+5Is51FtjRa6FQ0 +hr0ymc2gQIy8lGlzHXTxqicKZEp3DxRqRaIOh7rjG0jcQAiqtKLRw94luTXI4RVz +RkkdAW+BAgMBAAECggEAAzE/kPyCt1OEZP+BKvxp2Im4UnANr4eq3kaxR8uloB0j +aHSsW/XosHi7amM7WnRMlbkG7Sqz5Ws7+qB/EWKVIHRwackyOPm0QGYlYtxhn/7K +VRvavxW0wYrdk2iZ6RPvT/MjH7CMXuBeesEOzv0yQtScNX6pjszRjPXMCdD+hEWT +WFbn0KOr3rxxvIraDaBAGPyLZ3VvDNMxPlySNaE2FFcAr5/tQkH/AjFniEmNn0/j +EubQ4rXhtnL6Nb1zJ1rWYlf9Pc8JBokiOj5iGN4y5HuMHFYjcVbiUcuRodWxO7fk +P3fS8UHd5mXoTH737NYDzrP+8UMsRMieRIUkCFSW6wKBgQD6VPCbi87Y9qhypotq +gStp4lLIDPSvQA6gDHQfxg8ajjoHLVtjC3dE5YiUSseQLPsko6xW8RZIdXXeQj0F +WA/c6jW88mt9YZgxmwvTLuHsiRCyy/a7Kx38RF0Bs10YTrkA3LWTVchAwQBxA61x +Fy7rfQl9PgJAgtfjK4VMSYX7NwKBgQDbhMZkOquICeKsHyI1YOoHyZIOpVoluLnc +jhsAn4cZPQHm2fkKlVEcLUjXLgoTLv2uQJdUwbOLFGSF1DRvFOBtF+r4ugMZvXQ5 +JC4366ei8iRaoTPEiLQ54E03rCCbv2NkMZErHUpGi959rj8Skk246E2LBmpD1bTu ++PswVHd3BwKBgQCExtLMHg67w7C7Bx1Bg3vMcK/pzf1mivp258QcKkhOlIuwzN0B +Hs7HK1wTE8rf7QvUdj/t6XghPLQlDEsjb38SdOPF8WsUGNTJ0uwlumM4u8awn0Ci +LA9+g6A1S7agMvkrvOVOXZyWxAgA6atwJZTMcQi8dkxpfT0XEDlmqkS3ZwKBgFug +aRrO7mgjEC0d9a5oHGdRuJhKZn1WRKYN3rF85Owg7dlI5E2Jk8h6EmxWuDfXpmWE +amYjT+jegzLlJ1myUhbXI+nb4o1s6cUsF+qZf2hhP9FgdfYzxV5fBHwXaaj40uiw +U9K2MBmQKjc1cvgyfySOOkesTtCvtA0HeflrWE4jAoGAGu9mPYnYUZ+zZ4wEMdkz +u0YQJLhXBcGRPhJQqVrrp+Ictib5Sdw59xHAlT3Iq5TA6qiEL9fNd0rGypGEZ8BF +oX3w10zmzcTzOJF8XeOBQiqQljaU6mvX6d8P2ztMFiwiz4ownBmQFF26hDUQMDOJ +bdxlbZ4Db+n5TnFSKoHhEHo= +-----END PRIVATE KEY----- +`; + +suite('isHostBypassed', () => { + test('empty list never bypasses', () => { + assert.strictEqual(isHostBypassed('management.azure.com', []), false); + }); + + test('exact host match', () => { + assert.strictEqual(isHostBypassed('management.azure.com', ['management.azure.com']), true); + assert.strictEqual(isHostBypassed('login.microsoftonline.com', ['management.azure.com']), false); + }); + + test('is case-insensitive', () => { + assert.strictEqual(isHostBypassed('Management.Azure.COM', ['management.azure.com']), true); + }); + + test('.domain suffix matches domain and subdomains', () => { + assert.strictEqual(isHostBypassed('management.azure.com', ['.azure.com']), true); + assert.strictEqual(isHostBypassed('azure.com', ['.azure.com']), true); + assert.strictEqual(isHostBypassed('azure.com.evil.com', ['.azure.com']), false); + }); + + test('*.domain is treated as a suffix', () => { + assert.strictEqual(isHostBypassed('management.azure.com', ['*.azure.com']), true); + }); + + test('* bypasses everything', () => { + assert.strictEqual(isHostBypassed('management.azure.com', ['*']), true); + }); +}); + +suite('getProxyAgent', () => { + const savedEnv: Record = {}; + + suiteSetup(() => { + for (const key of proxyEnvVars) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + suiteTeardown(() => { + for (const key of proxyEnvVars) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + teardown(() => { + for (const key of proxyEnvVars) { + delete process.env[key]; + } + }); + + test('returns undefined when no proxy is configured', () => { + assert.strictEqual(getProxyAgent('https://management.azure.com'), undefined); + }); + + test('returns undefined for an invalid URL', () => { + assert.strictEqual(getProxyAgent('not a url'), undefined); + }); + + test('returns an HttpsProxyAgent for https requests when HTTPS_PROXY is set', () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:8888'; + const agent = getProxyAgent('https://management.azure.com/subscriptions'); + assert.ok(agent instanceof HttpsProxyAgent, 'expected an HttpsProxyAgent'); + }); + + test('returns an HttpProxyAgent for http requests when HTTP_PROXY is set', () => { + process.env.HTTP_PROXY = 'http://127.0.0.1:8888'; + const agent = getProxyAgent('http://example.com/'); + assert.ok(agent instanceof HttpProxyAgent, 'expected an HttpProxyAgent'); + }); + + test('prefers HTTP_PROXY over HTTPS_PROXY for http: requests', () => { + process.env.HTTP_PROXY = 'http://127.0.0.1:1111'; + process.env.HTTPS_PROXY = 'http://127.0.0.1:2222'; + const agent = getProxyAgent('http://example.com/') as HttpProxyAgent; + assert.ok(agent instanceof HttpProxyAgent, 'expected an HttpProxyAgent'); + assert.strictEqual(agent.proxy.port, '1111'); + }); + + test('falls back to ALL_PROXY when the protocol-specific var is unset', () => { + process.env.ALL_PROXY = 'http://127.0.0.1:3333'; + const agent = getProxyAgent('https://management.azure.com/') as HttpsProxyAgent; + assert.ok(agent instanceof HttpsProxyAgent, 'expected an HttpsProxyAgent'); + assert.strictEqual(agent.proxy.port, '3333'); + }); + + test('returns undefined for non-http(s) schemes even when a proxy is set', () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:8888'; + process.env.HTTP_PROXY = 'http://127.0.0.1:8888'; + process.env.ALL_PROXY = 'http://127.0.0.1:8888'; + assert.strictEqual(getProxyAgent('ftp://example.com/file'), undefined); + assert.strictEqual(getProxyAgent('file:///tmp/x'), undefined); + }); + + test('honors NO_PROXY bypass', () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:8888'; + process.env.NO_PROXY = '.azure.com'; + assert.strictEqual(getProxyAgent('https://management.azure.com/subscriptions'), undefined); + }); +}); + +suite('getProxySettings', () => { + const savedEnv: Record = {}; + const httpConfig = () => vscode.workspace.getConfiguration('http'); + + suiteSetup(() => { + for (const key of proxyEnvVars) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + suiteTeardown(() => { + for (const key of proxyEnvVars) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + teardown(() => { + for (const key of proxyEnvVars) { + delete process.env[key]; + } + }); + + test('returns undefined when no proxy is configured', () => { + assert.strictEqual(getProxySettings('https://management.azure.com'), undefined); + }); + + test('returns undefined for an invalid URL', () => { + assert.strictEqual(getProxySettings('not a url'), undefined); + }); + + test('returns ProxySettings when HTTPS_PROXY is set', () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:8888'; + const settings = getProxySettings('https://management.azure.com/subscriptions'); + assert.ok(settings, 'expected ProxySettings'); + assert.strictEqual(settings?.host, 'http://127.0.0.1'); + assert.strictEqual(settings?.port, 8888); + }); + + test('honors NO_PROXY bypass', () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:8888'; + process.env.NO_PROXY = '.azure.com'; + assert.strictEqual(getProxySettings('https://management.azure.com/subscriptions'), undefined); + }); + + test('returns undefined when http.proxySupport is off', async () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:8888'; + const previous = httpConfig().inspect('proxySupport')?.globalValue; + await httpConfig().update('proxySupport', 'off', vscode.ConfigurationTarget.Global); + try { + assert.strictEqual(getProxySettings('https://management.azure.com/subscriptions'), undefined); + } finally { + await httpConfig().update('proxySupport', previous, vscode.ConfigurationTarget.Global); + } + }); +}); + +suite('ProxyAgentPolicy', () => { + const savedEnv: Record = {}; + const httpConfig = () => vscode.workspace.getConfiguration('http'); + + async function withHttpSetting(key: string, value: unknown, callback: () => Promise | T): Promise { + const previous = httpConfig().inspect(key)?.globalValue; + await httpConfig().update(key, value, vscode.ConfigurationTarget.Global); + try { + return await callback(); + } finally { + await httpConfig().update(key, previous, vscode.ConfigurationTarget.Global); + } + } + + async function runPolicy(url: string): Promise> { + const policy = new ProxyAgentPolicy(); + const request = createPipelineRequest({ method: 'GET', url }); + await policy.sendRequest(request, () => Promise.resolve({} as PipelineResponse)); + return request; + } + + suiteSetup(() => { + for (const key of proxyEnvVars) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + suiteTeardown(() => { + for (const key of proxyEnvVars) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + test('injects proxySettings from http.proxy for a secure proxy', async () => { + await withHttpSetting('proxy', 'http://127.0.0.1:8888', async () => { + const request = await runPolicy('https://management.azure.com/subscriptions'); + assert.ok(request.proxySettings, 'expected proxySettings to be set'); + assert.strictEqual(request.proxySettings?.port, 8888); + assert.strictEqual(request.agent, undefined, 'secure proxy should defer agent creation to the built-in policy'); + }); + }); + + test('sets an insecure proxy agent when proxyStrictSSL is false', async () => { + await withHttpSetting('proxy', 'http://127.0.0.1:8888', async () => { + await withHttpSetting('proxyStrictSSL', false, async () => { + const request = await runPolicy('https://management.azure.com/subscriptions'); + assert.ok(request.agent instanceof HttpsProxyAgent, 'expected an HttpsProxyAgent'); + }); + }); + }); + + test('sets an insecure TLS agent when proxyStrictSSL is false and no proxy is set', async () => { + await withHttpSetting('proxy', '', async () => { + await withHttpSetting('proxyStrictSSL', false, async () => { + const request = await runPolicy('https://management.azure.com/subscriptions'); + assert.ok(request.agent instanceof HttpsAgent, 'expected an https.Agent'); + assert.ok(!(request.agent instanceof HttpsProxyAgent), 'should not be a proxy agent'); + }); + }); + }); + + test('respects an agent set by the caller', async () => { + await withHttpSetting('proxy', 'http://127.0.0.1:8888', async () => { + const policy = new ProxyAgentPolicy(); + const callerAgent = new HttpsAgent(); + const request = createPipelineRequest({ method: 'GET', url: 'https://management.azure.com/' }); + request.agent = callerAgent; + await policy.sendRequest(request, () => Promise.resolve({} as PipelineResponse)); + assert.strictEqual(request.agent, callerAgent); + assert.strictEqual(request.proxySettings, undefined); + }); + }); + + test('honors http.noProxy bypass', async () => { + await withHttpSetting('proxy', 'http://127.0.0.1:8888', async () => { + await withHttpSetting('noProxy', ['.azure.com'], async () => { + const request = await runPolicy('https://management.azure.com/subscriptions'); + assert.strictEqual(request.proxySettings, undefined); + assert.strictEqual(request.agent, undefined); + }); + }); + }); + + test('ignores non-http(s) request URLs', async () => { + await withHttpSetting('proxy', 'http://127.0.0.1:8888', async () => { + const request = await runPolicy('ftp://example.com/file'); + assert.strictEqual(request.proxySettings, undefined); + assert.strictEqual(request.agent, undefined); + }); + }); + + test('ignores http.proxy when http.proxySupport is off', async () => { + await withHttpSetting('proxy', 'http://127.0.0.1:8888', async () => { + await withHttpSetting('proxySupport', 'off', async () => { + const request = await runPolicy('https://management.azure.com/subscriptions'); + assert.strictEqual(request.proxySettings, undefined); + assert.strictEqual(request.agent, undefined); + assert.strictEqual(getProxyAgent('https://management.azure.com/subscriptions'), undefined); + }); + }); + }); + + test('fully no-ops when http.proxySupport is off even if proxyStrictSSL is false', async () => { + await withHttpSetting('proxy', 'http://127.0.0.1:8888', async () => { + await withHttpSetting('proxySupport', 'off', async () => { + await withHttpSetting('proxyStrictSSL', false, async () => { + const request = await runPolicy('https://management.azure.com/subscriptions'); + assert.strictEqual(request.proxySettings, undefined); + assert.strictEqual(request.agent, undefined); + assert.strictEqual(getProxyAgent('https://management.azure.com/subscriptions'), undefined); + }); + }); + }); + }); +}); + +suite('ProxyAgentPolicy TLS enforcement (integration)', () => { + const savedEnv: Record = {}; + const httpConfig = () => vscode.workspace.getConfiguration('http'); + + let destServer: HttpsServer; + let connectProxy: HttpServer; + let destPort = 0; + let proxyUrl = ''; + // Track every socket so teardown can force-close them; keep-alive proxy agents and the CONNECT + // tunnel otherwise leave sockets open and server.close() would hang. + const openSockets = new Set(); + + function track(socket: Socket): void { + openSockets.add(socket); + socket.on('close', () => { openSockets.delete(socket); }); + } + + async function withHttpSetting(key: string, value: unknown, callback: () => Promise | T): Promise { + const previous = httpConfig().inspect(key)?.globalValue; + await httpConfig().update(key, value, vscode.ConfigurationTarget.Global); + try { + return await callback(); + } finally { + await httpConfig().update(key, previous, vscode.ConfigurationTarget.Global); + } + } + + // Issues an HTTPS GET to the self-signed destination server through the given proxy agent, + // resolving with the response body or rejecting with the connection/TLS error. This exercises the + // real destination TLS handshake inside the CONNECT tunnel, which is what the strictSSL override + // has to reach. + function requestThroughProxy(agent: HttpsProxyAgent): Promise { + return new Promise((resolve, reject) => { + const request = httpsGet({ hostname: '127.0.0.1', port: destPort, path: '/', agent }, (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { body += chunk; }); + response.on('end', () => resolve(body)); + }); + request.on('error', reject); + }); + } + + suiteSetup(async () => { + for (const key of proxyEnvVars) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + + // Self-signed HTTPS destination whose certificate does not chain to any trusted root. + destServer = createHttpsServer({ key: SELF_SIGNED_KEY, cert: SELF_SIGNED_CERT }, (_request, response) => response.end('ok')); + destServer.on('connection', track); + await new Promise((resolve) => destServer.listen(0, '127.0.0.1', resolve)); + destPort = (destServer.address() as AddressInfo).port; + + // Plain HTTP CONNECT proxy that blindly tunnels to the requested host:port, so the destination + // TLS handshake happens end-to-end through the tunnel (the scenario a TLS-inspecting corporate + // proxy creates). + connectProxy = createHttpServer(); + connectProxy.on('connection', track); + connectProxy.on('connect', (request, clientSocket, head) => { + const [host, port] = (request.url ?? '').split(':'); + const upstream = netConnect(Number(port), host, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + track(upstream); + upstream.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => upstream.destroy()); + }); + await new Promise((resolve) => connectProxy.listen(0, '127.0.0.1', resolve)); + proxyUrl = `http://127.0.0.1:${(connectProxy.address() as AddressInfo).port}`; + }); + + suiteTeardown(async () => { + for (const key of proxyEnvVars) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + for (const socket of openSockets) { + socket.destroy(); + } + openSockets.clear(); + await new Promise((resolve) => destServer.close(() => resolve())); + await new Promise((resolve) => connectProxy.close(() => resolve())); + }); + + // Regression test for the headline scenario: without relaxing destination TLS, this request fails + // with DEPTH_ZERO_SELF_SIGNED_CERT even though the user opted out via http.proxyStrictSSL:false. + test('getProxyAgent relaxes destination TLS through the proxy when proxyStrictSSL is false', async () => { + await withHttpSetting('proxy', proxyUrl, async () => { + await withHttpSetting('proxyStrictSSL', false, async () => { + const agent = getProxyAgent(`https://127.0.0.1:${destPort}/`); + assert.ok(agent instanceof HttpsProxyAgent, 'expected an HttpsProxyAgent'); + assert.strictEqual(await requestThroughProxy(agent), 'ok'); + }); + }); + }).timeout(20000); + + // The default (strictSSL true) must still reject an untrusted destination certificate through the + // proxy, proving the override only relaxes TLS when explicitly requested. + test('getProxyAgent enforces destination TLS through the proxy by default (rejects self-signed cert)', async () => { + await withHttpSetting('proxy', proxyUrl, async () => { + const agent = getProxyAgent(`https://127.0.0.1:${destPort}/`); + assert.ok(agent instanceof HttpsProxyAgent, 'expected an HttpsProxyAgent'); + await assert.rejects( + requestThroughProxy(agent), + (err: unknown) => { + const nodeErr = err as NodeJS.ErrnoException; + return nodeErr.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || /self.?signed certificate/i.test(nodeErr.message); + }, + 'expected a self-signed certificate error', + ); + }); + }).timeout(20000); + + // The policy path (request.agent set by applyProxy) must also relax destination TLS, not just the + // standalone getProxyAgent export. + test('the insecure agent the policy sets on the request relaxes destination TLS', async () => { + await withHttpSetting('proxy', proxyUrl, async () => { + await withHttpSetting('proxyStrictSSL', false, async () => { + const policy = new ProxyAgentPolicy(); + const request = createPipelineRequest({ method: 'GET', url: `https://127.0.0.1:${destPort}/` }); + await policy.sendRequest(request, () => Promise.resolve({} as PipelineResponse)); + assert.ok(request.agent instanceof HttpsProxyAgent, 'expected the policy to set an HttpsProxyAgent'); + assert.strictEqual(await requestThroughProxy(request.agent), 'ok'); + }); + }); + }).timeout(20000); +});