cargo / reqwest / audit
cargo : reqwest @ 0.13.4
PE Patrick Elsen signed 2026-05-28 published 2026-05-28

Claims

concurrency-documentedconcurrency-safecrypto-safeenvironment-safehas-binarieshas-build-exechas-fuzz-testshas-install-exechas-integration-testshas-property-testshas-unit-testsimpl-algorithmimpl-concurrencyimpl-cryptoimpl-datastructureimpl-interpreterimpl-jitimpl-parserimpl-protocolis-benignnetwork-safenetwork-secureunsafe-documentedunsafe-minimalunsafe-safeuses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

reqwest 0.13.4 is a high-level Rust HTTP client built on hyper, tower, and rustls. Five unsafe blocks were reviewed and found sound. Redirect handling correctly strips Authorization and Cookie headers on cross-origin redirects; TLS defaults to rustls with platform certificate verification enabled. One medium-severity finding: no default request, read, or connect timeout is set, which callers must configure explicitly to avoid indefinitely stalled connections.

Report

Subject

reqwest 0.13.4 is a high-level async HTTP client for Rust built on top of hyper, hyper-util, and tower. It supports HTTP/1.1, HTTP/2 (feature-gated), and experimental HTTP/3 (QUIC), with rustls as the default TLS backend and native-tls as an alternative. The public API exposes a Client/ClientBuilder pair for the async interface and a blocking::Client/blocking::ClientBuilder pair that wraps the async implementation with a thread-park bridge. The crate also has a wasm32 target that delegates to the browser Fetch API.

Methodology

The published crate contents were compared against the upstream Git repository at the commit recorded in .cargo_vcs_info.json using diff -rq. All source files under src/ (~40 files, ~10,000 LOC total) were read; the security-sensitive files (src/redirect.rs, src/proxy.rs, src/connect.rs, src/tls.rs, src/cookie.rs, src/async_impl/client.rs, src/blocking/body.rs, src/retry.rs, src/util.rs) were read in full. The grep-based source survey from the runbook was applied. Integration tests exist in the VCS at tests/ (17 test files); unit tests were counted from the source. Dependency descriptions were written from source analysis.

Results

The diff against VCS shows no source-file differences. Only the auto-generated Cargo.toml normalization and files excluded from the published crate (tests, examples, CHANGELOG, .github) differ. No binary artifacts are present, justifying has-binaries. There is no build.rs, justifying has-build-exec and has-install-exec.

The crate is not a proc-macro and performs no I/O at compile time. It opens TCP connections and performs TLS at runtime, justifying uses-network. No filesystem operations occur in the crate's own code; File appears only in documentation examples, justifying uses-filesystem.

Three unsafe blocks exist across two files. The two blocks in src/connect.rs (verbose tracing wrapper, lines 1990-2008) carry inline SAFETY comments and correctly advance a hyper ReadBufCursor only by the number of bytes confirmed filled. The three blocks in src/blocking/body.rs (lines 307-339) zero-initialize newly reserved BytesMut capacity before transmuting MaybeUninit<u8> to u8, and advance the buffer only by the number of bytes the Read impl returned. The blocking body blocks lack SAFETY comments but their invariants are upheld by the immediately preceding logic. All five blocks justify uses-unsafe; the transmute pattern is minimal and necessary for bridging BytesMut's uninitialized-capacity API with a synchronous Read trait, justifying unsafe-minimal and unsafe-safe. SAFETY comments in connect.rs justify unsafe-documented; their absence in blocking/body.rs is a quality note but does not affect soundness.

The redirect policy (src/redirect.rs, lines 239-252) strips Authorization, Cookie, cookie2, Proxy-Authorization, and WWW-Authenticate on any cross-origin redirect (different host, port, or scheme). HTTPS-to-HTTP downgrades on the same host also trigger stripping. The logic is tested with four unit tests including a scheme-downgrade case. This justifies network-safe and network-secure.

Proxy configuration supports explicit Proxy::http/https/all/custom constructors and system proxy discovery (enabled by default via the system-proxy feature, delegated to hyper-util). The NO_PROXY/no_proxy environment variables are read via NoProxy::from_env() in src/proxy.rs. Only documented proxy-configuration variables are consumed; the environment is not enumerated or forwarded, justifying uses-environment and environment-safe.

TLS defaults to rustls backed by rustls-platform-verifier for certificate validation against the platform's native trust store. Both hostname verification and certificate verification are on by default. NoVerifier (which skips all certificate checks) is only reachable via an explicit danger_accept_invalid_certs(true) call. TLS 1.0 and 1.1 are not in the supported-versions list. These behaviors justify uses-crypto and crypto-safe.

