Back to Blog
critical SEVERITY9 min read

ExternalHttpClient::request() Sent Basic Auth Over Plain HTTP

The `ExternalHttpClient::request()` helper accepted a `$basicAuth` string and passed it straight to the HTTP client's `auth` option without checking that the target URL used `https://`. Any external JSON data source configured with an `http://` endpoint therefore shipped a base64-encoded `Authorization: Basic` header in cleartext on every scheduled load. The fix rejects the request outright — before a client is even created — when the URL scheme is not HTTPS.

O
By Orbis AppSec
Published September 13, 2026Reviewed September 13, 2026

Answer Summary

This is a first-party fix in the `ExternalHttpClient::request()` method, which performs outbound HTTP calls for external JSON data sources; no package version range applies. An attacker positioned on the network path (rogue Wi-Fi, hostile transit hop, compromised internal proxy) could capture the `Authorization: Basic` header sent to an `http://` endpoint and base64-decode the configured username and password instantly, then reuse those credentials against the upstream API. The fix adds a scheme check using `parse_url($url, PHP_URL_SCHEME)` that returns a `['status' => 0, 'body' => '', 'error' => 'Basic Authentication requires an HTTPS URL']` failure result before credentials are parsed into the request options; there is no released version number because this is first-party application code. No CVE or GHSA was assigned and the scanner did not attach a CWE identifier — the weakness class is cleartext transmission of sensitive information.

Vulnerability at a Glance

cweN/A (not assigned by the scanner)
fixReject the call with a structured error result when `parse_url($url, PHP_URL_SCHEME)` is not `https`, before the credentials are split or attached
riskHigh–critical. Base64-encoded credentials for an external API are exposed to any network observer when the configured endpoint uses http://
languagePHP
root cause`ExternalHttpClient::request()` set the client `auth` option from `$basicAuth` without validating the URL scheme, relying only on `'verify' => true` for transport safety
vulnerabilityCleartext transmission of Basic Authentication credentials

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:

  1. ExternalUrlValidator::validate() rejects internal/loopback/metadata-style targets. It does not constrain the schemehttp:// to a legitimate external host passes cleanly.
  2. $options['verify'] = true is set. Inert on non-TLS connections.
  3. If $basicAuth is non-empty, it is split and written into $options['auth'].
  4. 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' => true is not a transport-security guarantee. It validates certificates on TLS connections; it does nothing when the scheme is http://. Two different properties, one easily mistaken for the other.
  • URL validation for destination does not imply validation for confidentiality. ExternalUrlValidator correctly blocked internal targets and still let http:// 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-free http:// 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() returning null must 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 $basicAuth credential string and the $url argument passed to ExternalHttpClient::request(), both originating from administrator-configured external data source settings.
  • Sink: the HTTP client's auth request option ($options['auth'] = [$username, $password]), which the client serializes into a base64-encoded Authorization: Basic header and writes onto the wire.
  • Missing control: no enforcement that the request URL scheme was https before credentials were attached; 'verify' => true was 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'] when strtolower((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

Prevention and further reading

Frequently Asked Questions

Does the HTTPS check in `ExternalHttpClient::request()` break existing `http://` data sources that do not use Basic Auth?

No. The guard lives inside the `if ($basicAuth !== null && $basicAuth !== '')` branch, so plain `http://` requests with no credentials behave exactly as before. Only the combination of Basic Auth plus a non-HTTPS scheme is refused.

Why does the fix return `['status' => 0, 'body' => '', 'error' => ...]` instead of throwing an exception?

That array shape is the existing failure convention for this client, so callers in the scheduled data-load path surface the message `Basic Authentication requires an HTTPS URL` as a diagnostic instead of hitting an uncaught exception. The accompanying regression test asserts `newClient()` is never called, proving the request is abandoned before a socket is opened.

Wasn't `'verify' => true` already protecting the credentials in this client?

No — `verify` only validates the peer certificate on connections that actually negotiate TLS. If the configured URL scheme is `http`, no TLS handshake happens at all, and the `Authorization: Basic` header travels in the clear regardless of the verify setting.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #594

Related Articles

critical

How insufficient PBKDF2 iterations happen in JavaScript and how to fix it

A critical vulnerability in `libs/wgs/pbkdf2.js` used only 1 iteration for PBKDF2 password hashing, making passwords trivially crackable. The fix increases iterations to 600,000, aligning with OWASP 2023 recommendations and preventing GPU-accelerated brute-force attacks.

critical

How Hardcoded Encryption Salts Compromise Credential Storage in Node.js and How to Fix It

A critical vulnerability in `scripts/bench-cpu.js` used a hardcoded static salt (`'byok-relay-salt'`) when deriving encryption keys with scrypt, allowing attackers to decrypt all encrypted credentials if the encryption secret was compromised. The fix replaces the hardcoded salt with cryptographically secure random bytes generated per operation, ensuring each user's encrypted credentials require a unique derived key.

high

How weak scrypt password hashing happens in Node.js and how to fix it

The `hashPass` function in `store-saas/server.mjs` used Node.js's `crypto.scryptSync` with default cost parameters (N=16384, r=8, p=1), making stored password hashes cheap to attack with modern GPUs. The fix increases the CPU/memory cost factor to N=131072 and parallelization to p=2, dramatically raising the computational effort required to brute-force stolen hashes.

high

How Dependency Version Pinning Prevents Supply Chain Attacks in Node.js and How to Fix It

A critical supply chain vulnerability in `package.json` allowed automatic updates to a cryptographic library with known weaknesses. By pinning `rijndael-js` to version `2.0.0` instead of allowing `^2.0.0` updates, the fix prevents silent installation of vulnerable versions that could expose downstream consumers to weak block cipher modes and authentication bypasses.

critical

How Insecure Randomness in form-data happens in Node.js and how to fix it

The `form-data` npm package, pinned at `^2.3.3` in `server/package-lock.json`, generated multipart form boundaries using the insecure `Math.random()` function instead of a cryptographically secure random source. This predictable boundary generation (CVE-2025-7783) could allow an attacker to guess or influence multipart boundaries, opening the door to request smuggling and payload injection in HTTP requests built by the server.

critical

SQL's Insert() and Update() Methods Used F-String Interpolation in u2share_batch_give_sugar

The SQL helper class in u2share_batch_give_sugar used Python f-strings to construct INSERT and UPDATE queries, creating SQL injection vulnerabilities even though values appeared to come from internal constants. The fix replaces all f-string query construction with sqlite3 parameterized queries using `?` placeholders, eliminating string interpolation entirely from the database path.