Back to Blog
high SEVERITY7 min read

How Man-in-the-Middle via ignored TLS options happens in Node.js undici SOCKS5 proxies and how to fix it

`dsh-coding-subscription-oauth` shipped `undici@7.24.8`, a release affected by CVE-2026-9697: when requests are routed through a SOCKS5 proxy, undici silently drops the caller-supplied TLS `connect` options (`ca`, `rejectUnauthorized`, `checkServerIdentity`, `servername`), so certificate pinning and custom trust stores are never applied. The fix pins `undici` to `7.29.0` across the app, `dsh-coding-oauth-core@0.1.1`, and both the production and development dispatchers, and hardens the Docker `de

O
By Orbis AppSec
Published August 31, 2026Reviewed August 31, 2026

Answer Summary

CVE-2026-9697 is an improper certificate validation flaw (CWE-295, leading to CWE-300 man-in-the-middle) in the Node.js HTTP client **undici**: when a request is dispatched through a SOCKS5 proxy, the TLS options passed in `connect` — `ca`, `rejectUnauthorized`, `checkServerIdentity`, `servername` — were ignored while establishing the tunneled TLS session, so pinning and custom CAs were silently bypassed. Any attacker who controls or sits on the SOCKS5 path could present an arbitrary certificate and read or rewrite OAuth traffic. The fix is a dependency upgrade: move to `undici@7.28.0`/`8.5.0` or later (this repo pins `7.29.0`) and make sure no transitive copy of a vulnerable Undici remains in `pnpm-lock.yaml`.

Vulnerability at a Glance

cweCWE-295 (Improper Certificate Validation), leading to CWE-300 (Channel Accessible by Non-Endpoint)
fixPin `undici` to `7.29.0` (>= 7.28.0 / >= 8.5.0) in `package.json` + `pnpm-lock.yaml`, align `dsh-coding-oauth-core@0.1.1`, and fail the Docker build if the core pin is missing
riskOAuth bearer/refresh tokens and subscription API traffic can be intercepted or modified by a proxy-path attacker despite explicit TLS pinning
languageJavaScript / TypeScript (Node.js, undici HTTP client)
root causeundici's SOCKS5 connector established the tunneled TLS socket without threading the caller's `connect` TLS options through to `tls.connect()`
vulnerabilityMan-in-the-Middle via ignored TLS options on the SOCKS5 proxy path (CVE-2026-9697)

Summary

dsh-coding-subscription-oauth depended on undici@7.24.8, which is affected by CVE-2026-9697 (HIGH): when a request is routed through a SOCKS5 proxy, undici ignored the TLS options supplied in the dispatcher's connect block — ca, rejectUnauthorized, checkServerIdentity, servername — while establishing the tunneled TLS session. That turns an explicitly pinned HTTPS call into an unverified one, with no error and no log line. The fix pins undici to 7.29.0 across the app, dsh-coding-oauth-core@0.1.1, and both the production and development dispatcher dependencies, and hardens the Docker dependencies stage so the core pin can never drift.

Introduction

The pnpm-lock.yaml file in this repository is the single source of truth for every byte of third-party JavaScript that ends up inside the OAuth container. Trivy flagged one line of it:

  undici@7.24.8:
    resolution: {integrity: sha512-...}

That version is inside the vulnerable range for CVE-2026-9697. Undici is not an incidental dependency here — it is the HTTP stack for this service. The changelog entry added by this PR names the exact surfaces involved:

## v0.6.4 - 2026-08-29

- Pin Subscription, its shared core dependency, and both production/development
  dispatcher dependencies to `undici@7.29.0` with `dsh-coding-oauth-core@0.1.1`,
  preventing a co-installed Undici major split while preserving the proxy-store
  ABI and Grok Imagine's explicit pinned dispatcher.

Read that carefully: "proxy-store" and "Grok Imagine's explicit pinned dispatcher." This service deliberately builds custom undici dispatchers that route egress through proxies and that pin TLS trust. CVE-2026-9697 is the bug where undici accepts that pinning configuration on the SOCKS5 path and then throws it away. For a service whose entire job is exchanging and refreshing OAuth credentials, that is close to a worst-case combination.

The Vulnerability Explained

What the code asked for

Application code that pins TLS through a SOCKS5 proxy looks something like this (illustrative, matching the "explicit pinned dispatcher" pattern described in the changelog):

// Illustrative of the vulnerable usage pattern
import { ProxyAgent, request } from 'undici'
import { readFileSync } from 'node:fs'

const dispatcher = new ProxyAgent({
  // Egress is forced through the corporate SOCKS5 relay
  uri: 'socks5://proxy.internal:1080',
  connect: {
    ca: readFileSync('/etc/ssl/corp-root-ca.pem'),   // custom trust store
    rejectUnauthorized: true,                        // fail closed
    servername: 'auth.upstream.example',             // SNI + hostname check
    checkServerIdentity (host, cert) {               // certificate pinning
      if (sha256(cert.raw) !== PINNED_SPKI) {
        throw new Error('pin mismatch')
      }
    }
  }
})

// Token exchange — the most credential-dense request in the service
const res = await request('https://auth.upstream.example/oauth/token', {
  method: 'POST',
  dispatcher,
  headers: { authorization: `Basic ${clientCredentials}` },
  body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token })
})

Every knob a security reviewer would look for is present: custom CA, fail-closed verification, explicit SNI, and an SPKI pin.

What undici 7.24.8 actually did

On the plain HTTP/HTTPS CONNECT-proxy path, undici's buildConnector() receives that connect object and forwards it into tls.connect(). On the SOCKS5 path, the connector negotiated the SOCKS5 handshake (greeting → auth → CONNECT command → tunneled socket) and then upgraded the resulting raw socket to TLS without threading the caller's options through. Conceptually:

// Vulnerable shape (simplified): SOCKS5 tunnel established, then TLS
// created from the socket *without* the caller's connect options.
const socket = await socksConnect({ host: proxyHost, port: proxyPort, destination })

return tls.connect({ socket })   // <-- no ca, no servername,
                                 //     no checkServerIdentity, no rejectUnauthorized

The consequences of that single omission compound:

  • ca dropped → the private corporate root is not in the trust store for this handshake.
  • servername dropped → no SNI is sent and, critically, Node has no hostname to verify the certificate's CN/SAN against.
  • checkServerIdentity dropped → the SPKI pin callback is never invoked, so it can never throw.
  • rejectUnauthorized: true dropped → the "fail closed" instruction is not honored on this path.

Nothing throws. res.statusCode is 200. TLS is still "used." The verification that makes TLS mean something is simply absent.

Attack scenario against this service

  1. The subscription OAuth service is configured, as designed, to send upstream traffic through a SOCKS5 relay — a container sidecar, a regional egress node, or the proxy recorded in the proxy-store.
  2. An attacker gains any position on that path: a compromised SOCKS5 relay, a stolen proxy credential from the proxy-store, a DNS/ARP hijack of proxy.internal, or a malicious operator-supplied proxy URI.
  3. When the service issues the POST /oauth/token refresh above, the attacker's relay answers the SOCKS5 CONNECT itself instead of dialing the real upstream, and presents a self-signed certificate for auth.upstream.example.
  4. Under undici 7.24.8, that certificate is accepted. The attacker now reads the Authorization: Basic <client_id:client_secret> header, the refresh_token in the body, and the freshly minted access_token in the response.
  5. Because the attacker is a full man-in-the-middle, they can also rewrite responses — returning a longer-lived token, altering subscription entitlement fields, or downgrading scope checks that downstream code trusts.

Stolen refresh tokens and client secrets do not expire on the attacker's schedule; they persist until someone notices and rotates them. And the pinning code in the repository would have made the team confident this was impossible — which is why silent TLS bypasses are more dangerous than loud ones.

The Fix

The remediation is a version bump plus supply-chain hygiene so the bump actually holds. Two files carry the security change, and the Dockerfile makes it durable.

1. package.json and pnpm-lock.yaml: move off the vulnerable range

-  "undici": "7.24.8"
+  "undici": "7.29.0"

CVE-2026-9697 is fixed in undici 7.28.0 and 8.5.0. This repository pins 7.29.0 — inside the patched 7.x line, so the proxy-store ABI and dispatcher construction stay source-compatible while the SOCKS5 connector now forwards the full connect option bag into tls.connect(). After the upgrade, the same application code behaves as written: a self-signed certificate behind the SOCKS5 tunnel triggers UNABLE_TO_VERIFY_LEAF_SIGNATURE / ERR_TLS_CERT_ALTNAME_INVALID, and the checkServerIdentity pin callback runs and can reject.

The lockfile change matters as much as the manifest change. A single stale transitive entry:

  undici@7.24.8:      # removed
  undici@7.29.0:      # added

is what Trivy actually matches on, and it is what pnpm actually installs.

2. Aligning dsh-coding-oauth-core@0.1.1 — no "Undici major split"

The changelog is explicit about the second half of the problem: "preventing a co-installed Undici major split." If the application pins undici@7.29.0 but the shared dsh-coding-oauth-core package resolves its own undici@8.x (or, worse, its own vulnerable 7.24.x), pnpm's isolated node_modules layout will happily install both. You then get:

  • a patched Undici on the paths you audited, and
  • a second, unpatched Undici instance underneath the core library, still ignoring TLS options on SOCKS5, and
  • dispatcher objects from one Undici instance being passed to request() from another — the "ABI" mismatch the changelog is protecting against.

Pinning dsh-coding-oauth-core@0.1.1 alongside undici@7.29.0 collapses the tree to a single Undici copy so the fix covers every egress path, including the production dispatcher and the development dispatcher.

3. Dockerfile: fail the build instead of silently drifting

The dependencies stage previously did the minimum:

FROM toolchain AS dependencies
COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile

The PR replaces it with an assert-and-seed step:

FROM toolchain AS dependencies
COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile \
    && core_version="$(node -p 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).dependencies["dsh-coding-oauth-core"]')" \
    && test -n "${core_version}" \
    && mkdir -p /tmp/pnpm-metadata-seed \
    && cp pnpm-workspace.yaml /tmp/pnpm-metadata-seed/pnpm-workspace.yaml \
    && printf '{"name":"pnpm-metadata-seed","private":true}\n' > /tmp/pnpm-metadata-seed/package.json \
    && cd /tmp/pnpm-metadata-seed \
    && pnpm add --lockfile-only --ignore-scripts "dsh-coding-oauth-core@${core_version}" \
    && rm -rf /tmp/pnpm-metadata-seed

Each clause is load-bearing:

  • core_version="$(node -p ...)" reads the pin straight out of package.json rather than duplicating a version string in the Dockerfile — one place to update, no chance of the image and the manifest disagreeing.
  • test -n "${core_version}" is the guardrail. If a future refactor moves dsh-coding-oauth-core out of dependencies (into devDependencies, or drops it), the build fails immediately instead of producing an image whose Undici pinning is no longer enforced. Silent removal of a security-relevant pin is exactly how a fixed CVE reappears three releases later.
  • cp pnpm-workspace.yaml into the seed project carries the workspace-level resolution rules (catalog/override entries that hold undici at 7.29.0) into the metadata resolution, so the seeded graph is resolved under the same const

Frequently Asked Questions

What is Man-in-the-Middle via ignored TLS options?

It is a class of transport-security bug where an application explicitly configures TLS verification (a custom CA bundle, `rejectUnauthorized: true`, a `checkServerIdentity` pinning callback) but the HTTP client never applies those options on a particular code path — here, the SOCKS5 tunnel. The connection still looks like HTTPS, yet an attacker in the network path can terminate it with an arbitrary certificate and read or rewrite plaintext requests and responses.

How do you prevent Man-in-the-Middle via ignored TLS options in Node.js?

Keep HTTP clients patched (undici >= 7.28.0 or >= 8.5.0 for CVE-2026-9697), and treat TLS configuration as something to verify rather than assume: write an integration test that points your dispatcher at a server with an untrusted self-signed certificate and assert the request *fails*. Pin transitive versions with pnpm overrides/catalogs so a single vulnerable copy cannot sneak into the tree, and prefer `checkServerIdentity`-based pinning plus an explicit `ca` for sensitive endpoints.

What CWE is Man-in-the-Middle via ignored TLS options?

The root cause maps to CWE-295 (Improper Certificate Validation); the resulting exposure maps to CWE-300 (Channel Accessible by Non-Endpoint, i.e. classic MITM) and CWE-319 (Cleartext Transmission of Sensitive Information) when credentials are involved.

Is using HTTPS URLs enough to prevent Man-in-the-Middle via ignored TLS options?

No. An `https://` URL only expresses intent — the actual guarantee comes from certificate chain validation and hostname verification at connect time. In CVE-2026-9697 the URL scheme was still HTTPS and no error was raised; the verification step behind the SOCKS5 tunnel simply did not use the options you supplied, which is exactly why the failure is silent.

