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); +});