The cookie jar (src/cookie.rs) uses cookie_store::CookieStore, which handles origin-scoped cookie storage. The Jar implementation wraps it in a std::sync::RwLock, and the CookieStore trait requires Send + Sync. Concurrent access is correctly synchronized, justifying uses-concurrency, concurrency-safe, and concurrency-documented.

No obfuscated code, base64-encoded payloads, suspicious network endpoints, or telemetry was found, justifying is-benign. The codebase was reviewed for cryptographic implementations, parsers, interpreters, JIT compilers, protocols, data structures, concurrency primitives, and algorithms — none are implemented here; all are delegated to dependencies. This justifies impl-crypto, impl-parser, impl-interpreter, impl-jit, impl-protocol, impl-datastructure, impl-concurrency, and impl-algorithm. No child process execution was found (uses-exec). No JIT compiler is used (uses-jit). No embedded interpreter is used (uses-interpreter). unsafe-tested was not evaluated; reqwest does not run miri or sanitizers in its CI configuration.

One medium-severity finding (FINDING-1) was identified: the default Client has no request timeout, read timeout, or connect timeout. This is explicitly documented as a deliberate design decision, but callers who do not configure a timeout are exposed to indefinitely stalled connections, which can cause resource exhaustion in server or proxy contexts.

Unit tests total 100 inline #[test] items across the source; integration tests cover redirect, proxy, cookie, retry, timeout, decompression, multipart, and blocking behaviors in the VCS tests directory, justifying has-unit-tests and has-integration-tests. No fuzz or property tests exist, justifying has-fuzz-tests and has-property-tests.

Conclusion

reqwest 0.13.4 is a large, well-structured crate with a clear tower-based middleware architecture. The five unsafe blocks were reviewed and found sound; the redirect sensitive-header stripping is correct and tested; TLS defaults are conservative. One medium-severity finding concerns the absence of a default request timeout, which is documented but operationally significant for server-side users who do not explicitly configure one.

Findings(1)

FINDING-1 security medium

No default request, read, or connect timeout

The ClientBuilder sets timeout: None and read_timeout: None as defaults (src/async_impl/client.rs lines 313-314). There is also no default connect_timeout (line 299). Without an explicit timeout configured by the caller, connections and responses can stall indefinitely, which may cause resource exhaustion in server-side or proxy applications.

The documented behavior explicitly states "Default is no timeout" for all three settings. This is a deliberate design decision by the library author, not an oversight, but callers who do not configure a timeout may unknowingly expose themselves to hung connections.

Annotations(6)

src/blocking/body.rs

src/blocking/body.rs, line 307-339

        if buf.is_empty() {
            if buf.capacity() == buf.len() {
                buf.reserve(8192);
                // zero out the reserved memory
                let uninit = buf.spare_capacity_mut();
                let uninit_len = uninit.len();
                unsafe {
                    ptr::write_bytes(uninit.as_mut_ptr().cast::<u8>(), 0, uninit_len);
                }
            }

            let bytes = unsafe {
                mem::transmute::<&mut [MaybeUninit<u8>], &mut [u8]>(buf.spare_capacity_mut())
            };
            match body.read(bytes) {
                Ok(0) => {
                    // The buffer was empty and nothing's left to
                    // read. Return.
                    return Ok(());
                }
                Ok(n) => unsafe {
                    buf.advance_mut(n);
                },
                Err(e) => {
                    let _ = tx
                        .take()
                        .expect("tx only taken on error")
                        .clone()
                        .try_send(Err(Abort));
                    return Err(crate::error::body(e));
                }
            }
        }

Three unsafe blocks in send_future in the blocking body bridge. The first (lines 313-315) calls ptr::write_bytes to zero-initialize newly reserved BytesMut capacity before treating it as initialized; this ensures bytes written by the Read impl are not uninitialized. The second (lines 318-319) uses mem::transmute to convert &mut [MaybeUninit<u8>] to &mut [u8] after the zeroing step; the zeroing guarantees the invariant. The third (line 327) calls buf.advance_mut(n) where n is the return value of body.read(), so n bytes have been written by the OS. No SAFETY comments accompany these blocks.

Justifies uses-unsafe. The absence of SAFETY comments is noted but the invariants are upheld by the logic immediately preceding each block.

src/connect.rs