Can static analysis detect Man-in-the-Middle via ignored TLS options?

Static analysis reliably detects the *dependency* form of the issue — SCA scanners such as Trivy match `undici@7.24.8` in `pnpm-lock.yaml` against CVE-2026-9697 and flag it immediately. Detecting the logic bug inside a library, or an application that passes TLS options to a code path that discards them, generally requires source-level taint analysis or a negative TLS integration test.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #23

Related Articles

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.

high

How Interpretation Conflict Vulnerability happens in Node.js and how to fix it

node-forge versions up to 1.3.1 shipped an ASN.1 parser vulnerable to an interpretation conflict that could let attackers bypass cryptographic signature verification, alongside a related unbounded recursion flaw (CVE-2025-66031) that enables denial-of-service. Upgrading the dependency to node-forge 1.4.0 patches both issues by hardening the ASN.1 decoder against malformed and adversarially crafted input.

critical

How Hardcoded HMAC-SHA256 Keys Compromise API Authentication in HarmonyOS and How to Fix It

A critical vulnerability in the Bika application exposed a hardcoded HMAC-SHA256 signing key directly in the Constants.ets file, allowing attackers to forge valid API requests. The fix implements runtime key deobfuscation using XOR masking, removing the plaintext credential from both source code and compiled binaries. This change demonstrates why symmetric keys must never be embedded in client-side code.

