fix(http-proxy): give children the user's environment under a layered direct policy

A direct policy installed over a proxied one swapped the global
dispatcher but left `process.env` holding the outer install's published
normalization. `proxyEnvironmentForChild()` returns nothing under a
direct policy, and `scrubbedParentEnv()` copies `process.env` as it is,
so a child spawned in that window inherited values no active policy
stood behind: an `HTTPS_PROXY` derived from `HTTP_PROXY` the user never
set, or the loss of a SOCKS value they set for `curl` and this package
had refused.

The direct branch now writes the user's own values — the record the
outermost install keeps — back into `process.env` for the window, and
re-applies the outer install's published values when it ends. An
install underneath that proxied nothing published nothing, so there is
nothing to put back. Publishing and restoring share one `writeProxyEnv`;
the empty-string special case it replaced was unreachable, since a
resolved bypass list always carries the loopback entries and an
accepted proxy URL is never empty.

The nesting is reachable only from tests since the plugin was removed;
the fix keeps the disposer symmetric for whoever layers installs next.
This commit is contained in:
Yichen Jiang committed 2026-09-02 10:19:23 +08:00
1 parent c44dcb7b85
commit 6ecdc93901
2 files changed
+91 -14

No files matched your search

+46 -14
View File
@@ -76,28 +76,54 @@ export function proxyRouteFor(url: URL): ProxyRoute {
* @returns a function restoring every name this call changed.
*/
function applyPolicyEnv(policy: ProxyPolicy): () => void {
const previousInherited = inheritedProxyEnv
inheritedProxyEnv = previousInherited ?? snapshotProxyEnv()
const published: Record<string, string | undefined> = {}
for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
const value = policy[field as keyof typeof POLICY_ENV_NAMES]
for (const name of names) published[name] = value
}
const restore = writeProxyEnv(published)
return () => {
restore()
inheritedProxyEnv = previousInherited
}
}
/**
* Read every proxy name this package publishes, as `process.env` holds it now.
*
* @returns one entry per name in {@link POLICY_ENV_NAMES}; `undefined` marks an absent name.
*/
function snapshotProxyEnv(): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {}
for (const names of Object.values(POLICY_ENV_NAMES)) {
for (const name of names) snapshot[name] = process.env[name]
}
return snapshot
}
/**
* Set every proxy name to the value `values` holds for it, removing a name whose value is `undefined`.
*
* @param values - the value each name in {@link POLICY_ENV_NAMES} should hold.
* @returns a function restoring every name to what it held before this call.
*/
function writeProxyEnv(values: Readonly<Record<string, string | undefined>>): () => void {
// Snapshot EVERY name before writing any of them. Windows folds environment names case-insensitively,
// so reading the uppercase spelling after writing the lowercase one would read back the value just
// written and restore the policy instead of the user's environment.
const previous = new Map<string, string | undefined>()
for (const names of Object.values(POLICY_ENV_NAMES)) {
for (const name of names) previous.set(name, process.env[name])
}
const previousInherited = inheritedProxyEnv
inheritedProxyEnv = previousInherited ?? Object.fromEntries(previous)
for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
const value = policy[field as keyof typeof POLICY_ENV_NAMES]
for (const name of names) {
if (value === undefined || value === '') Reflect.deleteProperty(process.env, name)
else process.env[name] = value
}
const previous = snapshotProxyEnv()
for (const name of Object.keys(previous)) {
const value = values[name]
if (value === undefined) Reflect.deleteProperty(process.env, name)
else process.env[name] = value
}
return () => {
for (const [name, value] of previous) {
for (const [name, value] of Object.entries(previous)) {
if (value === undefined) Reflect.deleteProperty(process.env, name)
else process.env[name] = value
}
inheritedProxyEnv = previousInherited
}
}
@@ -159,6 +185,11 @@ async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise<vo
}
}
const previousInstalled = installed
// The install underneath published its normalized policy into `process.env`, which is what a
// spawned child copies. With no policy active there is no normalization to stand behind, so the
// user's own values return for the window and the outer install's come back when it ends. An
// install underneath that proxied nothing published nothing, and there is nothing to put back.
const restoreEnv = inheritedProxyEnv === undefined ? undefined : writeProxyEnv(inheritedProxyEnv)
const undici = await import('undici')
const previous = undici.getGlobalDispatcher()
const direct = new undici.Agent()
@@ -169,6 +200,7 @@ async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise<vo
undici.setGlobalDispatcher(previous)
active = previousPolicy
installed = previousInstalled
restoreEnv?.()
await direct.close()
}
}
@@ -357,6 +357,51 @@ describe('installing over an existing installation', () => {
})
})
describe('the environment while a direct policy is layered over a proxied one', () => {
it('hands a child the user\'s own values, and the outer normalization again afterwards', async () => {
await withCleanProxyEnv(async () => {
// The user exported one usable proxy and one this package refuses.
process.env.HTTP_PROXY = proxyUrl
process.env.https_proxy = 'socks5://127.0.0.1:1080'
const outer = await install(env({ HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080' }))
try {
// The outer install published its policy: the refused scheme is removed in both casings.
expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toEqual([undefined, undefined])
const off = await install(env({}))
try {
// A spawned child copies `process.env`, and `proxyEnvironmentForChild()` adds nothing under a
// direct policy — so what it copies has to be the user's own environment, not a normalization
// no active policy stands behind: the SOCKS value they set for `curl` is theirs again.
expect(process.env.HTTP_PROXY).toBe(proxyUrl)
expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toContain('socks5://127.0.0.1:1080')
expect(proxyEnvironmentForChild()).toEqual({})
} finally {
await off.dispose()
}
// Ending the window re-applies what the outer install published.
expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toEqual([undefined, undefined])
expect(process.env.HTTP_PROXY).toBe(proxyUrl)
} finally {
await outer.dispose()
}
})
})
it('touches no environment when the install underneath proxied nothing', async () => {
process.env.HTTP_PROXY = 'http://untouched.example'
const outer = await install(env({}))
const inner = await install(env({}))
try {
expect(process.env.HTTP_PROXY).toBe('http://untouched.example')
} finally {
await inner.dispose()
await outer.dispose()
expect(process.env.HTTP_PROXY).toBe('http://untouched.example')
delete process.env.HTTP_PROXY
}
})
})
describe('the published environment', () => {
it('restores every name from one snapshot taken before any write', async () => {
process.env.http_proxy = 'http://before.example'