Files
orca/src/shared/network-proxy.ts
T
Neil 80a2041142 fix(browser): an SSH workspace browser reaches sites through the host's proxy (#36022)
An SSH workspace's browser reaches sites from the SSH host: the host's sshd
dials each destination over a direct-tcpip channel. sshd ignores the
host's HTTPS_PROXY/HTTP_PROXY/ALL_PROXY, so on a host whose only way out is
an HTTP proxy (a Docker sandbox, for example), every external site failed
with ERR_SOCKS_CONNECTION_FAILED while localhost pages loaded.

The SSH route now reads the proxy variables an exec session on the host
gets (the environment orcad runs with) when it opens, and picks the
upstream for each destination the same way a paired server's own tunnel
does: environment proxy with its NO_PROXY list, never for loopback. An
HTTP(S) proxy is reached over the SSH channel and asked to CONNECT; a
no-auth SOCKS5 proxy gets a SOCKS handshake. This works for both the ssh2
and system OpenSSH transports. The CONNECT and SOCKS clients now run over
any transport, so the server tunnel and the SSH route share them.

Fixes #35217
2026-10-29 10:51:24 -07:00

141 lines
4.1 KiB
TypeScript

export type NetworkProxySettings = {
httpProxyUrl?: string | null
httpProxyBypassRules?: string | null
}
// One English table: `message` stays English; the settings UI translates by code.
export const PROXY_URL_FAILURE_COPY = {
tooLong: 'Proxy URL is too long.',
invalid: 'Enter a valid proxy URL.',
unsupportedProtocol: 'Use an http, https, socks, socks4, or socks5 proxy URL.',
missingHost: 'Proxy URL must include a host.'
} as const
export type ProxyUrlFailureCode = keyof typeof PROXY_URL_FAILURE_COPY
export type ProxyUrlValidationResult =
| { ok: true; value: string; message?: undefined }
| { ok: false; value: ''; message: string; code: ProxyUrlFailureCode }
function proxyUrlFailure(code: ProxyUrlFailureCode): ProxyUrlValidationResult {
return { ok: false, value: '', message: PROXY_URL_FAILURE_COPY[code], code }
}
const PROXY_URL_MAX_LENGTH = 2048
const PROXY_BYPASS_RULES_MAX_LENGTH = 4096
const PROXY_PROTOCOLS = new Set(['http:', 'https:', 'socks:', 'socks4:', 'socks5:'])
const PROXY_ENV_KEYS = [
'HTTPS_PROXY',
'https_proxy',
'ALL_PROXY',
'all_proxy',
'HTTP_PROXY',
'http_proxy'
] as const
const NO_PROXY_ENV_KEYS = ['NO_PROXY', 'no_proxy'] as const
/** Every variable the environment proxy readers below consult. */
export const PROXY_ENVIRONMENT_KEYS: readonly string[] = [...PROXY_ENV_KEYS, ...NO_PROXY_ENV_KEYS]
function formatProxyUrl(url: URL): string {
const auth =
url.username || url.password ? `${url.username}${url.password ? `:${url.password}` : ''}@` : ''
return `${url.protocol}//${auth}${url.host}`
}
export function normalizeProxyUrl(value: unknown): ProxyUrlValidationResult {
if (typeof value !== 'string') {
return { ok: true, value: '' }
}
const trimmed = value.trim()
if (!trimmed) {
return { ok: true, value: '' }
}
if (trimmed.length > PROXY_URL_MAX_LENGTH) {
return proxyUrlFailure('tooLong')
}
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
return proxyUrlFailure('invalid')
}
if (!PROXY_PROTOCOLS.has(parsed.protocol)) {
return proxyUrlFailure('unsupportedProtocol')
}
if (!parsed.hostname) {
return proxyUrlFailure('missingHost')
}
return { ok: true, value: formatProxyUrl(parsed) }
}
export function normalizeProxyBypassRules(value: unknown): string {
if (typeof value !== 'string') {
return ''
}
return value
.slice(0, PROXY_BYPASS_RULES_MAX_LENGTH)
.split(/[;,\n]/)
.map((rule) => rule.trim())
.filter(Boolean)
.join(';')
}
export function getProxyUrlFromEnvironment(
env: Record<string, string | undefined>
): ProxyUrlValidationResult {
for (const key of PROXY_ENV_KEYS) {
if (env[key]) {
return normalizeProxyUrl(env[key])
}
}
return { ok: true, value: '' }
}
export function getProxyBypassRulesFromEnvironment(
env: Record<string, string | undefined>
): string {
for (const key of NO_PROXY_ENV_KEYS) {
if (env[key]) {
return normalizeProxyBypassRules(env[key])
}
}
return ''
}
export function buildConfiguredProxyEnv(
settings: NetworkProxySettings | null | undefined
): Record<string, string> {
const proxy = normalizeProxyUrl(settings?.httpProxyUrl)
if (!proxy.ok || !proxy.value) {
return {}
}
const env: Record<string, string> = {
HTTP_PROXY: proxy.value,
HTTPS_PROXY: proxy.value,
ALL_PROXY: proxy.value,
http_proxy: proxy.value,
https_proxy: proxy.value,
all_proxy: proxy.value
}
const bypassRules = normalizeProxyBypassRules(settings?.httpProxyBypassRules)
// Explicit proxy settings must not inherit shell NO_PROXY; only the configured bypass field controls child-process exclusions.
const noProxy = bypassRules ? bypassRules.replaceAll(';', ',') : ''
env.NO_PROXY = noProxy
env.no_proxy = noProxy
return env
}
export function redactProxyUrl(value: string): string {
const parsed = normalizeProxyUrl(value)
if (!parsed.ok || !parsed.value) {
return parsed.value
}
const url = new URL(parsed.value)
if (url.username || url.password) {
url.username = '***'
url.password = url.password ? '***' : ''
}
return formatProxyUrl(url)
}