Skip to content

ca_cert_file mutates the process-wide environment and contaminates later clients #86

Description

@thomasht86

Summary

The ca_cert_file constructor argument is implemented by writing to the process-wide environment. The value outlives the client that set it, leaks into child processes, and silently changes which CA roots every subsequently-constructed client trusts.

Cause

src/lib.rs:194-197:

// Ca_cert_file. BEFORE!!! verify (fn load_ca_certs() reads env var HTTPR_CA_BUNDLE)
if let Some(ca_bundle_path) = &ca_cert_file {
    std::env::set_var("HTTPR_CA_BUNDLE", ca_bundle_path);
}

load_ca_certs() (src/utils.rs:9) then reads that env var. The per-client argument is routed through global mutable state, and it is never cleared. Results are not cached, so every later client re-reads whatever path was left behind.

Reproduction

import os, subprocess, sys, httpr

try:
    httpr.Client(ca_cert_file="/nonexistent/a.pem")
except Exception as e:
    print("ctor raised:", type(e).__name__)

subprocess.run([sys.executable, "-c",
                "import os; print('child sees:', os.environ.get('HTTPR_CA_BUNDLE'))"])

httpr.Client()          # no ca_cert_file at all

Observed:

ctor raised: ConnectError
child sees: /nonexistent/a.pem                                    <- leaked to the child process
ConnectError: Failed to read CA certificates from /nonexistent/a.pem   <- from the *second*, unconfigured client

A plain httpr.Client() — which asked for nothing — now fails because of a path a different client set earlier.

Note os.environ in the parent still reads None: CPython caches os.environ at import, so Rust's setenv isn't visible there. The child process proves the real process environment was mutated.

With a valid CA path the failure is worse because it's silent: later clients quietly trust a different root set than the built-in one they'd expect.

Additional concern

std::env::set_var is not thread-safe. Concurrently reading the environment from another thread (any getenv, including from C libraries) while it runs is undefined behaviour — this is why it became unsafe in Rust edition 2024. The crate is on edition 2021, so it compiles today, but this is a latent hazard and a blocker for an edition bump.

Expected

ca_cert_file scopes to the client that specified it, like verify=/REQUESTS_CA_BUNDLE handling in httpx and requests. Reading HTTPR_CA_BUNDLE from the environment as a default is fine; writing to it is not.

Proposed fix

Change load_ca_certs() to take the path as a parameter rather than reading a global:

pub fn load_ca_certs(path: Option<&str>) -> Result<Vec<Certificate>>

In new(), resolve the effective path once as ca_cert_file.or_else(|| std::env::var("HTTPR_CA_BUNDLE").ok()) and pass it down — matching how proxy already falls back to HTTPR_PROXY at src/lib.rs:176 without writing back to the environment. Delete the set_var call.

The #[cfg(test)] tests in src/utils.rs that set the env var will need updating to pass the path directly.

Suggested tests

  • Constructing a client with ca_cert_file does not change the environment seen by a child process.
  • Client A with ca_cert_file=<path> followed by client B with none: B is unaffected.

Size

~1 hour.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingrustPull requests that update rust code

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions