-
Notifications
You must be signed in to change notification settings - Fork 11
@W-22849587 fix(sdk): harden mTLS client-cert fetch against bundled-undici drift #474
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
clavery
wants to merge
4
commits into
main
Choose a base branch
from
fix/468-undici-dispatcher
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
569a022
fix(sdk): route mTLS dispatcher requests through undici's own fetch
clavery bf2070e
test: fix test-tsconfig type error in dispatch-fetch regression test
clavery e7cbc90
ci: add Node 26 to the Linux test matrix
clavery a9f8b6a
Revert "ci: add Node 26 to the Linux test matrix"
clavery File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@salesforce/b2c-tooling-sdk': patch | ||
| --- | ||
|
|
||
| Make mTLS / self-signed client certificates robust against Node's bundled undici version. The TLS dispatcher is an undici `Agent` from the `undici` npm package, but it was handed 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. Because undici's request-handler interface changed across majors (and the cross-version compatibility shim is removed in undici 8), pairing a foreign Agent with `global.fetch` can fail and silently drop the client certificate. Requests that carry a dispatcher now use undici's own `fetch` so the Agent and fetch always share one undici instance, regardless of Node version. Applies to all auth strategies (basic, client-credentials, JWT, implicit, API key), so staging deploys with `--certificate`/`--selfsigned` keep working as Node updates its bundled undici. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| /* | ||
| * Copyright (c) 2025, Salesforce, Inc. | ||
| * SPDX-License-Identifier: Apache-2 | ||
| * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 | ||
| */ | ||
| import {fetch as undiciFetch} from 'undici'; | ||
| import type {FetchInit} from './types.js'; | ||
|
|
||
| // undiciFetch returns undici's Response type; cast to the global Response at the | ||
| // call site. The two are structurally compatible for our usage. | ||
| type FetchFn = (url: string, init?: RequestInit) => Promise<Response>; | ||
|
|
||
| /** | ||
| * Performs a fetch, transparently routing requests that carry an undici | ||
| * `dispatcher` (mTLS / self-signed TLS Agent) through undici's own `fetch`. | ||
| * | ||
| * Why this exists: the `dispatcher` is an undici `Agent` constructed from the | ||
| * `undici` npm package (see clients/tls-dispatcher.ts). `global.fetch` is backed | ||
| * by whatever undici Node bundles internally, which drifts across Node | ||
| * minor/patch releases and can be a different major than the npm package | ||
| * (e.g. Node 22 bundles undici 6.x, Node 24 bundles 7.x, the npm dep is pinned to | ||
| * 7.x). The undici `Dispatcher` request-handler interface changed across undici | ||
| * 6/7/8: undici 7 still ships a compatibility shim that lets a foreign Agent | ||
| * accept the bundled fetch's handler, but that shim was removed in undici 8 | ||
| * (handler validation now throws `invalid onRequestStart method`). So handing a | ||
| * foreign Agent to `global.fetch` across that version boundary can fail and | ||
| * silently drop the client certificate. Routing dispatcher-bearing requests | ||
| * through undici's own `fetch` keeps the Agent and the fetch on a single | ||
| * undici instance, eliminating this class of failure regardless of which undici | ||
| * Node bundles. | ||
| * | ||
| * When no dispatcher is present we use `global.fetch` to preserve the built-in | ||
| * behaviour (and existing test coverage) for the common case. | ||
| * | ||
| * @param url - Request URL | ||
| * @param init - Fetch init; may include an undici `dispatcher` | ||
| * @returns The fetch response | ||
| */ | ||
| export function dispatchFetch(url: string, init: FetchInit = {}): Promise<Response> { | ||
| const fetchFn = (init.dispatcher ? undiciFetch : fetch) as FetchFn; | ||
| return fetchFn(url, init as RequestInit); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| /* | ||
| * Copyright (c) 2025, Salesforce, Inc. | ||
| * SPDX-License-Identifier: Apache-2 | ||
| * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 | ||
| */ | ||
|
|
||
| import {expect} from 'chai'; | ||
| import {MockAgent} from 'undici'; | ||
| import {BasicAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; | ||
|
|
||
| /** | ||
| * Regression tests for issue #468: when a request carries an undici `dispatcher` | ||
| * (mTLS / self-signed TLS Agent), it must be routed through undici's OWN `fetch`, | ||
| * not `global.fetch`, so the Agent and the fetch share one undici instance. | ||
| * | ||
| * The discriminating assertion is *which* fetch is used: | ||
| * - dispatcher present -> undici's fetch (NOT global.fetch) | ||
| * - dispatcher absent -> global.fetch | ||
| * | ||
| * We detect a global.fetch call by replacing globalThis.fetch with a counting | ||
| * stub. The pre-fix code (`fetch(url, ...)` with the dispatcher) would call that | ||
| * stub even when a dispatcher is present; the fixed code must not. A real undici | ||
| * MockAgent stands in for the dispatcher so the undici path resolves without | ||
| * touching the network. Asserting on the global.fetch count (rather than only on | ||
| * a successful response) is what makes this fail for the buggy code: at the | ||
| * current undici version global.fetch happens to honor a per-call dispatcher, so | ||
| * a response-only assertion would pass for the old code too. | ||
| */ | ||
| describe('auth/dispatch-fetch (issue #468)', () => { | ||
| const realGlobalFetch = globalThis.fetch; | ||
| let globalFetchCalls = 0; | ||
|
|
||
| beforeEach(() => { | ||
| globalFetchCalls = 0; | ||
| globalThis.fetch = (async (...args: Parameters<typeof fetch>) => { | ||
| globalFetchCalls++; | ||
| return realGlobalFetch(...args); | ||
| }) as typeof fetch; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| globalThis.fetch = realGlobalFetch; | ||
| }); | ||
|
|
||
| it('routes dispatcher-bearing requests through undici fetch, not global.fetch', async () => { | ||
| const mockAgent = new MockAgent(); | ||
| mockAgent.disableNetConnect(); | ||
| let interceptedAuth: string | undefined; | ||
| mockAgent | ||
| .get('https://example.test') | ||
| .intercept({path: '/file.txt', method: 'PUT'}) | ||
| .reply((opts) => { | ||
| interceptedAuth = (opts.headers as Record<string, string>)?.authorization; | ||
| return {statusCode: 201, data: 'created'}; | ||
| }); | ||
|
|
||
| const auth = new BasicAuthStrategy('user', 'pass'); | ||
| const res = await auth.fetch('https://example.test/file.txt', { | ||
| method: 'PUT', | ||
| body: 'hello', | ||
| dispatcher: mockAgent, | ||
| }); | ||
|
|
||
| expect(res.status, 'request resolved via the undici dispatcher path').to.equal(201); | ||
| // Core regression assertion: the dispatcher path must NOT use global.fetch. | ||
| expect(globalFetchCalls, 'global.fetch must not be used when a dispatcher is present').to.equal(0); | ||
| // Auth header is still injected on the dispatcher path. | ||
| expect(interceptedAuth).to.equal(`Basic ${Buffer.from('user:pass').toString('base64')}`); | ||
|
|
||
| mockAgent.assertNoPendingInterceptors(); | ||
| await mockAgent.close(); | ||
| }); | ||
|
|
||
| it('uses global.fetch when no dispatcher is present', async () => { | ||
| // The beforeEach stub already counts global.fetch calls; replace it with one | ||
| // that short-circuits to a canned Response so no network is touched. | ||
| globalThis.fetch = (async () => { | ||
| globalFetchCalls++; | ||
| return new Response('ok', {status: 200}); | ||
| }) as typeof fetch; | ||
|
|
||
| const auth = new BasicAuthStrategy('user', 'pass'); | ||
| const res = await auth.fetch('https://example.test/'); | ||
|
|
||
| expect(res.status).to.equal(200); | ||
| expect(globalFetchCalls, 'no-dispatcher requests use global.fetch').to.equal(1); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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