Summary
A high-severity finding (tracked internally as V-001) landed on the outbound HTTP path used by external JSON data sources. The ExternalHttpClient::request() method accepted a $basicAuth credential string and handed it to the underlying HTTP client without ever checking whether the destination URL used TLS. Configure a data source with an http:// endpoint and a username/password, and every scheduled load broadcast a base64-encoded Authorization: Basic header across the network.
The fix is four lines: refuse to build the request at all unless the URL scheme is https.
Introduction
ExternalHttpClient::request() is the single chokepoint through which the application talks to third-party JSON endpoints. Its signature already showed a healthy security posture — it takes a URL, runs it through an ExternalUrlValidator to block internal/SSRF-style targets, and sets 'verify' => true so that certificate validation is never silently disabled.
That last detail is what made the bug easy to miss during review. 'verify' => true looks like the transport-security decision has been made. It has not. verify governs certificate validation on connections that negotiate TLS. It says nothing about whether TLS is negotiated in the first place. If the caller supplies http://example.test/data, the verify flag is inert and the request goes out in plaintext — including the credentials.
The credential handling block was where the two concerns collided:
if ($basicAuth !== null && $basicAuth !== '') {
[$username, $password] = array_pad(explode(':', $basicAuth, 2), 2, '');
$options['auth'] = [$username, $password];
}
The $basicAuth parameter is a user:pass string, split on the first colon and pushed into the client's auth option. The client then encodes it as Authorization: Basic base64(user:pass). Base64 is an encoding, not encryption — YWRtaW46c2VjcmV0cGFzcw== is admin:secretpass to anyone with thirty seconds and a terminal. There was no reference to the URL scheme anywhere in this branch.
If you maintain a similar wrapper around Guzzle, HttpClient, requests, or axios, this is the pattern to go look for: a credential parameter and a URL parameter that are validated independently, where nothing ties the sensitivity of the former to the safety of the latter.
Affected Versions
| Affected | not applicable (first-party code) — all revisions of ExternalHttpClient::request() prior to the fix commit |
| Fixed in | not applicable (first-party code) — fixed by the scheme-enforcement commit described below |
| Ecosystem | composer (PHP application code, not a published package) |
| CVE / GHSA | not assigned |
| CWE | unknown (the scanner did not attach a CWE identifier; the weakness class is cleartext transmission of sensitive information) |
Because this is application code rather than a distributed dependency, there is no version to upgrade to. Exposure is determined by whether your deployment contains the credential branch of ExternalHttpClient::request() without the scheme guard, and whether any configured external data source uses Basic Auth.
The Vulnerability Explained
The vulnerable path
Three inputs converge in request(): the HTTP method, the $url, and the optional $basicAuth string. Before the fix, the relevant sequence was:
ExternalUrlValidator::validate()rejects internal/loopback/metadata-style targets. It does not constrain the scheme —http://to a legitimate external host passes cleanly.$options['verify'] = trueis set. Inert on non-TLS connections.- If
$basicAuthis non-empty, it is split and written into$options['auth']. - A client is created and the request is dispatched.
Step 3 is the flaw. There is no relationship in that code between "I am about to transmit a password" and "this connection may not be encrypted."
Attack scenario against this specific code path
The credentials here are not user-submitted per request; they are configuration. An administrator registers an external JSON data source — say a reporting API — and enters a URL plus Basic Auth credentials. If they type http://reports.partner.example/v1/rows (copied from vendor docs, an internal legacy integration, or a staging host that never got a certificate), the following happens on every scheduled data load:
- The application opens a plain TCP connection.
- Guzzle serializes
Authorization: Basic YWRtaW46c2VjcmV0cGFzcw==into the request head. - Every hop between the app server and the partner — the local switch, the datacenter egress, an ISP, a transparent proxy, a rogue Wi-Fi access point if the app runs on a workstation — can read that header verbatim.
An attacker with passive network visibility does not need to break anything. tcpdump plus base64 -d yields the plaintext username and password. Because it is a scheduled load, the credential is re-broadcast on a predictable cadence: the attacker does not need to be present at the moment of configuration, only at any point afterwards.
The blast radius is the upstream account, not this application. Depending on what the partner API exposes, that means data exfiltration, data modification, or lateral movement into a third-party system that this application's own authorization model cannot restrain. And because credential rotation requires cooperation from the external provider, remediation after a leak is slow.
A secondary detail worth calling out: ExternalUrlValidator blocking internal URLs was doing real work, and it is exactly the kind of control that creates false confidence. "The URL is validated" was true. It was validated for destination, not for confidentiality.
The Fix
The change adds a scheme check as the first statement inside the credential branch:
if ($basicAuth !== null && $basicAuth !== '') {
if (strtolower((string)parse_url($url, PHP_URL_SCHEME)) !== 'https') {
return ['status' => 0, 'body' => '', 'error' => 'Basic Authentication requires an HTTPS URL'];
}
[$username, $password] = array_pad(explode(':', $basicAuth, 2), 2, '');
$options['auth'] = [$username, $password];
}
Several deliberate choices are packed into those three lines.
The guard is positioned before the credential is parsed. explode(':', $basicAuth, 2) never runs on a rejected request, so the username and password are never written into $options. There is no window in which a populated auth option exists on an object that might later be logged, serialized, or dumped in a stack trace.
It returns before a client is created. The early return means the request is abandoned entirely — no DNS lookup, no TCP connection, no partial handshake. The regression test enforces this explicitly rather than trusting the reading of the code:
$service->expects($this->never())->method('newClient');
If a future refactor moves client construction above the guard, that assertion fails.
(string) cast plus strtolower() makes the check fail closed. parse_url() returns null for a scheme-less input like example.test/data. Casting null to '' and comparing against 'https' yields a rejection, which is the correct outcome for an ambiguous URL. strtolower() handles HTTPS:// and Https://, which are valid per RFC 3986 and would otherwise be refused for the wrong reason — a strict !== 'https' on the raw value would have produced a confusing false rejection for legitimate uppercase configuration.
The failure uses the existing result shape. Returning ['status' => 0, 'body' => '', 'error' => ...] rather than throwing keeps the contract intact for callers in the scheduled data-load path, which already know how to surface a status => 0 result with a diagnostic message. An administrator who misconfigured the endpoint sees "Basic Authentication requires an HTTPS URL" in the load failure, which is actionable — it tells them precisely what to change. A generic exception or a silent credential-stripping fallback would not.
The user-visible contract change was recorded in the changelog as "Require HTTPS when using Basic Authentication with external JSON data sources." This is a behavioral break for anyone who had an http:// + Basic Auth data source working, and that is the point: it was working insecurely, and it now fails loudly.
Residual considerations
The guard validates the scheme of the URL that request() is given. If the client follows redirects, a 301 from an HTTPS origin to an http:// location is a separate scenario to reason about — HTTP clients differ in whether they strip the Authorization header on scheme downgrade versus host change. Teams hardening a similar wrapper should confirm their redirect policy independently rather than assuming the entry-point check covers the whole request chain.
Key Takeaways
'verify' => trueis not a transport-security guarantee. It validates certificates on TLS connections; it does nothing when the scheme ishttp://. Two different properties, one easily mistaken for the other.- URL validation for destination does not imply validation for confidentiality.
ExternalUrlValidatorcorrectly blocked internal targets and still lethttp://credentials out the door. Enumerate which property each validator actually enforces. - Tie the check to the sensitive parameter, not to the endpoint globally. The guard lives inside the
if ($basicAuth !== null && $basicAuth !== '')branch, so credential-freehttp://requests still work. Scheme enforcement was scoped to exactly the case where it matters. - Reject before you parse. Placing the scheme check ahead of
explode(':', $basicAuth, 2)means the password never enters the options array of a request that will not be made — no leak surface in logs or traces. parse_url()returningnullmust be treated as a rejection. The(string)cast turns an unparseable or scheme-less URL into a fail-closed outcome; comparing a nullable value loosely would have been an exploitable gap of its own.- Assert on the absence of behavior in regression tests.
expects($this->never())->method('newClient')proves no connection is attempted, which is a stronger guarantee than asserting on the returned error string alone.
How Orbis AppSec Detected This
- Source: the
$basicAuthcredential string and the$urlargument passed toExternalHttpClient::request(), both originating from administrator-configured external data source settings. - Sink: the HTTP client's
authrequest option ($options['auth'] = [$username, $password]), which the client serializes into a base64-encodedAuthorization: Basicheader and writes onto the wire. - Missing control: no enforcement that the request URL scheme was
httpsbefore credentials were attached;'verify' => truewas present but is inert without a TLS handshake, and the URL validator constrained only the destination host, not the scheme. - CWE: unknown — the scanner did not attach a CWE identifier to finding
V-001. The weakness class is cleartext transmission of sensitive information over an unencrypted channel. - Fix: reject the call with
['status' => 0, 'body' => '', 'error' => 'Basic Authentication requires an HTTPS URL']whenstrtolower((string)parse_url($url, PHP_URL_SCHEME)) !== 'https', before the credential is parsed or a client is constructed.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This was not a subtle parser bug or a memory-safety trap. It was a missing four-line precondition in a method that otherwise looked well-defended — certificate verification on, destination validated, credentials never logged. The gap was that nothing connected the presence of a password to the safety of the channel carrying it.
The corrected ExternalHttpClient::request() now treats $basicAuth as a capability that unlocks only over TLS: no HTTPS scheme