/** * Tests that the proxyPolicy does NOT leak application-level request headers * into the HTTP CONNECT tunnel handshake. * * Background: HttpsProxyAgent's `headers` constructor option specifies headers * to send in the CONNECT request to the proxy server. When Azure SDK request * headers (Content-Type, x-ms-version, x-ms-blob-type, etc.) are passed here, * strict corporate proxies (Fortinet, Zscaler) reject the tunnel — causing * ECONNRESET. See: https://github.com/actions/upload-artifact/issues/747 */ import {describe, test, expect, beforeEach, afterEach} from '@jest/globals' import { createPipelineRequest, type PipelineRequest, type SendRequest } from '@typespec/ts-http-runtime' import {proxyPolicy} from '@typespec/ts-http-runtime/internal/policies' import {HttpsProxyAgent} from 'https-proxy-agent' import {HttpProxyAgent} from 'http-proxy-agent' describe('proxyPolicy', () => { const PROXY_URL = 'http://corporate-proxy.example.com:3128' // The runtime checks both uppercase and lowercase proxy env vars, so we // must save/clear/restore both casings to keep tests hermetic. const PROXY_ENV_KEYS = [ 'HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy' ] as const let savedEnv: Record beforeEach(() => { // Save all proxy env vars savedEnv = {} for (const key of PROXY_ENV_KEYS) { savedEnv[key] = process.env[key] } // Set uppercase, delete lowercase to avoid ambiguity process.env['HTTPS_PROXY'] = PROXY_URL process.env['HTTP_PROXY'] = PROXY_URL delete process.env['https_proxy'] delete process.env['http_proxy'] delete process.env['NO_PROXY'] delete process.env['no_proxy'] }) afterEach(() => { // Restore original env for (const key of PROXY_ENV_KEYS) { if (savedEnv[key] !== undefined) { process.env[key] = savedEnv[key] } else { delete process.env[key] } } }) /** * A mock "next" handler that captures the request after the proxy policy * has set the agent, so we can inspect it. */ function createCapturingNext(): SendRequest & { capturedRequest: PipelineRequest | undefined } { const fn = async (request: PipelineRequest) => { fn.capturedRequest = request return { status: 200, headers: createPipelineRequest({url: ''}).headers, request } } fn.capturedRequest = undefined as PipelineRequest | undefined return fn } test('does not leak application headers into HttpsProxyAgent CONNECT request', async () => { const policy = proxyPolicy() const next = createCapturingNext() // Simulate an Azure Blob Storage upload request with typical SDK headers const request = createPipelineRequest({ url: 'https://productionresultssa0.blob.core.windows.net/artifacts/upload' }) request.headers.set('Content-Type', 'application/octet-stream') request.headers.set('x-ms-version', '2024-11-04') request.headers.set('x-ms-blob-type', 'BlockBlob') request.headers.set( 'x-ms-client-request-id', '00000000-0000-0000-0000-000000000000' ) await policy.sendRequest(request, next) // The policy should have assigned an HttpsProxyAgent const agent = next.capturedRequest?.agent expect(agent).toBeDefined() expect(agent).toBeInstanceOf(HttpsProxyAgent) // CRITICAL: The agent's proxyHeaders must NOT contain application headers. // If this fails, application headers are being leaked into the CONNECT // request, which breaks strict corporate proxies. const proxyAgent = agent as HttpsProxyAgent const proxyHeaders = typeof proxyAgent.proxyHeaders === 'function' ? proxyAgent.proxyHeaders() : proxyAgent.proxyHeaders expect(proxyHeaders).toBeDefined() // None of the Azure SDK application headers should appear const headerObj = proxyHeaders as Record expect(headerObj['content-type']).toBeUndefined() expect(headerObj['Content-Type']).toBeUndefined() expect(headerObj['x-ms-version']).toBeUndefined() expect(headerObj['x-ms-blob-type']).toBeUndefined() expect(headerObj['x-ms-client-request-id']).toBeUndefined() // proxyHeaders should be empty (no application headers leaked) expect(Object.keys(headerObj).length).toBe(0) }) test('selects HttpProxyAgent for plain HTTP requests without leaking headers', async () => { const policy = proxyPolicy() const next = createCapturingNext() // Simulate an insecure (HTTP) request with application headers const request = createPipelineRequest({ url: 'http://example.com/api/upload', allowInsecureConnection: true }) request.headers.set('Content-Type', 'application/json') request.headers.set('Authorization', 'Bearer some-token') await policy.sendRequest(request, next) const agent = next.capturedRequest?.agent expect(agent).toBeDefined() expect(agent).toBeInstanceOf(HttpProxyAgent) }) test('still routes HTTPS requests through the proxy', async () => { const policy = proxyPolicy() const next = createCapturingNext() const request = createPipelineRequest({ url: 'https://results-receiver.actions.githubusercontent.com/twirp/test' }) await policy.sendRequest(request, next) const agent = next.capturedRequest?.agent expect(agent).toBeDefined() expect(agent).toBeInstanceOf(HttpsProxyAgent) // Verify the proxy URL is correct const proxyAgent = agent as HttpsProxyAgent expect(proxyAgent.proxy.href).toBe(`${PROXY_URL}/`) }) test('bypasses proxy for no_proxy hosts', async () => { // Use customNoProxyList since globalNoProxyList is only loaded once. // Patterns starting with "." match subdomains (e.g. ".example.com" // matches "api.example.com"), bare names match the host exactly. const policy = proxyPolicy(undefined, { customNoProxyList: ['.blob.core.windows.net', 'exact-host.test'] }) const next = createCapturingNext() // This host matches ".blob.core.windows.net" via subdomain matching const request = createPipelineRequest({ url: 'https://productionresultssa0.blob.core.windows.net/artifacts/upload' }) await policy.sendRequest(request, next) // Agent should not be set for a bypassed host expect(next.capturedRequest?.agent).toBeUndefined() }) test('does not override a custom agent already set on the request', async () => { const policy = proxyPolicy() const next = createCapturingNext() const customAgent = new HttpsProxyAgent('http://custom-proxy:9999') const request = createPipelineRequest({ url: 'https://blob.core.windows.net/test' }) request.agent = customAgent await policy.sendRequest(request, next) // The policy should not overwrite the pre-existing agent expect(next.capturedRequest?.agent).toBe(customAgent) }) })