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:
1 parent
c44dcb7b85
commit
6ecdc93901
2 files changed
+91
-14
No files matched your search
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user