Files
gstack/browse/src/proxy-config.ts
T
Garry Tanandcapy-ai[bot] 5755601dac v1.91.248.0 fix(browse): direct mode launches Chromium with --proxy-server=direct:// so proxy env vars never win silently (#3440 item 8) (#3448)
* fix(browse): direct mode launches Chromium with --proxy-server=direct:// so proxy env vars never win silently (#3440 item 8)

BROWSE_PROXY_URL=direct only stopped gstack from configuring a proxy; the daemon
inherits the environment and the launch passed no proxy switch, so Chromium read
HTTPS_PROXY/HTTP_PROXY/ALL_PROXY itself and every goto still went through that
proxy while browse reported a direct connection. Every launch path now adds
--proxy-server=direct:// when no proxy is configured (chrome-headless-shell
ignores --no-proxy-server), the daemon logs the effective proxy mode on launch,
and BROWSER.md's proxy section says what Chromium actually gets.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Name the direct-connection switch once (DIRECT_PROXY_ARG in proxy-config.ts) so the
launch argument and the daemon log line cannot drift apart.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): force direct:// only where Chromium would pick its own proxy; review fixes

Red team (ship Step 9.2): the first cut passed --proxy-server=direct:// on every
platform, but Chromium reads HTTPS_PROXY/HTTP_PROXY/ALL_PROXY itself only on
Linux. On macOS and Windows it follows the system proxy settings (PAC/WPAD),
which GStack Browser.app users behind a corporate proxy depend on, and
BROWSE_PROXY_URL cannot name a PAC. forcesDirect(env, platform) now gates the
switch: Linux always, elsewhere only with BROWSE_PROXY_URL=direct. The daemon
log says what is really ignored per platform, or that the system settings
apply.

Review pass 2: the CLI e2e asserts on the daemon-log slice its own invocation
wrote (the log is append-mode and shared across cases); connectProxy moves to
browse/test/helpers/connect-proxy.ts for both e2e files; the CHANGELOG Tests
bullets cover the generated cases. Adds the coverage-audit cases
(unparseable HTTPS_PROXY; extension, headed and handoff paths under Xvfb).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(browse): assert the direct switch on the launched Chromium command line per platform (#3440 item 8)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Ship review pass 3 (red team, GStack Minder's decision B, 2026-10-26): on macOS
and Windows an explicit BROWSE_PROXY_URL=direct is the only thing that makes the
daemon bypass the system proxy settings, so explicitDirect() puts it in the
daemon config hash, server.ts records it as proxyDirect in the state file, and
buildRestartEnv re-emits it on a crash auto-restart. A reused daemon started on
the system proxy is now refused (the refusal names BROWSE_PROXY_URL and offers
browse stop or unsetting it) instead of silently keeping the system proxy while
the CLI reports direct. Linux hashes as before. BROWSER.md says the daemon log
records the mode each time the daemon starts, not on every launch. The Linux
desktop (GNOME/KDE) system-proxy question is filed as #3450.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(browse): daemon-state and refusal e2e for the explicit direct opt-out; headed env and BROWSER.md contract checks (#3440 item 8)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Ship rerun review pass: proxy-direct-launch-args.test.ts joins the macOS
named-regressions job so its omission arm (no switch without the opt-out) runs
in CI; serverProxyEnv takes the platform so the state-file predicate is pinned
per platform instead of mirroring process.platform; buildHeadedServerEnv's
parameter type requires explicitDirect so a narrowed caller cannot drop the
opt-out silently; BROWSER.md's precedence item 6 and the reuse sentence say
what each platform does (a connect daemon gets the generic flags refusal);
CHANGELOG notes the one-time refusal against a pre-release daemon.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Ship adversarial pass: a crash restart re-emits BROWSE_PROXY_URL=direct from the
daemon state only when the invocation has no flags, so the env it hands the new
daemon never contradicts the config hash it also hands over; an explicit-direct
session is refused against a daemon with no recorded config hash, like --proxy
and --headed already were; the unparseable-variable hint says that the opt-out
also bypasses the system proxy settings on macOS and Windows; BROWSER.md notes
that a managed Chromium proxy policy still overrides the switch; CHANGELOG names
both files that joined the macOS named-regressions job.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): read scheme-less proxy variables as http:// so the explicit direct launch keeps those sessions proxied (#3440 item 8)

