Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

### Added

- Support for VS Code's built-in proxy settings: `http.noProxy` (as fallback when `coder.proxyBypass`
is not set), `http.proxyAuthorization`, and `http.proxyStrictSSL`.

### Fixed

- Fixed proxy scheme handling where URLs with schemes got duplicated and URLs without schemes
were not normalized.

### Changed

- WebSocket connections are now more robust and reconnect less frequently, only when truly
Expand Down
3 changes: 3 additions & 0 deletions src/api/coderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ const webSocketConfigSettings = [
"coder.tlsAltHost",
"http.proxy",
"coder.proxyBypass",
"http.noProxy",
"http.proxyAuthorization",
"http.proxyStrictSSL",
] as const;

/**
Expand Down
12 changes: 10 additions & 2 deletions src/api/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ const DEFAULT_PORTS: Record<string, number> = {

/**
* @param {string|object} url - The URL, or the result from url.parse.
* @param {string} httpProxy - The proxy URL to use.
* @param {string} noProxy - Primary no-proxy list (e.g., coder.proxyBypass).
* @param {string} noProxyFallback - Fallback no-proxy list (e.g., http.noProxy).
* @return {string} The URL of the proxy that should handle the request to the
* given URL. If no proxy is set, this will be an empty string.
*/
export function getProxyForUrl(
url: string,
httpProxy: string | null | undefined,
noProxy: string | null | undefined,
noProxyFallback?: string | null,
): string {
const parsedUrl = typeof url === "string" ? parseUrl(url) : url || {};
let proto = parsedUrl.protocol;
Expand All @@ -36,7 +40,7 @@ export function getProxyForUrl(
hostname = hostname.replace(/:\d*$/, "");
const port =
(portRaw && Number.parseInt(portRaw)) || DEFAULT_PORTS[proto] || 0;
if (!shouldProxy(hostname, port, noProxy)) {
if (!shouldProxy(hostname, port, noProxy, noProxyFallback)) {
return ""; // Don't proxy URLs that match NO_PROXY.
}

Expand All @@ -46,7 +50,7 @@ export function getProxyForUrl(
getEnv(proto + "_proxy") ||
getEnv("npm_config_proxy") ||
getEnv("all_proxy");
if (proxy?.includes("://")) {
if (proxy && !proxy.includes("://")) {
// Missing scheme in proxy, default to the requested URL's scheme.
proxy = proto + "://" + proxy;
}
Expand All @@ -58,16 +62,20 @@ export function getProxyForUrl(
*
* @param {string} hostname - The host name of the URL.
* @param {number} port - The effective port of the URL.
* @param {string} noProxy - Primary no-proxy list (e.g., coder.proxyBypass).
* @param {string} noProxyFallback - Fallback no-proxy list (e.g., http.noProxy).
* @returns {boolean} Whether the given URL should be proxied.
* @private
*/
function shouldProxy(
hostname: string,
port: number,
noProxy: string | null | undefined,
noProxyFallback?: string | null,
): boolean {
const NO_PROXY = (
noProxy ||
noProxyFallback ||
getEnv("npm_config_no_proxy") ||
getEnv("no_proxy")
).toLowerCase();
Expand Down
24 changes: 18 additions & 6 deletions src/api/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getProxyForUrl } from "./proxy";
* If mTLS is in use (as specified by the cert or key files being set) then
* token authorization is disabled. Otherwise, it is enabled.
*/
export function needToken(cfg: WorkspaceConfiguration): boolean {
export function needToken(cfg: Pick<WorkspaceConfiguration, "get">): boolean {
const certFile = expandPath(
String(cfg.get("coder.tlsCertFile") ?? "").trim(),
);
Expand All @@ -24,9 +24,13 @@ export function needToken(cfg: WorkspaceConfiguration): boolean {
* Configures proxy, TLS certificates, and security options.
*/
export async function createHttpAgent(
cfg: WorkspaceConfiguration,
cfg: Pick<WorkspaceConfiguration, "get">,
): Promise<ProxyAgent> {
const insecure = Boolean(cfg.get("coder.insecure"));
const insecure = cfg.get<boolean>("coder.insecure", false);
const proxyStrictSSL = cfg.get<boolean>("http.proxyStrictSSL", true);
const proxyAuthorization = cfg.get<string>("http.proxyAuthorization");
const httpNoProxy = cfg.get<string>("http.noProxy");

const certFile = expandPath(
String(cfg.get("coder.tlsCertFile") ?? "").trim(),
);
Expand All @@ -40,21 +44,29 @@ export async function createHttpAgent(
caFile === "" ? Promise.resolve(undefined) : fs.readFile(caFile),
]);

// Build proxy authorization header if configured.
const headers: Record<string, string> | undefined = proxyAuthorization
? { "Proxy-Authorization": proxyAuthorization }
: undefined;

return new ProxyAgent({
// Called each time a request is made.
getProxyForUrl: (url: string) => {
return getProxyForUrl(
url,
cfg.get("http.proxy"),
cfg.get("coder.proxyBypass"),
httpNoProxy,
);
},
headers,
cert,
key,
ca,
servername: altHost === "" ? undefined : altHost,
// rejectUnauthorized defaults to true, so we need to explicitly set it to
// false if we want to allow self-signed certificates.
rejectUnauthorized: !insecure,
// TLS verification is disabled if either:
// - http.proxyStrictSSL is false (VS Code's proxy SSL setting)
// - coder.insecure is true (backward compatible override for Coder server)
rejectUnauthorized: proxyStrictSSL && !insecure,
});
}
257 changes: 257 additions & 0 deletions test/unit/api/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

import { getProxyForUrl } from "@/api/proxy";

describe("proxy", () => {
const proxy = "http://proxy.example.com:8080";

beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.unstubAllEnvs();
});

describe("getProxyForUrl", () => {
describe("proxy resolution", () => {
it("returns httpProxy when provided", () => {
expect(getProxyForUrl("https://example.com", proxy, null)).toBe(proxy);
});

it("falls back to environment variables when httpProxy is null", () => {
vi.stubEnv("HTTPS_PROXY", "http://env-proxy.example.com:8080");
expect(getProxyForUrl("https://example.com", null, null)).toBe(
"http://env-proxy.example.com:8080",
);
});

it("returns empty string when no proxy is configured", () => {
const proxyEnvVars = [
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"ALL_PROXY",
"all_proxy",
"npm_config_https_proxy",
"npm_config_proxy",
];
proxyEnvVars.forEach((v) => vi.stubEnv(v, ""));

expect(getProxyForUrl("https://example.com", null, null)).toBe("");
});

it("returns empty string for invalid URLs", () => {
expect(getProxyForUrl("invalid", proxy, null)).toBe("");
});
});

describe("noProxy handling", () => {
interface NoProxyBypassCase {
name: string;
noProxy: string;
url: string;
}

it.each<NoProxyBypassCase>([
{
name: "exact match",
noProxy: "internal.example.com",
url: "https://internal.example.com",
},
{
name: "wildcard",
noProxy: "*.internal.example.com",
url: "https://api.internal.example.com",
},
{
name: "suffix",
noProxy: ".example.com",
url: "https://api.example.com",
},
{
name: "wildcard *",
noProxy: "*",
url: "https://any.domain.com",
},
{
name: "port-specific",
noProxy: "example.com:8443",
url: "https://example.com:8443",
},
])(
"bypasses proxy when hostname matches noProxy ($name)",
({ noProxy, url }) => {
expect(getProxyForUrl(url, proxy, noProxy)).toBe("");
},
);

it("proxies when hostname does not match noProxy", () => {
expect(
getProxyForUrl("https://external.com", proxy, "internal.example.com"),
).toBe(proxy);
});

it("proxies when port does not match noProxy port", () => {
expect(
getProxyForUrl("https://example.com:443", proxy, "example.com:8443"),
).toBe(proxy);
});

it("handles multiple entries in noProxy (comma-separated)", () => {
const noProxy = "localhost,127.0.0.1,.internal.com";

expect(getProxyForUrl("https://localhost", proxy, noProxy)).toBe("");
expect(getProxyForUrl("https://127.0.0.1", proxy, noProxy)).toBe("");
expect(getProxyForUrl("https://api.internal.com", proxy, noProxy)).toBe(
"",
);
expect(getProxyForUrl("https://external.com", proxy, noProxy)).toBe(
proxy,
);
});
});

describe("noProxy fallback chain", () => {
const targetUrl = "https://internal.example.com";
const targetHost = "internal.example.com";

interface NoProxyFallbackCase {
name: string;
noProxy: string | null;
noProxyFallback: string;
}

it.each<NoProxyFallbackCase>([
{
name: "noProxy (coder.proxyBypass)",
noProxy: targetHost,
noProxyFallback: "other.example.com",
},
{
name: "noProxyFallback when noProxy is null",
noProxy: null,
noProxyFallback: targetHost,
},
{
name: "noProxyFallback when noProxy is empty",
noProxy: "",
noProxyFallback: targetHost,
},
])("uses $name", ({ noProxy, noProxyFallback }) => {
expect(getProxyForUrl(targetUrl, proxy, noProxy, noProxyFallback)).toBe(
"",
);
});

interface EnvVarFallbackCase {
name: string;
envVar: string;
}

it.each<EnvVarFallbackCase>([
{ name: "npm_config_no_proxy", envVar: "npm_config_no_proxy" },
{ name: "NO_PROXY", envVar: "NO_PROXY" },
{ name: "no_proxy (lowercase)", envVar: "no_proxy" },
])("falls back to $name env var", ({ envVar }) => {
// Clear all no_proxy env vars first
vi.stubEnv("npm_config_no_proxy", "");
vi.stubEnv("NO_PROXY", "");
vi.stubEnv("no_proxy", "");

vi.stubEnv(envVar, targetHost);
expect(getProxyForUrl(targetUrl, proxy, null, null)).toBe("");
});

it("prioritizes noProxy over noProxyFallback over env vars", () => {
vi.stubEnv("NO_PROXY", "env.example.com");

// noProxy takes precedence
expect(
getProxyForUrl(
"https://primary.example.com",
proxy,
"primary.example.com",
"fallback.example.com",
),
).toBe("");

// noProxyFallback is used when noProxy is null
expect(
getProxyForUrl(
"https://fallback.example.com",
proxy,
null,
"fallback.example.com",
),
).toBe("");

// env var is used when both are null
expect(
getProxyForUrl("https://env.example.com", proxy, null, null),
).toBe("");
});
});

describe("protocol and port handling", () => {
interface ProtocolCase {
protocol: string;
url: string;
}

it.each<ProtocolCase>([
{ protocol: "http://", url: "http://example.com" },
{ protocol: "https://", url: "https://example.com" },
{ protocol: "ws://", url: "ws://example.com" },
{ protocol: "wss://", url: "wss://example.com" },
])("handles $protocol URLs", ({ url }) => {
expect(getProxyForUrl(url, proxy, null)).toBe(proxy);
});

interface DefaultPortCase {
protocol: string;
url: string;
defaultPort: number;
}

it.each<DefaultPortCase>([
{ protocol: "HTTP", url: "http://example.com", defaultPort: 80 },
{ protocol: "HTTPS", url: "https://example.com", defaultPort: 443 },
{ protocol: "WS", url: "ws://example.com", defaultPort: 80 },
{ protocol: "WSS", url: "wss://example.com", defaultPort: 443 },
])(
"uses default port $defaultPort for $protocol",
({ url, defaultPort }) => {
expect(getProxyForUrl(url, proxy, `example.com:${defaultPort}`)).toBe(
"",
);
},
);
});

describe("proxy scheme handling", () => {
it("adds scheme to proxy URL when missing", () => {
expect(
getProxyForUrl("https://example.com", "proxy.example.com:8080", null),
).toBe("https://proxy.example.com:8080");
});

it("uses request scheme when proxy has no scheme", () => {
expect(
getProxyForUrl("http://example.com", "proxy.example.com:8080", null),
).toBe("http://proxy.example.com:8080");
});

it("preserves existing scheme in proxy URL", () => {
expect(
getProxyForUrl(
"https://example.com",
"http://proxy.example.com:8080",
null,
),
).toBe("http://proxy.example.com:8080");
});
});
});
});
Loading