Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions __tests__/proxy-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* 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'

let savedHttpsProxy: string | undefined
let savedHttpProxy: string | undefined
let savedNoProxy: string | undefined

beforeEach(() => {
// Save and set proxy env vars
savedHttpsProxy = process.env['HTTPS_PROXY']
savedHttpProxy = process.env['HTTP_PROXY']
savedNoProxy = process.env['NO_PROXY']

process.env['HTTPS_PROXY'] = PROXY_URL
process.env['HTTP_PROXY'] = PROXY_URL
delete process.env['NO_PROXY']
})

afterEach(() => {
// Restore original env
if (savedHttpsProxy !== undefined) {
process.env['HTTPS_PROXY'] = savedHttpsProxy
} else {
delete process.env['HTTPS_PROXY']
}
if (savedHttpProxy !== undefined) {
process.env['HTTP_PROXY'] = savedHttpProxy
} else {
delete process.env['HTTP_PROXY']
}
if (savedNoProxy !== undefined) {
process.env['NO_PROXY'] = savedNoProxy
} else {
delete process.env['NO_PROXY']
}
})

/**
* 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<string>
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<string, string>
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('does not leak application headers into HttpProxyAgent CONNECT request', 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<string>
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)
})
})
12 changes: 9 additions & 3 deletions dist/merge/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -89375,16 +89375,22 @@ function setProxyAgentOnRequest(request, cachedAgents, proxyUrl) {
if (request.tlsSettings) {
log_logger.warning("TLS settings are not supported in combination with custom Proxy, certificates provided to the client will be ignored.");
}
const headers = request.headers.toJSON();
// Do NOT pass application-level request headers to the proxy agent.
// The `headers` option in HttpsProxyAgent/HttpProxyAgent specifies headers
// to include in the HTTP CONNECT request to the proxy server. Leaking
// application headers (Content-Type, x-ms-version, etc.) into the CONNECT
// handshake violates RFC 7231 §4.3.6 and causes strict proxies (e.g.
// Fortinet, Zscaler) to reject the tunnel, resulting in ECONNRESET.
// See: https://github.com/actions/upload-artifact/issues/XXX
if (isInsecure) {
if (!cachedAgents.httpProxyAgent) {
cachedAgents.httpProxyAgent = new http_proxy_agent_dist.HttpProxyAgent(proxyUrl, { headers });
cachedAgents.httpProxyAgent = new http_proxy_agent_dist.HttpProxyAgent(proxyUrl);
}
request.agent = cachedAgents.httpProxyAgent;
}
else {
if (!cachedAgents.httpsProxyAgent) {
cachedAgents.httpsProxyAgent = new dist.HttpsProxyAgent(proxyUrl, { headers });
cachedAgents.httpsProxyAgent = new dist.HttpsProxyAgent(proxyUrl);
}
request.agent = cachedAgents.httpsProxyAgent;
}
Expand Down
12 changes: 9 additions & 3 deletions dist/upload/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -86950,16 +86950,22 @@ function setProxyAgentOnRequest(request, cachedAgents, proxyUrl) {
if (request.tlsSettings) {
log_logger.warning("TLS settings are not supported in combination with custom Proxy, certificates provided to the client will be ignored.");
}
const headers = request.headers.toJSON();
// Do NOT pass application-level request headers to the proxy agent.
// The `headers` option in HttpsProxyAgent/HttpProxyAgent specifies headers
// to include in the HTTP CONNECT request to the proxy server. Leaking
// application headers (Content-Type, x-ms-version, etc.) into the CONNECT
// handshake violates RFC 7231 §4.3.6 and causes strict proxies (e.g.
// Fortinet, Zscaler) to reject the tunnel, resulting in ECONNRESET.
// See: https://github.com/actions/upload-artifact/issues/XXX
if (isInsecure) {
if (!cachedAgents.httpProxyAgent) {
cachedAgents.httpProxyAgent = new http_proxy_agent_dist.HttpProxyAgent(proxyUrl, { headers });
cachedAgents.httpProxyAgent = new http_proxy_agent_dist.HttpProxyAgent(proxyUrl);
}
request.agent = cachedAgents.httpProxyAgent;
}
else {
if (!cachedAgents.httpsProxyAgent) {
cachedAgents.httpsProxyAgent = new dist.HttpsProxyAgent(proxyUrl, { headers });
cachedAgents.httpsProxyAgent = new dist.HttpsProxyAgent(proxyUrl);
}
request.agent = cachedAgents.httpsProxyAgent;
}
Expand Down
Loading