src/connect.rs, line 1990-2008

            // TODO: This _does_ forget the `init` len, so it could result in
            // re-initializing twice. Needs upstream support, perhaps.
            // SAFETY: Passing to a ReadBuf will never de-initialize any bytes.
            let mut vbuf = hyper::rt::ReadBuf::uninit(unsafe { buf.as_mut() });
            match Pin::new(&mut self.inner).poll_read(cx, vbuf.unfilled()) {
                Poll::Ready(Ok(())) => {
                    log::trace!("{:08x} read: {:?}", self.id, Escape::new(vbuf.filled()));
                    let len = vbuf.filled().len();
                    // SAFETY: The two cursors were for the same buffer. What was
                    // filled in one is safe in the other.
                    unsafe {
                        buf.advance(len);
                    }
                    Poll::Ready(Ok(()))
                }
                Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
                Poll::Pending => Poll::Pending,
            }
        }

Two unsafe blocks in the verbose tracing wrapper for connections. The first (line 1993) calls buf.as_mut() to obtain a MaybeUninit slice for ReadBuf::uninit; this is safe because hyper's ReadBuf::uninit accepts uninitialized memory by contract. The second (lines 1999-2001) calls buf.advance(len) after filling len bytes; this is safe because len is taken from filled() on the same buffer, so it cannot exceed the initialized region. Safety comments are present inline.

Justifies unsafe-safe and unsafe-documented for this file.

src/cookie.rs

src/cookie.rs, line 166-190

impl CookieStore for Jar {
    fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, url: &url::Url) {
        let iter =
            cookie_headers.filter_map(|val| Cookie::parse(val).map(|c| c.0.into_owned()).ok());

        self.0.write().unwrap().store_response_cookies(iter, url);
    }

    fn cookies(&self, url: &url::Url) -> Option<HeaderValue> {
        let s = self
            .0
            .read()
            .unwrap()
            .get_request_values(url)
            .map(|(name, value)| format!("{name}={value}"))
            .collect::<Vec<_>>()
            .join("; ");

        if s.is_empty() {
            return None;
        }

        HeaderValue::from_maybe_shared(Bytes::from(s)).ok()
    }
}

The client uses tokio as its async runtime, sharing state behind Arc<ClientRef>. The cookie jar uses std::sync::RwLock<cookie_store::CookieStore> for synchronized concurrent access. The connection pool is managed by hyper-util. Thread-safety contracts are expressed through Send + Sync bounds on CookieStore, Policy, and related types. Justifies uses-concurrency and concurrency-safe and concurrency-documented.

src/proxy.rs

src/proxy.rs, line 477-484

        let raw = std::env::var("NO_PROXY")
            .or_else(|_| std::env::var("no_proxy"))
            .ok()?;

        // Per the docs, this returns `None` if no environment variable is set. We can only reach
        // here if an env var is set, so we return `Some(NoProxy::default)` if `from_string`
        // returns None, which occurs with an empty string.
        Some(Self::from_string(&raw).unwrap_or_default())

NoProxy::from_env() reads NO_PROXY then no_proxy environment variables to build a no-proxy exclusion list. This is the only direct env-var access in reqwest's own source. The system proxy feature (system-proxy, on by default) delegates to hyper-util::client::proxy::matcher::Matcher::from_system(), which reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY from the environment. Only documented, expected proxy-configuration variables are read; the environment is not enumerated. Justifies uses-environment and environment-safe.

src/redirect.rs

src/redirect.rs, line 239-252

pub(crate) fn remove_sensitive_headers(headers: &mut HeaderMap, next: &Url, previous: &[Url]) {
    if let Some(previous) = previous.last() {
        let cross_host = next.host_str() != previous.host_str()
            || next.port_or_known_default() != previous.port_or_known_default()
            || next.scheme() != previous.scheme();
        if cross_host {
            headers.remove(AUTHORIZATION);
            headers.remove(COOKIE);
            headers.remove("cookie2");
            headers.remove(PROXY_AUTHORIZATION);
            headers.remove(WWW_AUTHENTICATE);
        }
    }
}

remove_sensitive_headers() strips Authorization, Cookie, cookie2, Proxy-Authorization, and WWW-Authenticate when redirecting cross-origin (different host, port, or scheme). Cross-origin detection compares host, port, and scheme between the previous and next URL. Scheme downgrades (https to http, same host/port) also trigger stripping. The function is called in TowerRedirectPolicy::on_request() for every redirect hop. Unit tests cover same-origin (no strip) and cross-origin (strip) cases, including scheme downgrade.

Justifies network-safe and network-secure (redirect stripping), and uses-network.

src/tls.rs

The default TLS backend is rustls with rustls-platform-verifier for certificate verification against the platform trust store. Certificate verification (certs_verification) and hostname verification (hostname_verification) are both enabled by default. The NoVerifier custom verifier (which skips all certificate checks) is only reachable if the caller explicitly calls danger_accept_invalid_certs(true). TLS 1.2 and 1.3 are supported; TLS 1.0 and 1.1 are not included in the supported versions list. Justifies uses-crypto and crypto-safe.