proxy-config.ts
391 lines · browse/src/proxy-config.ts at 3f79d81
1/**
2 * Parse + validate proxy config from CLI flags and environment.
3 *
4 * Used by:
5 * cli.ts — to resolve the proxy (resolveProxy), detect cred-mixing and
6 * daemon-mismatch, and forward to server
7 * server.ts — to spawn the bridge and pass proxy to chromium.launch
8 *
9 * Proxy precedence (#915): --proxy, then BROWSE_PROXY_URL, then HTTPS_PROXY,
10 * HTTP_PROXY and ALL_PROXY (upper case first, then lower case), then direct.
11 * BROWSE_PROXY_URL=direct opts out of the standard variables.
12 *
13 * Cred policy (D9): if BOTH the URL embeds creds AND the env vars
14 * BROWSE_PROXY_USER/PASS are set, refuse with a clear error. No silent
15 * override — debugging confusion is worse than a one-time setup error.
16 */
18import { createHash } from 'crypto';
19import type { UpstreamConfig } from './socks-bridge';
21export interface ParsedProxyConfig {
22 /** Original scheme: 'socks5' | 'http' | 'https' */
23 scheme: 'socks5' | 'http' | 'https';
24 host: string;
25 port: number;
26 userId?: string;
27 password?: string;
28 /** True if creds are present (from URL or env). */
29 hasAuth: boolean;
30}
32export class ProxyConfigError extends Error {
33 constructor(public readonly hint: string, message: string) {
34 super(message);
35 this.name = 'ProxyConfigError';
36 }
37}
39function percentDecodable(part: string): boolean {
40 try {
41 decodeURIComponent(part);
42 return true;
43 } catch {
44 return false;
45 }
46}
48/**
49 * Parse the BROWSE_PROXY_URL string and merge env-supplied creds.
50 *
51 * @throws ProxyConfigError on malformed URL, unsupported scheme, a path
52 * (other than a bare `/`), query or fragment, bad credential
53 * percent-encoding, or ambiguous credentials (set in both URL and env).
54 */
55export function parseProxyConfig(opts: {
56 proxyUrl: string;
57 envUser?: string;
58 envPass?: string;
59 /** Where proxyUrl came from, named in the cred-mixing hint. */
60 source?: string;
61}): ParsedProxyConfig {
62 let url: URL;
63 try {
64 url = new URL(opts.proxyUrl);
65 } catch {
66 throw new ProxyConfigError(
67 'expected scheme://[user:pass@]host:port',
68 `invalid proxy URL — could not parse`,
69 );
70 }
72 // socks5h (remote DNS) is what Chromium's socks5 already does.
73 const scheme = url.protocol === 'socks5h:' ? 'socks5' : url.protocol.replace(':', '');
74 if (scheme !== 'socks5' && scheme !== 'http' && scheme !== 'https') {
75 throw new ProxyConfigError(
76 'use socks5://, http://, or https://',
77 `unsupported proxy scheme '${scheme}'`,
78 );
79 }
81 if (!url.hostname) {
82 throw new ProxyConfigError(
83 'expected scheme://[user:pass@]host:port',
84 `invalid proxy URL — missing host`,
85 );
86 }
88 // A proxy address has no path, query or fragment. Anything the URL parser
89 // put there came from a value that was not a proxy URL (a backslash in a
90 // DOMAIN\user login, a '/' in a password) and would otherwise be printed
91 // in the clear, because only the userinfo is redacted.
92 if ((url.pathname !== '' && url.pathname !== '/') || url.search || url.hash || /[?#]/.test(opts.proxyUrl)) {
93 throw new ProxyConfigError(
94 'expected scheme://[user:pass@]host:port with no path; percent-encode any / \\ ? # @ in the credentials',
95 `invalid proxy URL — unexpected path, query or fragment`,
96 );
97 }
99 const port = url.port
100 ? parseInt(url.port, 10)
101 : (scheme === 'http' ? 80 : scheme === 'https' ? 443 : 1080);
102 if (!Number.isInteger(port) || port <= 0 || port > 65535) {
103 throw new ProxyConfigError(
104 'expected scheme://[user:pass@]host:port',
105 `invalid proxy URL — bad port`,
106 );
107 }
109 const urlHasUser = !!url.username;
110 const urlHasPass = !!url.password;
111 const envHasUser = !!opts.envUser;
112 const envHasPass = !!opts.envPass;
113 const urlHasCreds = urlHasUser || urlHasPass;
114 const envHasCreds = envHasUser || envHasPass;
116 // D9 (codex correction): refuse on mixed sources. Silent override is a
117 // debugging trap — when a stale BROWSE_PROXY_USER from a prior session
118 // wins over a fresh --proxy URL, the user can't tell why.
119 if (urlHasCreds && envHasCreds) {
120 throw new ProxyConfigError(
121 `unset BROWSE_PROXY_USER/PASS or remove user:pass@ from ${opts.source ?? '--proxy'}`,
122 `proxy creds set in both env (BROWSE_PROXY_USER) and URL — pick one source`,
123 );
124 }
126 let userId: string | undefined;
127 let password: string | undefined;
128 if (urlHasCreds && ![url.username, url.password].every(percentDecodable)) {
129 throw new ProxyConfigError(
130 'percent-encode the credentials (a literal % is %25)',
131 `invalid proxy URL — credentials are not valid percent-encoding`,
132 );
133 }
134 if (urlHasCreds) {
135 userId = decodeURIComponent(url.username);
136 password = url.password ? decodeURIComponent(url.password) : undefined;
137 } else if (envHasCreds) {
138 userId = opts.envUser;
139 password = opts.envPass;
140 }
142 return {
143 scheme: scheme as 'socks5' | 'http' | 'https',
144 host: url.hostname,
145 port,
146 ...(userId ? { userId } : {}),
147 ...(password ? { password } : {}),
148 hasAuth: !!(userId || password),
149 };
150}
152/** Convert a ParsedProxyConfig to the UpstreamConfig shape socks-bridge wants. */
153export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig {
154 return {
155 host: cfg.host,
156 port: cfg.port,
157 ...(cfg.userId ? { userId: cfg.userId } : {}),
158 ...(cfg.password ? { password: cfg.password } : {}),
159 };
160}
162/**
163 * Compute a stable hash of the daemon config (proxyUrl + headed flag, plus
164 * the NO_PROXY rules beside a proxy and the explicit direct opt-out where it
165 * bypasses the system proxy settings, #3440) for daemon-mismatch detection
166 * (D2). The hash is deterministic across CLI invocations on the same machine
167 * and survives daemon restarts via the state file.
168 *
169 * NEVER include resolved creds — the hash compares config intent, not
170 * specific credential values, and we don't want creds in any persisted form.
171 */
172export function computeConfigHash(opts: {
173 proxyUrl: string | null | undefined;
174 headed: boolean;
175 /** Bypass rules derived from NO_PROXY (resolveProxy's noProxyRules). */
176 noProxyRules?: string | null;
177 /** explicitDirect(): BROWSE_PROXY_URL=direct where it bypasses the system proxy settings. */
178 explicitDirect?: boolean;
179}): string {
180 const proxyKey = canonicalizeProxyUrl(opts.proxyUrl);
181 const input = JSON.stringify({
182 proxy: proxyKey,
183 headed: opts.headed,
184 ...(proxyKey && opts.noProxyRules ? { noProxy: opts.noProxyRules } : {}),
185 ...(!proxyKey && opts.explicitDirect ? { direct: true } : {}),
186 });
187 return createHash('sha256').update(input).digest('hex').slice(0, 16);
188}
190/** Strip creds from a proxy URL for hashing. Returns null for empty input. */
191function canonicalizeProxyUrl(input: string | null | undefined): string | null {
192 if (!input) return null;
193 try {
194 const u = new URL(input);
195 u.username = '';
196 u.password = '';
197 return `${u.protocol}//${u.host}`;
198 } catch {
199 return '<unparseable>';
200 }
201}
203export const STANDARD_PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'] as const;
204const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
205export const PROXY_TROUBLESHOOTING = 'https://github.com/garrytan/gstack/blob/main/BROWSER.md#proxy-from-environment';
207export interface ResolvedProxy {
208 /** Proxy URL with resolved creds embedded, or null for a direct connection. */
209 url: string | null;
210 /** '--proxy' or the env var the proxy came from; null when direct. */
211 source: string | null;
212 /** NO_PROXY translated to Chromium bypass rules (sorted, comma-separated), or null. */
213 noProxyRules: string | null;
214 /** Rules Chromium receives: noProxyRules plus loopback; null when direct. */
215 bypass: string | null;
216 /** Set when a standard proxy variable was ignored because it does not parse. */
217 warning: string | null;
218}
220/**
221 * Translate a curl-style NO_PROXY list to Chromium bypass rules: `example.com`
222 * and `.example.com` cover the domain and its subdomains, `*` bypasses all,
223 * CIDR blocks and `host:port` pass through, and bare IPv6 gets brackets.
224 * Loopback names are left out: withLoopbackBypass always adds them, and the
225 * CLI appends them to NO_PROXY for its own daemon calls (#494).
226 */
227export function noProxyToChromiumRules(noProxy: string | undefined): string[] {
228 const rules = new Set<string>();
229 for (const raw of (noProxy ?? '').toLowerCase().split(/[\s,]+/)) {
230 if (!raw) continue;
231 if (raw === '*' || raw.includes('/')) { rules.add(raw); continue; }
232 let host = raw;
233 let port = '';
234 const bracket = raw.match(/^(\[[0-9a-f:.]+\])(:\d+)?$/);
235 if (bracket) [host, port] = [bracket[1], bracket[2] ?? ''];
236 else if (raw.split(':').length > 2) {
237 if (!/^[0-9a-f:.]+$/.test(raw)) continue;
238 host = `[${raw}]`;
239 }
240 else if (raw.includes(':')) [host, port] = [raw.slice(0, raw.lastIndexOf(':')), raw.slice(raw.lastIndexOf(':'))];
241 if (port && !/^:\d+$/.test(port)) continue;
242 if (!port && LOOPBACK_HOSTS.includes(host)) continue;
243 if (host.startsWith('*.')) { rules.add(host + port); continue; }
244 host = host.replace(/^\./, '');
245 if (!host) continue;
246 rules.add(host + port);
247 if (!host.startsWith('[') && !/^\d+(\.\d+){3}$/.test(host)) rules.add(`*.${host}${port}`);
248 }
249 return [...rules].sort();
250}
252/**
253 * Chromium bypass rules for a proxied session: the NO_PROXY rules plus
254 * loopback. Naming loopback also stops Playwright from adding `<-loopback>`,
255 * which would send localhost through the proxy.
256 */
257export function withLoopbackBypass(rules: string[]): string {
258 return [...new Set([...rules, ...LOOPBACK_HOSTS])].join(',');
259}
261/**
262 * The daemon's view of the proxy the CLI resolved: BROWSE_PROXY_URL ('direct'
263 * means none), its source, the bypass rules (computed from NO_PROXY when a
264 * caller other than the CLI set only BROWSE_PROXY_URL), and whether an
265 * explicit direct bypasses the system proxy settings here (explicitDirect).
266 */
267export function serverProxyEnv(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): { url: string | undefined; source: string; bypass: string; direct: boolean } {
268 return {
269 url: env.BROWSE_PROXY_URL === 'direct' ? undefined : env.BROWSE_PROXY_URL || undefined,
270 source: env.BROWSE_PROXY_SOURCE || 'BROWSE_PROXY_URL',
271 bypass: env.BROWSE_PROXY_BYPASS || withLoopbackBypass(noProxyToChromiumRules(env.NO_PROXY || env.no_proxy)),
272 direct: explicitDirect(env, platform),
273 };
274}
276/**
277 * The Chromium switch that makes a direct connection explicit (#3440 item 8).
278 * Launched without --proxy-server, Chromium reads HTTPS_PROXY, HTTP_PROXY and
279 * ALL_PROXY (Linux) or the system proxy settings itself. Not --no-proxy-server:
280 * chrome-headless-shell, the default headless build, ignores that switch and
281 * still took HTTPS_PROXY (measured on Playwright 1.62.1); the full Chromium
282 * honors both, and direct:// works on both.
283 */
284export const DIRECT_PROXY_ARG = '--proxy-server=direct://';
286/**
287 * Whether a direct connection is made explicit to Chromium with
288 * DIRECT_PROXY_ARG. Always on Linux, where the leak in #3440 item 8 lives:
289 * Chromium launched without --proxy-server reads the proxy variables itself.
290 * Chromium on macOS and Windows never reads them and follows the system proxy
291 * settings (PAC and WPAD included), which GStack Browser.app users behind a
292 * corporate proxy rely on, so there the switch is passed only when the user
293 * asked for a direct connection with BROWSE_PROXY_URL=direct.
294 */
295export function forcesDirect(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): boolean {
296 return platform === 'linux' || explicitDirect(env, platform);
297}
299/**
300 * Whether BROWSE_PROXY_URL=direct changes what Chromium does on this
301 * platform. On macOS and Windows it is the only thing that makes the daemon
302 * bypass the system proxy settings, so it is part of the daemon's config hash
303 * (a reused daemon started without it would keep the system proxy while the
304 * CLI reports direct), is recorded in the daemon state and is re-emitted on an
305 * auto-restart from the invocation's own flags, which the hash gate already
306 * matched against that daemon. On Linux every direct connection already carries
307 * DIRECT_PROXY_ARG, so the opt-out changes nothing and hashes like no proxy.
308 */
309export function explicitDirect(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): boolean {
310 return platform !== 'linux' && env.BROWSE_PROXY_URL === 'direct';
311}
313/**
314 * The daemon's launch log line when no proxy is configured: the switch
315 * Chromium was given and what it ignores for it (each standard variable set on
316 * Linux, the system proxy settings elsewhere), or that Chromium follows the
317 * system proxy settings.
318 */
319export function directProxyLogLine(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): string {
320 if (!forcesDirect(env, platform)) {
321 return `[browse] [proxy] none: system proxy settings (Chromium's default on ${platform}; BROWSE_PROXY_URL=direct connects directly)`;
322 }
323 const ignored = platform === 'linux' ? STANDARD_PROXY_VARS.filter(name => env[name]) : ['the system proxy settings'];
324 return `[browse] [proxy] none: direct connection (${DIRECT_PROXY_ARG}${ignored.length ? `; ignoring ${ignored.join(', ')}` : ''})`;
325}
327/**
328 * The scheme-less proxy shapes curl and Chromium's environment reader take as
329 * an HTTP proxy: `[user:pass@]host[:port]` with a name, dotted address or
330 * bracketed IPv6 host. Anything else (a scheme, a path, query or fragment
331 * character, a backslash, whitespace inside, a bare number) goes to the URL
332 * parser as written, so a bare number such as `1234` keeps the diagnostic
333 * it had and `http:/host:3128` reads as it always did; parseProxyConfig
334 * then rejects any path, query or fragment, so no part of a password can
335 * land outside the redacted userinfo.
336 * A userinfo that opens with a known scheme (`https:bob@host:3128`, the
337 * slash-less spelling the URL parser reads as an `https://` URL with user
338 * `bob`) is a scheme, not credentials, so it is excluded too.
339 */
340const SCHEMELESS_PROXY_RE = /^(?:[^/\\?#@\s]+@)?(?:\[[0-9a-f:.]+\]|(?=[^:]*[a-z.])[^/\\?#@\s:[\]]+)(?::\d+)?$/i;
341const LEADING_SCHEME_RE = /^(?:https?|socks5h?):/i;
343/**
344 * Resolve the proxy for this invocation (#915). Env URLs go through
345 * parseProxyConfig, so the cred policy applies. A standard variable in a
346 * scheme-less shape (curl-style `host:port` or `user:pass@host:port`,
347 * SCHEMELESS_PROXY_RE) is read as an `http://` proxy, the way curl takes it.
348 * For `host:port` that is also what Chromium's own environment reader did,
349 * so a session that worked through Chromium reading the variable keeps
350 * working now that the launch is explicit (#3440); `user:pass@host:port` is
351 * newly proxied, because Chromium rejects any proxy spec with a userinfo. One that still does not parse (an unsupported scheme, a bad port,
352 * a missing host) is ignored with a warning instead of failing every command;
353 * --proxy and BROWSE_PROXY_URL are explicit browse settings and still fail
354 * closed, scheme required.
355 */
356export function resolveProxy(flagUrl: string | null, env: NodeJS.ProcessEnv): ResolvedProxy {
357 const direct = (warning: string | null = null): ResolvedProxy => ({ url: null, source: null, noProxyRules: null, bypass: null, warning });
358 let source: string;
359 let raw: string;
360 if (flagUrl) {
361 [source, raw] = ['--proxy', flagUrl];
362 } else if (env.BROWSE_PROXY_URL) {
363 if (env.BROWSE_PROXY_URL === 'direct') return direct();
364 [source, raw] = ['BROWSE_PROXY_URL', env.BROWSE_PROXY_URL];
365 } else {
366 const name = STANDARD_PROXY_VARS.find(key => env[key]);
367 if (!name) return direct();
368 const value = env[name]!.trim();
369 [source, raw] = [name, SCHEMELESS_PROXY_RE.test(value) && !LEADING_SCHEME_RE.test(value) ? `http://${value}` : value];
370 try {
371 parseProxyConfig({ proxyUrl: raw });
372 } catch (err) {
373 if (!(err instanceof ProxyConfigError)) throw err;
374 return direct(`${source} is not a proxy URL browse can use (${err.message}; ${err.hint}), so browse connects directly. ` +
375 `fix: export ${source}=http://host:port (or socks5://), or set BROWSE_PROXY_URL=direct to silence this (on macOS and Windows that also makes Chromium bypass the system proxy settings). see ${PROXY_TROUBLESHOOTING}`);
376 }
377 }
378 const parsed = parseProxyConfig({ proxyUrl: raw, envUser: env.BROWSE_PROXY_USER, envPass: env.BROWSE_PROXY_PASS, source });
379 const url = new URL(raw);
380 if (url.protocol === 'socks5h:') url.protocol = 'socks5:';
381 url.username = parsed.userId ? encodeURIComponent(parsed.userId) : '';
382 url.password = parsed.password ? encodeURIComponent(parsed.password) : '';
383 const rules = noProxyToChromiumRules(env.NO_PROXY || env.no_proxy);
384 return {
385 url: url.toString(),
386 source,
387 noProxyRules: rules.length ? rules.join(',') : null,
388 bypass: withLoopbackBypass(rules),
389 warning: null,
390 };
391}