A standard proxy variable without a scheme (curl-style host:port or
user:pass@host:port) was rejected by browse's parser with a "connects
directly" warning while Chromium on Linux read the variable itself, so the
session was proxied anyway. With the launch now explicit
(--proxy-server=direct://) that environment went genuinely direct. resolveProxy
now reads such values as http://, the way curl and Chromium's own environment
reader take them; --proxy and BROWSE_PROXY_URL still require the scheme, and a
value that does not parse even as http:// still warns and connects directly.

Also drops buildRestartEnv's persisted-state re-emit branch, which production
never reached (_globalFlags is set before any restart), names the four
BrowserManager launch sites in the CHANGELOG and excludes the cookie-import
worker, and carries the docs audit's one-line BROWSER.md project-tree note.

Decision: GStack Minder (2026-10-26), option A.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

The lenient scheme-less proxy read is now gated on the plain
[user:pass@]host[:port] shape (SCHEMELESS_PROXY_RE, trimmed): a scheme'd value
with surrounding whitespace, a one-slash typo and a bare number keep the
behaviour they had, and a credential that would spill past the userinfo
(?, #, /) is not rewritten, so nothing can land outside the redacted part.
Credentials that are not valid percent-encoding are a ProxyConfigError for
every source instead of a URIError crash. buildRestartEnv's docblock, BROWSER.md
and the CHANGELOG describe the parse (port 80 default, macOS/Windows now treat
such a value as a configured proxy).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

A backslash is excluded from the scheme-less proxy shape and parseProxyConfig
refuses any proxy URL with a path, query or fragment, for every source: WHATWG
parsing ends an http authority at a backslash, so a DOMAIN\user login or a /
in a password landed in the path, which the redactor does not mask, and was
printed in the clear on the start line, in the daemon log and in the state
file. Such values now warn (standard variables) or error (--proxy,
BROWSE_PROXY_URL) without echoing the credential. Docs updated.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): keep a slash-less scheme spelling, refuse a bare ? or #, and tighten the proxy docs

A standard proxy variable whose userinfo opens with a known scheme
(https:bob:pw@host:3128, which the URL parser reads as https://) is no
longer taken as scheme-less credentials, so the protocol it names is kept.
parseProxyConfig refuses a literal ? or # anywhere in the value, not only
a non-empty query or fragment. The unparseable-variable test now puts the
credential in every looped value, so its no-echo assertion is real; new
cases pin the slash-less scheme spelling, the bare ?/#, an env-sourced
trailing slash, and that mixed credential sources stay a hard error for
the scheme-less spelling as for the http:// one (the cred-mixing refusal
predates this branch and its test on main pins it, so it is documented
rather than softened).

CHANGELOG and BROWSER.md: single backslashes inside code spans, "a path
other than a bare /", the socks5 bridge log lines in the effective-mode
list, and the character list applies anywhere in the value.

Decision: GStack Minder (2026-10-26), option B.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): keep a basic-auth URL out of the scheme-guard comment so the pregate secret scan stays clean

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(browse): pin the mismatch remedy per platform so the Windows subset does not take process.platform

proxyMismatchLines defaulted to process.platform in the env-proxy-meets-direct-daemon case, so on the windows-free-tests lane the remedy read 'or unset HTTPS_PROXY' and the assertion failed. The case now passes 'linux' explicitly and pins the darwin/win32 remedy beside it. Test only; source unchanged since 1f6c85af.

Decision: GStack Minder (2026-10-26), option A.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): the one-slash http:/host:3128 spelling was never a diagnostic; say what the parser does with it

The CHANGELOG sentence and the SCHEMELESS_PROXY_RE docblock claimed a one-slash scheme 'keeps its diagnostic'; the URL parser has always read it as http://host:3128/ and browse/test/proxy-env.test.ts:83 pins that. Wording only, found by the Step 14.5 docs audit on 1f6c85af.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): percent-encoding advice per scheme, and state the credential-free-path narrowing and the new scheme-less cred-mix refusal

BROWSER.md said to percent-encode / \ ? # @ in http(s) credentials; ? and # are refused for every scheme, @ needs no encoding, and only the backslash is http(s)-specific. The CHANGELOG now states the narrowing that a credential-free standard-variable value with a path (http://proxy.corp:3128/pac) used to be read with the path dropped and now warns and goes direct, and that the cred-mix refusal is new for the scheme-less spelling. Wording only.

Decision: GStack Minder (2026-10-26), option A.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): only host:port was Chromium's own reading; user:pass@host:port is newly proxied

