Skip to content

bug: explicit proxy_mounts is silently merged with environment proxies #1292

Description

@BeArchiTek

Component

Python SDK

Infrahub SDK version

1.23.0

Current Behavior

When Config.proxy_mounts is set, the SDK's explicit proxy configuration is merged with the ambient environment/OS proxy settings rather than replacing them — and for some patterns the environment wins.

_build_proxy_config() returns proxy=None on the proxy_mounts branch and never passes trust_env=False:

# infrahub_sdk/client.py:1468-1478 (async), :2454-2464 (sync)
proxy_config: ProxyConfig = {"proxy": None, "mounts": None}
if self.config.proxy:
    proxy_config["proxy"] = self.config.proxy
elif self.config.proxy_mounts.is_set:
    proxy_config["mounts"] = {
        key: httpx.AsyncHTTPTransport(proxy=value)
        for key, value in self.config.proxy_mounts.model_dump(by_alias=True).items()
    }

httpx decides whether to read the environment like this:

# httpx/_client.py:685 (sync), :1399 (async)
allow_env_proxies = trust_env and transport is None
proxy_map = self._get_proxy_map(proxy, allow_env_proxies)

_get_proxy_map (httpx/_client.py:239-251) only short-circuits when proxy is not None. Since the proxy_mounts branch leaves proxy=None and no transport is passed, httpx calls get_environment_proxies() and seeds the client's mount table from urllib.request.getproxies() — environment variables plus, on macOS/Windows, OS system proxy settings. The SDK's mounts are then applied on top, updating only matching keys.

The SDK's keys (http://, https://) do override correctly. Anything else the environment contributes does not — and NO_PROXY contributes host-specific patterns (all://*<host>) that sort ahead of bare scheme patterns, because URLPattern.priority orders longer hostnames first.

Result: an unrelated NO_PROXY entry in the user's shell silently disables an explicitly configured proxy, for exactly the host they are most likely to care about.

There is a second, opposite-direction inconsistency from the same code. ProxyMountsConfig.model_dump(by_alias=True) emits both keys even when only one is configured, so a partial config builds a real httpx.HTTPTransport(proxy=None) for the unset scheme:

INFRAHUB_PROXY_MOUNTS_HTTP=http://only-http:8080
built mounts: {'http://': only-http:8080, 'https://': DIRECT}

That phantom direct transport shadows an ambient HTTPS_PROXY. So a partial proxy_mounts suppresses environment proxying for the scheme the user never configured, while still honouring environment NO_PROXY host rules for the one they did. Incoherent in both directions.

Config.validate_proxy_config (infrahub_sdk/config.py:219-222) already treats proxy and proxy_mounts as mutually exclusive, which shows the intent that explicit proxy configuration is authoritative. The proxy branch only behaves correctly by accident, as a side effect of httpx's proxy is not None short-circuit — not by design in this SDK.

Expected Behavior

Explicitly configured proxy settings (INFRAHUB_PROXY / INFRAHUB_PROXY_MOUNTS_*) should be authoritative. When the SDK supplies its own proxy configuration, ambient HTTP_PROXY / HTTPS_PROXY / NO_PROXY / OS system proxy settings should not be consulted or blended in.

When no SDK proxy configuration is set, the current behaviour should be preserved: environment proxies continue to be honoured.

Steps to Reproduce

Reproduced against httpx 0.28.1 using the exact mounts dict the SDK builds — no network or Infrahub server required:

import os, httpx

os.environ["HTTPS_PROXY"] = "http://env-proxy:3128"
os.environ["NO_PROXY"] = "infrahub.internal"

# what the SDK builds for INFRAHUB_PROXY_MOUNTS_HTTPS=http://corp-proxy:8080
mounts = {
    "http://": httpx.HTTPTransport(proxy=None),
    "https://": httpx.HTTPTransport(proxy="http://corp-proxy:8080"),
}
c = httpx.Client(proxy=None, mounts=mounts, verify=False)

print("mount patterns:", [p.pattern for p in c._mounts])
for url in ("https://infrahub.internal/graphql", "https://elsewhere.com/graphql"):
    t = c._transport_for_url(httpx.URL(url))
    print(url, "->", getattr(getattr(t, "_pool", None), "_proxy_url", "DIRECT"))

Output:

mount patterns: ['all://*infrahub.internal', 'https://', 'http://']
https://infrahub.internal/graphql -> DIRECT                      # ambient NO_PROXY won
https://elsewhere.com/graphql     -> http://corp-proxy:8080/

The user configured a proxy for HTTPS; the ambient NO_PROXY overrode it for the internal host.

Additional Information

Affected code

  • infrahub_sdk/client.py:1468-1478 and :2454-2464 — both _build_proxy_config() implementations
  • infrahub_sdk/client.py:1493, 1574, 1634 (async) and :2479, 3716, 3803 (sync) — every httpx client construction spreads that config, passing neither trust_env nor transport
  • infrahub_sdk/ctl/marketplace.py:300-311_make_http_client independently reimplements the same pattern
  • infrahub_sdk/config.py:18-35ProxyMountsConfig.model_dump(by_alias=True) always emits both keys

Suggested fix

Add a trust_env: bool key to the ProxyConfig / ProxyConfigSync TypedDicts and set it to False in both _build_proxy_config() implementations whenever config.proxy or config.proxy_mounts is set, leaving it True in the default branch. The six call sites already spread **self._build_proxy_config(), so they need no change. While there, use exclude_none=True on the model_dump so a partial proxy_mounts stops fabricating a direct transport for the unset scheme. Mirror the change in ctl/marketplace.py.

trust_env=False is safe here. Its only other effect in httpx 0.28 is reading SSL_CERT_FILE / SSL_CERT_DIR inside create_ssl_context, and the SDK always passes an already-built ssl.SSLContext via verify=self.config.tls_context, which httpx/_config.py:57 returns unchanged. No TLS behaviour changes.

This should not flip the default to trust_env=False for users with no SDK proxy configuration — that would drop HTTPS_PROXY support and is a separate, breaking change.

Testing notes

There is currently no test coverage for proxies anywhere under tests/. A regression test can be pure unit, no network. Two gotchas:

  • Setting only HTTPS_PROXY will not fail on current code, because the SDK's https:// mount already overrides that same key. The failing case needs a host-specific NO_PROXY entry.
  • ProxyMountsConfig(**{"https://": ...}) raises extra_forbiddenvalidation_alias="INFRAHUB_PROXY_MOUNTS_HTTPS" takes precedence over alias="https://" (config.py:24-31), so construct with the field names or the environment variables.

Related

  • feature: support local DNS resolution (rdns) for SOCKS5 proxies #1265 (SOCKS5 rdns) touches the same _build_proxy_config() code path and independently documents that _request_multipart bypasses the requester hook.
  • Separately, the same allow_env_proxies condition makes the SDK crash under fork() on macOS (Ansible forked workers): with no proxy configured, getproxies() reaches _scproxy → SystemConfiguration → ObjC in a forked child → SIGABRT. no_proxy='*' works around it only because CPython's getproxies_environment() matches any *_proxy variable name, so no_proxy short-circuits the macOS system lookup. That is a distinct issue and needs a separate, user-facing trust_env escape hatch — filing it separately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    type/bugSomething isn't working as expected

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions