Skip to content

@W-22849587 fix(sdk): harden mTLS client-cert fetch against bundled-undici drift#474

Open
clavery wants to merge 4 commits into
mainfrom
fix/468-undici-dispatcher
Open

@W-22849587 fix(sdk): harden mTLS client-cert fetch against bundled-undici drift#474
clavery wants to merge 4 commits into
mainfrom
fix/468-undici-dispatcher

Conversation

@clavery
Copy link
Copy Markdown
Collaborator

@clavery clavery commented Jun 5, 2026

Summary

Client certificates (mTLS) for WebDAV/staging deploys are presented by passing an undici Agent (built from the undici npm package) as the dispatcher option to fetch. Every auth strategy passed that Agent to global.fetch, which is backed by whatever undici Node bundles internally — a version that drifts across Node releases and can be a different major than the npm package.

This PR routes any request that carries a dispatcher through undici's own fetch (a small shared dispatchFetch helper), so the Agent and the fetch always share one undici instance, independent of which undici Node bundles. Wired into all five auth strategies (basic, client-credentials, JWT, implicit, API key).

Credit: independent implementation of the fix proposed in #469 by @hnestmann. Fixes #468. Tracked as W-22849587.

Root cause (empirically verified)

The undici request-handler interface is produced by global.fetch (= Node's bundled undici) but validated by the Agent (= the npm undici). undici 8 removed the legacy-handler compatibility shim, so its assertRequestHandler now requires onRequestStart and rejects the older handler shape with UND_ERR_INVALID_ARG: invalid onRequestStart method — the exact error from #468.

Reproduced against a real local mutual-TLS server (requestCert: true) with a real client .p12, across Node 22 (bundles undici 6.x), Node 24 (7.x), and Node 26 (8.x):

npm undici Agent Node 22 (bundled 6.x) Node 24 (bundled 7.x) Node 26 (bundled 8.x)
7.19.2 (current pin) via global.fetch ✅ cert presented
8.3.0 via global.fetch UND_ERR_INVALID_ARG: invalid onRequestStart method ❌ same
either, via undici's own fetch (this fix)

Key takeaways:

  • The break happens when the npm undici major differs from the bundled undici major (handler shape vs. validator strictness disagree). With the current 7.19.2 pin it does not reproduce on Node 22/24/26 — undici 7's shim bridges the gap, which is why the original reporter (on a newer undici) hit it and a naive local repro does not.
  • The real risk is therefore bumping our npm undici to 8.x while users run Node whose bundled undici is older (Node 22/24) — that bump is inevitable. The fix presents the certificate in every cell of the matrix because the npm-undici Agent is always driven by the npm-undici fetch — one instance, one handler interface, regardless of the bundled version.

Why this approach (and not the alternatives)

This is the documented, ecosystem-standard "bring-your-own-dispatcher" pattern (Node docs: a custom dispatcher "must be compatible with undici's Dispatcher class" and you "install undici"; undici README; @octokit/request uses the identical import { fetch as undiciFetch, Agent } from 'undici' + dispatcher wrapper). There is no node-native, undici-free path for client certs over fetch: node:undici is not importable; a node:https.Agent as dispatcher throws agent.dispatch is not a function; cert/key on RequestInit are silently ignored; NODE_EXTRA_CA_CERTS only extends trusted root CAs (server verification), not client identity.

Rejected: setGlobalDispatcher (process-global mutation — library-unsafe; we keep zero usages in src), version-detecting process.versions.undici (fragile, breaks on every minor/patch drift), loosening the undici pin (you cannot pin bundled undici), and rewriting onto undici.request (no correctness gain; breaks the Response/middleware contract).

Changes

  • src/auth/dispatch-fetch.ts — new shared helper: dispatcher present → undici's fetch; otherwise → global.fetch.
  • Routed all five strategies through it: basic.ts, oauth.ts, api-key.ts, oauth-jwt.ts, oauth-implicit.ts. (The original PR patched only oauth.ts; the default WebDAV deploy path uses BasicAuthStrategy.)
  • test/auth/dispatch-fetch.test.ts — regression test asserting the routing branch (verified to fail against the old global.fetch code path, version-independent).
  • Changeset (@salesforce/b2c-tooling-sdk: patch).

Testing

  • typecheck:agent ✅, lint:agent ✅, format:check ✅ (including the tsc -p test pretest step CI runs).
  • Full auth suite passes on Node 22.20.0, 24.14.0, and 26.3.0
  • End-to-end mTLS reproduction matrix (above) run on all three Node versions, plus an undici@8.3.0 fixture, confirming the fix presents the cert in every case and the old path fails exactly as Problems with staging deployments #468 describes ✅

Node 26 / test-harness follow-up (not in this PR)

Adding Node 26 to the CI matrix surfaced that the test harness itself (mocha 10 / c8 / yargs) cannot start on Node 26 — yargs <18 ships an extensionless type: module file that Node 26 classifies as ESM (require is not defined in ES module scope), crashing mocha/c8 before any test runs. That is unrelated to this fix and is its own substantial change (mocha 11, a scoped c8>yargs override, an applicationinsights ESM-interop fix). It is tracked separately in W-22850150 and will add Node 26 as a required CI leg there. This PR therefore keeps the required matrix at 22.x/24.x; the undici-8 risk it guards is fully exercised on those legs.

The TLS/mTLS dispatcher is an undici `Agent` constructed from the `undici`
npm package, but every auth strategy handed it to `global.fetch`, which is
backed by whatever undici Node bundles internally. That bundled version
drifts across Node releases (Node 22 -> undici 6.x, Node 24 -> 7.x) and can
be a different major than the npm package. undici's request-handler interface
changed across majors, and the cross-version compat shim is removed in undici
8 — so pairing a foreign Agent with `global.fetch` across that boundary can
fail and silently drop the client certificate.

Add a shared `dispatchFetch` helper that routes dispatcher-bearing requests
through undici's own `fetch`, keeping the Agent and fetch on one undici
instance regardless of the bundled version, and wire it into all five auth
strategies (basic, client-credentials, JWT, implicit, API key). `global.fetch`
is preserved for the common no-dispatcher case.

Adds a regression test that asserts the routing branch (and fails against the
old `global.fetch` code path).

Independent implementation of the fix proposed in #469 by Holger Nestmann
(@hnestmann); fixes #468.

Co-authored-by: Holger Nestmann <hnestmann@users.noreply.github.com>
@clavery clavery requested review from charithaT07 and hnestmann June 5, 2026 00:27
@clavery clavery changed the title fix(sdk): harden mTLS client-cert fetch against bundled-undici drift @W-22849587 fix(sdk): harden mTLS client-cert fetch against bundled-undici drift Jun 5, 2026
clavery added 3 commits June 4, 2026 20:34
The no-dispatcher case routed its global.fetch stub through an undici
MockAgent and cast the init to RequestInit, which collides between the
global RequestInit (undici-types) and undici's own RequestInit under
`tsc -p test` (the CI pretest step). Simplify it to a plain canned-Response
stub that just asserts global.fetch is the entry point — no cast, no mock.
Node 26 bundles undici 8.x, whose request-handler interface dropped the
legacy compatibility shim. This leg guards the mTLS dispatcher path against
undici version skew between Node's bundled undici and the npm `undici`
dependency (the class of failure fixed in this PR).
* @returns The fetch response
*/
export function dispatchFetch(url: string, init: FetchInit = {}): Promise<Response> {
const fetchFn = (init.dispatcher ? undiciFetch : fetch) as FetchFn;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe some trace logging could help identify which version we have

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Problems with staging deployments

3 participants