Chromium's proxy spec parser (net/base/proxy_string_util.cc, ProxySchemeHostAndPortToProxyServer) returns an invalid server for any userinfo, so a scheme-less user:pass@host:port in HTTPS_PROXY was never honoured by Chromium on Linux and went direct; reading it as http:// follows curl and is new behaviour. BROWSER.md, the CHANGELOG, the resolveProxy docblock and the Linux e2e test comment now say so, and the scheme-less spill test is named for what it proves (those values never reach the path refusal; the http:// cases in the next test do). Wording only.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 13:56:12 +00:00

392 lines
17 KiB
TypeScript

/**
* Parse + validate proxy config from CLI flags and environment.
*
* Used by:
* cli.ts — to resolve the proxy (resolveProxy), detect cred-mixing and
* daemon-mismatch, and forward to server
* server.ts — to spawn the bridge and pass proxy to chromium.launch
*
* Proxy precedence (#915): --proxy, then BROWSE_PROXY_URL, then HTTPS_PROXY,
* HTTP_PROXY and ALL_PROXY (upper case first, then lower case), then direct.
* BROWSE_PROXY_URL=direct opts out of the standard variables.
*
* Cred policy (D9): if BOTH the URL embeds creds AND the env vars
* BROWSE_PROXY_USER/PASS are set, refuse with a clear error. No silent
* override — debugging confusion is worse than a one-time setup error.
*/
import { createHash } from 'crypto';
import type { UpstreamConfig } from './socks-bridge';
export interface ParsedProxyConfig {
/** Original scheme: 'socks5' | 'http' | 'https' */
scheme: 'socks5' | 'http' | 'https';
host: string;
port: number;
userId?: string;
password?: string;
/** True if creds are present (from URL or env). */
hasAuth: boolean;
}
export class ProxyConfigError extends Error {
constructor(public readonly hint: string, message: string) {
super(message);
this.name = 'ProxyConfigError';
}
}
function percentDecodable(part: string): boolean {
try {
decodeURIComponent(part);
return true;
} catch {
return false;
}
}
/**
* Parse the BROWSE_PROXY_URL string and merge env-supplied creds.
*
* @throws ProxyConfigError on malformed URL, unsupported scheme, a path
* (other than a bare `/`), query or fragment, bad credential
* percent-encoding, or ambiguous credentials (set in both URL and env).
*/
export function parseProxyConfig(opts: {
proxyUrl: string;
envUser?: string;
envPass?: string;
/** Where proxyUrl came from, named in the cred-mixing hint. */
source?: string;
}): ParsedProxyConfig {
let url: URL;
try {
url = new URL(opts.proxyUrl);
} catch {
throw new ProxyConfigError(
'expected scheme://[user:pass@]host:port',
`invalid proxy URL — could not parse`,
);
}
// socks5h (remote DNS) is what Chromium's socks5 already does.
const scheme = url.protocol === 'socks5h:' ? 'socks5' : url.protocol.replace(':', '');
if (scheme !== 'socks5' && scheme !== 'http' && scheme !== 'https') {
throw new ProxyConfigError(
'use socks5://, http://, or https://',
`unsupported proxy scheme '${scheme}'`,
);
}
if (!url.hostname) {
throw new ProxyConfigError(
'expected scheme://[user:pass@]host:port',
`invalid proxy URL — missing host`,
);
}
// A proxy address has no path, query or fragment. Anything the URL parser
// put there came from a value that was not a proxy URL (a backslash in a
// DOMAIN\user login, a '/' in a password) and would otherwise be printed
// in the clear, because only the userinfo is redacted.
if ((url.pathname !== '' && url.pathname !== '/') || url.search || url.hash || /[?#]/.test(opts.proxyUrl)) {
throw new ProxyConfigError(
'expected scheme://[user:pass@]host:port with no path; percent-encode any / \\ ? # @ in the credentials',
`invalid proxy URL — unexpected path, query or fragment`,
);
}
const port = url.port
? parseInt(url.port, 10)
: (scheme === 'http' ? 80 : scheme === 'https' ? 443 : 1080);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new ProxyConfigError(
'expected scheme://[user:pass@]host:port',
`invalid proxy URL — bad port`,
);
}
const urlHasUser = !!url.username;
const urlHasPass = !!url.password;
const envHasUser = !!opts.envUser;
const envHasPass = !!opts.envPass;
const urlHasCreds = urlHasUser || urlHasPass;
const envHasCreds = envHasUser || envHasPass;
// D9 (codex correction): refuse on mixed sources. Silent override is a
// debugging trap — when a stale BROWSE_PROXY_USER from a prior session
// wins over a fresh --proxy URL, the user can't tell why.
if (urlHasCreds && envHasCreds) {
throw new ProxyConfigError(
`unset BROWSE_PROXY_USER/PASS or remove user:pass@ from ${opts.source ?? '--proxy'}`,
`proxy creds set in both env (BROWSE_PROXY_USER) and URL — pick one source`,
);
}
let userId: string | undefined;
let password: string | undefined;
if (urlHasCreds && ![url.username, url.password].every(percentDecodable)) {
throw new ProxyConfigError(
'percent-encode the credentials (a literal % is %25)',
`invalid proxy URL — credentials are not valid percent-encoding`,
);
}
if (urlHasCreds) {
userId = decodeURIComponent(url.username);
password = url.password ? decodeURIComponent(url.password) : undefined;
} else if (envHasCreds) {
userId = opts.envUser;
password = opts.envPass;
}
return {
scheme: scheme as 'socks5' | 'http' | 'https',
host: url.hostname,
port,
...(userId ? { userId } : {}),
...(password ? { password } : {}),
hasAuth: !!(userId || password),
};
}
/** Convert a ParsedProxyConfig to the UpstreamConfig shape socks-bridge wants. */
export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig {
return {
host: cfg.host,
port: cfg.port,
...(cfg.userId ? { userId: cfg.userId } : {}),
...(cfg.password ? { password: cfg.password } : {}),
};
}
/**
* Compute a stable hash of the daemon config (proxyUrl + headed flag, plus
* the NO_PROXY rules beside a proxy and the explicit direct opt-out where it
* bypasses the system proxy settings, #3440) for daemon-mismatch detection
* (D2). The hash is deterministic across CLI invocations on the same machine
* and survives daemon restarts via the state file.
*
* NEVER include resolved creds — the hash compares config intent, not
* specific credential values, and we don't want creds in any persisted form.
*/
export function computeConfigHash(opts: {
proxyUrl: string | null | undefined;
headed: boolean;
/** Bypass rules derived from NO_PROXY (resolveProxy's noProxyRules). */
noProxyRules?: string | null;
/** explicitDirect(): BROWSE_PROXY_URL=direct where it bypasses the system proxy settings. */
explicitDirect?: boolean;
}): string {
const proxyKey = canonicalizeProxyUrl(opts.proxyUrl);
const input = JSON.stringify({
proxy: proxyKey,
headed: opts.headed,
...(proxyKey && opts.noProxyRules ? { noProxy: opts.noProxyRules } : {}),
...(!proxyKey && opts.explicitDirect ? { direct: true } : {}),
});
return createHash('sha256').update(input).digest('hex').slice(0, 16);
}
/** Strip creds from a proxy URL for hashing. Returns null for empty input. */
function canonicalizeProxyUrl(input: string | null | undefined): string | null {
if (!input) return null;
try {
const u = new URL(input);
u.username = '';
u.password = '';
return `${u.protocol}//${u.host}`;
} catch {
return '<unparseable>';
}
}
export const STANDARD_PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'] as const;
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
export const PROXY_TROUBLESHOOTING = 'https://github.com/garrytan/gstack/blob/main/BROWSER.md#proxy-from-environment';
export interface ResolvedProxy {
/** Proxy URL with resolved creds embedded, or null for a direct connection. */
url: string | null;
/** '--proxy' or the env var the proxy came from; null when direct. */
source: string | null;
/** NO_PROXY translated to Chromium bypass rules (sorted, comma-separated), or null. */
noProxyRules: string | null;
/** Rules Chromium receives: noProxyRules plus loopback; null when direct. */
bypass: string | null;
/** Set when a standard proxy variable was ignored because it does not parse. */
warning: string | null;
}
/**
* Translate a curl-style NO_PROXY list to Chromium bypass rules: `example.com`
* and `.example.com` cover the domain and its subdomains, `*` bypasses all,
* CIDR blocks and `host:port` pass through, and bare IPv6 gets brackets.
* Loopback names are left out: withLoopbackBypass always adds them, and the
* CLI appends them to NO_PROXY for its own daemon calls (#494).
*/
export function noProxyToChromiumRules(noProxy: string | undefined): string[] {
const rules = new Set<string>();
for (const raw of (noProxy ?? '').toLowerCase().split(/[\s,]+/)) {
if (!raw) continue;
if (raw === '*' || raw.includes('/')) { rules.add(raw); continue; }
let host = raw;
let port = '';
const bracket = raw.match(/^(\[[0-9a-f:.]+\])(:\d+)?$/);
if (bracket) [host, port] = [bracket[1], bracket[2] ?? ''];
else if (raw.split(':').length > 2) {
if (!/^[0-9a-f:.]+$/.test(raw)) continue;
host = `[${raw}]`;
}
else if (raw.includes(':')) [host, port] = [raw.slice(0, raw.lastIndexOf(':')), raw.slice(raw.lastIndexOf(':'))];
if (port && !/^:\d+$/.test(port)) continue;
if (!port && LOOPBACK_HOSTS.includes(host)) continue;
if (host.startsWith('*.')) { rules.add(host + port); continue; }
host = host.replace(/^\./, '');
if (!host) continue;
rules.add(host + port);
if (!host.startsWith('[') && !/^\d+(\.\d+){3}$/.test(host)) rules.add(`*.${host}${port}`);
}
return [...rules].sort();
}
/**
* Chromium bypass rules for a proxied session: the NO_PROXY rules plus
* loopback. Naming loopback also stops Playwright from adding `<-loopback>`,
* which would send localhost through the proxy.
*/
export function withLoopbackBypass(rules: string[]): string {
return [...new Set([...rules, ...LOOPBACK_HOSTS])].join(',');
}
/**
* The daemon's view of the proxy the CLI resolved: BROWSE_PROXY_URL ('direct'
* means none), its source, the bypass rules (computed from NO_PROXY when a
* caller other than the CLI set only BROWSE_PROXY_URL), and whether an
* explicit direct bypasses the system proxy settings here (explicitDirect).
*/
export function serverProxyEnv(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): { url: string | undefined; source: string; bypass: string; direct: boolean } {
return {
url: env.BROWSE_PROXY_URL === 'direct' ? undefined : env.BROWSE_PROXY_URL || undefined,
source: env.BROWSE_PROXY_SOURCE || 'BROWSE_PROXY_URL',
bypass: env.BROWSE_PROXY_BYPASS || withLoopbackBypass(noProxyToChromiumRules(env.NO_PROXY || env.no_proxy)),
direct: explicitDirect(env, platform),
};
}
/**
* The Chromium switch that makes a direct connection explicit (#3440 item 8).
* Launched without --proxy-server, Chromium reads HTTPS_PROXY, HTTP_PROXY and
* ALL_PROXY (Linux) or the system proxy settings itself. Not --no-proxy-server:
* chrome-headless-shell, the default headless build, ignores that switch and
* still took HTTPS_PROXY (measured on Playwright 1.62.1); the full Chromium
* honors both, and direct:// works on both.
*/
export const DIRECT_PROXY_ARG = '--proxy-server=direct://';
/**
* Whether a direct connection is made explicit to Chromium with
* DIRECT_PROXY_ARG. Always on Linux, where the leak in #3440 item 8 lives:
* Chromium launched without --proxy-server reads the proxy variables itself.
* Chromium on macOS and Windows never reads them and follows the system proxy
* settings (PAC and WPAD included), which GStack Browser.app users behind a
* corporate proxy rely on, so there the switch is passed only when the user
* asked for a direct connection with BROWSE_PROXY_URL=direct.
*/
export function forcesDirect(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): boolean {
return platform === 'linux' || explicitDirect(env, platform);
}
/**
* Whether BROWSE_PROXY_URL=direct changes what Chromium does on this
* platform. On macOS and Windows it is the only thing that makes the daemon
* bypass the system proxy settings, so it is part of the daemon's config hash
* (a reused daemon started without it would keep the system proxy while the
* CLI reports direct), is recorded in the daemon state and is re-emitted on an
* auto-restart from the invocation's own flags, which the hash gate already
* matched against that daemon. On Linux every direct connection already carries
* DIRECT_PROXY_ARG, so the opt-out changes nothing and hashes like no proxy.
*/
export function explicitDirect(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): boolean {
return platform !== 'linux' && env.BROWSE_PROXY_URL === 'direct';
}
/**
* The daemon's launch log line when no proxy is configured: the switch
* Chromium was given and what it ignores for it (each standard variable set on
* Linux, the system proxy settings elsewhere), or that Chromium follows the
* system proxy settings.
*/
export function directProxyLogLine(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): string {
if (!forcesDirect(env, platform)) {
return `[browse] [proxy] none: system proxy settings (Chromium's default on ${platform}; BROWSE_PROXY_URL=direct connects directly)`;
}
const ignored = platform === 'linux' ? STANDARD_PROXY_VARS.filter(name => env[name]) : ['the system proxy settings'];
return `[browse] [proxy] none: direct connection (${DIRECT_PROXY_ARG}${ignored.length ? `; ignoring ${ignored.join(', ')}` : ''})`;
}
/**
* The scheme-less proxy shapes curl and Chromium's environment reader take as
* an HTTP proxy: `[user:pass@]host[:port]` with a name, dotted address or
* bracketed IPv6 host. Anything else (a scheme, a path, query or fragment
* character, a backslash, whitespace inside, a bare number) goes to the URL
* parser as written, so a bare number such as `1234` keeps the diagnostic
* it had and `http:/host:3128` reads as it always did; parseProxyConfig
* then rejects any path, query or fragment, so no part of a password can
* land outside the redacted userinfo.
* A userinfo that opens with a known scheme (`https:bob@host:3128`, the
* slash-less spelling the URL parser reads as an `https://` URL with user
* `bob`) is a scheme, not credentials, so it is excluded too.
*/
const SCHEMELESS_PROXY_RE = /^(?:[^/\\?#@\s]+@)?(?:\[[0-9a-f:.]+\]|(?=[^:]*[a-z.])[^/\\?#@\s:[\]]+)(?::\d+)?$/i;
const LEADING_SCHEME_RE = /^(?:https?|socks5h?):/i;
/**
* Resolve the proxy for this invocation (#915). Env URLs go through
* parseProxyConfig, so the cred policy applies. A standard variable in a
* scheme-less shape (curl-style `host:port` or `user:pass@host:port`,
* SCHEMELESS_PROXY_RE) is read as an `http://` proxy, the way curl takes it.
* For `host:port` that is also what Chromium's own environment reader did,
* so a session that worked through Chromium reading the variable keeps
* working now that the launch is explicit (#3440); `user:pass@host:port` is
* newly proxied, because Chromium rejects any proxy spec with a userinfo. One that still does not parse (an unsupported scheme, a bad port,
* a missing host) is ignored with a warning instead of failing every command;
* --proxy and BROWSE_PROXY_URL are explicit browse settings and still fail
* closed, scheme required.
*/
export function resolveProxy(flagUrl: string | null, env: NodeJS.ProcessEnv): ResolvedProxy {
const direct = (warning: string | null = null): ResolvedProxy => ({ url: null, source: null, noProxyRules: null, bypass: null, warning });
let source: string;
let raw: string;
if (flagUrl) {
[source, raw] = ['--proxy', flagUrl];
} else if (env.BROWSE_PROXY_URL) {
if (env.BROWSE_PROXY_URL === 'direct') return direct();
[source, raw] = ['BROWSE_PROXY_URL', env.BROWSE_PROXY_URL];
} else {
const name = STANDARD_PROXY_VARS.find(key => env[key]);
if (!name) return direct();
const value = env[name]!.trim();
[source, raw] = [name, SCHEMELESS_PROXY_RE.test(value) && !LEADING_SCHEME_RE.test(value) ? `http://${value}` : value];
try {
parseProxyConfig({ proxyUrl: raw });
} catch (err) {
if (!(err instanceof ProxyConfigError)) throw err;
return direct(`${source} is not a proxy URL browse can use (${err.message}; ${err.hint}), so browse connects directly. ` +
`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}`);
}
}
const parsed = parseProxyConfig({ proxyUrl: raw, envUser: env.BROWSE_PROXY_USER, envPass: env.BROWSE_PROXY_PASS, source });
const url = new URL(raw);
if (url.protocol === 'socks5h:') url.protocol = 'socks5:';
url.username = parsed.userId ? encodeURIComponent(parsed.userId) : '';
url.password = parsed.password ? encodeURIComponent(parsed.password) : '';
const rules = noProxyToChromiumRules(env.NO_PROXY || env.no_proxy);
return {
url: url.toString(),
source,
noProxyRules: rules.length ? rules.join(',') : null,
bypass: withLoopbackBypass(rules),
warning: null,
};
}