critical

How Unsafe Random Functions Happen in Node.js Form Data and How to Fix It

CVE-2025-7783 is a critical vulnerability in the `form-data` npm package caused by the use of an unsafe random number generator to produce multipart form boundaries, making those boundaries predictable by an attacker. The fix upgrades `form-data` to versions 2.5.4, 3.0.4, and 4.0.4, which replace the weak random function with a cryptographically secure alternative. This change was applied to the `example-apps/collector/package-lock.json` and `package.json` files in the Instana collector example

critical

How Plaintext Token Storage happens in TypeScript/Tauri and how to fix it

A critical vulnerability in a Tauri desktop application allowed GitHub API tokens with full `repo` scope to be written to plaintext local storage files via the `getAllSettings()` function in `src/config/settings.ts`. Any process with filesystem access — including malware, other apps, or a logged-in attacker — could silently extract these tokens. The fix introduces a `SENSITIVE_KEYS` exclusion set that prevents credentials from being serialized to disk.

high

How Dependabot Missing Cooldown Periods Enable Supply Chain Attacks and How to Fix It

A critical security vulnerability in `.github/dependabot.yml` was exposing a Node.js library to supply chain attacks by automatically updating to newly published packages without a safety delay. By adding a 7-day cooldown period to each package ecosystem configuration, the project now protects against malicious or unstable package versions that could affect downstream consumers.