fix(slack): honor HTTPS_PROXY for Socket Mode WebSocket connections

When HTTPS_PROXY or HTTP_PROXY env vars are set, create an
HttpsProxyAgent and pass it as the `agent` option through
@slack/bolt → @slack/socket-mode → ws, so the WebSocket upgrade
request is tunneled through the proxy.

This fixes Slack Socket Mode in environments where all outbound
traffic must go through an HTTP CONNECT proxy (e.g. sandboxed
containers, corporate networks). Previously the ws library opened
a direct connection to wss-primary.slack.com, ignoring proxy env
vars entirely.

The approach mirrors the existing Discord gateway proxy support
(extensions/discord/src/monitor/gateway-plugin.ts) which uses the
same https-proxy-agent library.

Fixes #57405
This commit is contained in:
Michael Martello
2026-04-08 03:23:20 +00:00
committed by Peter Steinberger
parent b73d8ef7d7
commit d4e5f250a0
3 changed files with 101 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@slack/web-api", () => {
const WebClient = vi.fn(function WebClientMock(
@@ -81,3 +81,72 @@ describe("slack web client config", () => {
);
});
});
describe("slack proxy agent", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
// Clear all proxy env vars before each test
for (const key of ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"]) {
delete process.env[key];
}
});
afterEach(() => {
// Restore original env
for (const key of ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"]) {
if (originalEnv[key] !== undefined) {
process.env[key] = originalEnv[key];
} else {
delete process.env[key];
}
}
});
it("sets agent from HTTPS_PROXY env var", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeDefined();
expect(options.agent!.constructor.name).toBe("HttpsProxyAgent");
});
it("falls back to HTTP_PROXY when HTTPS_PROXY is not set", () => {
process.env.HTTP_PROXY = "http://proxy.example.com:3128";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeDefined();
});
it("does not set agent when no proxy env var is configured", () => {
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeUndefined();
});
it("does not override an explicitly provided agent", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
const customAgent = {} as never;
const options = resolveSlackWebClientOptions({ agent: customAgent });
expect(options.agent).toBe(customAgent);
});
it("prefers lowercase https_proxy over uppercase", () => {
process.env.https_proxy = "http://lower.example.com:3128";
process.env.HTTPS_PROXY = "http://upper.example.com:3128";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeDefined();
// HttpsProxyAgent stores the proxy URL — verify it picked the lower-case one
expect((options.agent as { proxy: { href: string } }).proxy.href).toContain("lower.example.com");
});
it("also applies proxy agent to write client options", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
const options = resolveSlackWriteClientOptions();
expect(options.agent).toBeDefined();
expect(options.agent!.constructor.name).toBe("HttpsProxyAgent");
});
});

View File

@@ -1,4 +1,5 @@
import { type RetryOptions, type WebClientOptions, WebClient } from "@slack/web-api";
import { HttpsProxyAgent } from "https-proxy-agent";
export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = {
retries: 2,
@@ -12,9 +13,36 @@ export const SLACK_WRITE_RETRY_OPTIONS: RetryOptions = {
retries: 0,
};
/**
* Build an HTTPS proxy agent from env vars (HTTPS_PROXY, HTTP_PROXY, etc.)
* for use as the `agent` option in Slack WebClient and Socket Mode connections.
*
* When set, this agent is forwarded through @slack/bolt → @slack/socket-mode →
* SlackWebSocket as the `httpAgent`, which the `ws` library uses to tunnel the
* WebSocket upgrade request through the proxy. This fixes Socket Mode in
* environments where outbound traffic must go through an HTTP CONNECT proxy.
*
* Returns `undefined` when no proxy env var is configured.
*/
function resolveSlackProxyAgent(): HttpsProxyAgent<string> | undefined {
// Match undici EnvHttpProxyAgent semantics: lower-case takes precedence,
// HTTPS prefers https_proxy then falls back to http_proxy.
const proxyUrl =
process.env.https_proxy?.trim() ||
process.env.HTTPS_PROXY?.trim() ||
process.env.http_proxy?.trim() ||
process.env.HTTP_PROXY?.trim() ||
undefined;
if (!proxyUrl) {
return undefined;
}
return new HttpsProxyAgent<string>(proxyUrl);
}
export function resolveSlackWebClientOptions(options: WebClientOptions = {}): WebClientOptions {
return {
...options,
agent: options.agent ?? resolveSlackProxyAgent(),
retryConfig: options.retryConfig ?? SLACK_DEFAULT_RETRY_OPTIONS,
};
}
@@ -22,6 +50,7 @@ export function resolveSlackWebClientOptions(options: WebClientOptions = {}): We
export function resolveSlackWriteClientOptions(options: WebClientOptions = {}): WebClientOptions {
return {
...options,
agent: options.agent ?? resolveSlackProxyAgent(),
retryConfig: options.retryConfig ?? SLACK_WRITE_RETRY_OPTIONS,
};
}