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
•Technically reviewed by Anupam Mediratta•Published August 31, 2026•Reviewed 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

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #23

Related Articles

high

file_md5() MD5 Digest Lets Agent Docs Collide (CWE-328)

A first-party maintenance script used `hashlib.md5()` inside a `file_md5(path)` helper to decide whether the per-vendor agent instruction documents in a repository were byte-equivalent. Because MD5 is collision-broken, two meaningfully different documents could be crafted to produce the same digest and pass the "these files are in sync" gate. The fix replaces the MD5 call with `hashlib.sha256()` over the same `path.read_bytes()` input.

high

SQLite Auth Database Plaintext Storage in InitAuthDB()

The `InitAuthDB()` function opened SQLite databases without restricting filesystem permissions, leaving bcrypt password hashes, session tokens, and user settings exposed to any local account with read access. The fix explicitly sets `os.Chmod(filepath, 0600)` immediately after database creation, ensuring only the owner can access authentication secrets.

critical

Hybridauth Telegram OAuth Timing Attack in `authenticateCheckError()`

The Telegram OAuth provider in Hybridauth relied on `strcmp()` to validate HMAC-SHA256 signatures, exposing a timing side-channel that could allow attackers to forge authentication tokens character by character. The fix replaces this with PHP's timing-safe `hash_equals()` function, eliminating the information leak.

high

undici 7.29.0 TLS Bypass: BalancedPool Drops Connect Options

A critical flaw in undici's BalancedPool implementation silently discards TLS certificate validation settings when routing requests through certain connection paths. Attackers on the network could intercept HTTPS traffic without triggering validation errors. The fix preserves connect options throughout the connection lifecycle.

critical

Model Fetching Without Integrity Verification in ONNX Loading

A machine learning application was fetching ONNX model files and chunks over the network without verifying their integrity, creating an opening for model poisoning attacks. The fix adds cryptographic integrity verification at the point where downloaded chunks are reassembled and cached, ensuring models have not been modified in transit or at rest.

high

nanoid 3.3.11 Integer Overflow: Predictable ID Generation

An integer overflow in nanoid 3.3.11's internal randomness generation causes the library to fall back to predictable ID sequences, undermining the cryptographic guarantees of its supposedly unguessable identifiers. The fix upgrades the dependency tree to patched versions 3.3.12 or 5.1.11.