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:
cadropped → the private corporate root is not in the trust store for this handshake.servernamedropped → no SNI is sent and, critically, Node has no hostname to verify the certificate's CN/SAN against.checkServerIdentitydropped → the SPKI pin callback is never invoked, so it can never throw.rejectUnauthorized: truedropped → 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
- 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.
- 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. - When the service issues the
POST /oauth/tokenrefresh above, the attacker's relay answers the SOCKS5CONNECTitself instead of dialing the real upstream, and presents a self-signed certificate forauth.upstream.example. - Under undici 7.24.8, that certificate is accepted. The attacker now reads the
Authorization: Basic <client_id:client_secret>header, therefresh_tokenin the body, and the freshly mintedaccess_tokenin the response. - 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 ofpackage.jsonrather 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 movesdsh-coding-oauth-coreout ofdependencies(intodevDependencies, 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.yamlinto the seed project carries the workspace-level resolution rules (catalog/override entries that holdundiciat7.29.0) into the metadata resolution, so the seeded graph is resolved under the same const