diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 8fd44183af7..470b9cb028c 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -14,6 +14,12 @@ import { OAuthUtils, ResourceMismatchError } from './oauth-utils.js'; import { coreEvents } from '../utils/events.js'; import { debugLogger } from '../utils/debugLogger.js'; import { getConsentForOauth } from '../utils/authConsent.js'; +import { + createPinnedDispatcher, + isLoopbackHost, + resolveAndValidateDns, +} from '../utils/fetch.js'; +import type { Dispatcher } from 'undici'; import { generatePKCEParams, startCallbackServer, @@ -111,13 +117,27 @@ export class MCPOAuthProvider { scope: config.scopes?.join(' ') || '', }; + const parsedRegUrl = new URL(registrationUrl); + if (isLoopbackHost(parsedRegUrl.hostname)) { + throw new Error( + `Client registration blocked: loopback address not allowed: ${registrationUrl}`, + ); + } + const resolvedAddrs = await resolveAndValidateDns(registrationUrl); + if (resolvedAddrs.length === 0) { + throw new Error( + `Client registration blocked: private/reserved IP not allowed: ${registrationUrl}`, + ); + } + const dispatcher = createPinnedDispatcher(resolvedAddrs[0]); const response = await fetch(registrationUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(registrationRequest), - }); + dispatcher, + } as RequestInit & { dispatcher: Dispatcher }); if (!response.ok) { const errorText = await response.text(); diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index 12ab2bd9ff6..f1918a73b13 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -7,6 +7,12 @@ import type { MCPOAuthConfig } from './oauth-provider.js'; import { getErrorMessage } from '../utils/errors.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { + createPinnedDispatcher, + isLoopbackHost, + resolveAndValidateDns, +} from '../utils/fetch.js'; +import type { Dispatcher } from 'undici'; /** * Error thrown when the discovered resource metadata does not match the expected resource. @@ -97,7 +103,24 @@ export class OAuthUtils { resourceMetadataUrl: string, ): Promise { try { - const response = await fetch(resourceMetadataUrl); + const parsedUrl = new URL(resourceMetadataUrl); + if (isLoopbackHost(parsedUrl.hostname)) { + debugLogger.debug( + `Blocked OAuth metadata fetch to loopback address: ${resourceMetadataUrl}`, + ); + return null; + } + const resolvedAddrs = await resolveAndValidateDns(resourceMetadataUrl); + if (resolvedAddrs.length === 0) { + debugLogger.debug( + `Blocked OAuth metadata fetch to private/reserved IP: ${resourceMetadataUrl}`, + ); + return null; + } + const dispatcher = createPinnedDispatcher(resolvedAddrs[0]); + const response = await fetch(resourceMetadataUrl, { + dispatcher, + } as RequestInit & { dispatcher: Dispatcher }); if (!response.ok) { return null; } @@ -121,7 +144,24 @@ export class OAuthUtils { authServerMetadataUrl: string, ): Promise { try { - const response = await fetch(authServerMetadataUrl); + const parsedUrl = new URL(authServerMetadataUrl); + if (isLoopbackHost(parsedUrl.hostname)) { + debugLogger.debug( + `Blocked OAuth metadata fetch to loopback address: ${authServerMetadataUrl}`, + ); + return null; + } + const resolvedAddrs = await resolveAndValidateDns(authServerMetadataUrl); + if (resolvedAddrs.length === 0) { + debugLogger.debug( + `Blocked OAuth metadata fetch to private/reserved IP: ${authServerMetadataUrl}`, + ); + return null; + } + const dispatcher = createPinnedDispatcher(resolvedAddrs[0]); + const response = await fetch(authServerMetadataUrl, { + dispatcher, + } as RequestInit & { dispatcher: Dispatcher }); if (!response.ok) { return null; } diff --git a/packages/core/src/tools/web-fetch.test.ts b/packages/core/src/tools/web-fetch.test.ts index 6266644a7ff..aca89bef891 100644 --- a/packages/core/src/tools/web-fetch.test.ts +++ b/packages/core/src/tools/web-fetch.test.ts @@ -27,6 +27,7 @@ import { type ToolConfirmationResponse, } from '../confirmation-bus/types.js'; import { randomUUID } from 'node:crypto'; +import { lookup } from 'node:dns/promises'; import { logWebFetchFallbackAttempt, WebFetchFallbackAttemptEvent, @@ -52,7 +53,7 @@ vi.mock('../utils/fetch.js', async (importOriginal) => { return { ...actual, fetchWithTimeout: vi.fn(), - isPrivateIp: vi.fn(), + resolveAndValidateDns: vi.fn().mockResolvedValue(['8.8.8.8']), }; }); @@ -60,6 +61,8 @@ vi.mock('node:crypto', () => ({ randomUUID: vi.fn(), })); +vi.mock('node:dns/promises'); + /** * Helper to mock fetchWithTimeout with URL matching. */ @@ -67,6 +70,8 @@ const mockFetch = (url: string, response: Partial | Error) => vi .spyOn(fetchUtils, 'fetchWithTimeout') .mockImplementation(async (actualUrl) => { + // With the undici Agent dispatcher pattern, the original URL is + // always passed unchanged; DNS pinning happens inside the Agent. if (actualUrl !== url) { throw new Error( `Unexpected fetch URL: expected "${url}", got "${actualUrl}"`, @@ -270,6 +275,10 @@ describe('WebFetchTool', () => { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(lookup).mockResolvedValue([ + { address: '8.8.8.8', family: 4 }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any); bus = createMockMessageBus(); getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user'; mockConfig = { @@ -376,7 +385,9 @@ describe('WebFetchTool', () => { describe('execute', () => { it('should return WEB_FETCH_PROCESSING_ERROR on rate limit exceeded', async () => { - vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([ + '8.8.8.8', + ]); mockGenerateContent.mockResolvedValue({ candidates: [{ content: { parts: [{ text: 'response' }] } }], }); @@ -400,7 +411,9 @@ describe('WebFetchTool', () => { }); it('should skip rate-limited URLs but fetch others', async () => { - vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([ + '8.8.8.8', + ]); const tool = new WebFetchTool(mockConfig, bus); const params = { @@ -439,8 +452,8 @@ describe('WebFetchTool', () => { }); it('should skip private or local URLs but fetch others and log telemetry', async () => { - vi.mocked(fetchUtils.isPrivateIp).mockImplementation( - (url) => url === 'https://private.com/', + vi.mocked(fetchUtils.resolveAndValidateDns).mockImplementation( + async (url) => (url === 'https://private.com/' ? [] : ['8.8.8.8']), ); const tool = new WebFetchTool(mockConfig, bus); @@ -475,7 +488,9 @@ describe('WebFetchTool', () => { }); it('should fallback to all public URLs if primary fails', async () => { - vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([ + '8.8.8.8', + ]); // Primary fetch fails mockGenerateContent.mockRejectedValueOnce(new Error('primary fail')); @@ -513,8 +528,8 @@ describe('WebFetchTool', () => { }); it('should NOT include private URLs in fallback', async () => { - vi.mocked(fetchUtils.isPrivateIp).mockImplementation( - (url) => url === 'https://private.com/', + vi.mocked(fetchUtils.resolveAndValidateDns).mockImplementation( + async (url) => (url === 'https://private.com/' ? [] : ['8.8.8.8']), ); // Primary fetch fails @@ -546,7 +561,9 @@ describe('WebFetchTool', () => { }); it('should return WEB_FETCH_FALLBACK_FAILED on total failure', async () => { - vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([ + '8.8.8.8', + ]); mockGenerateContent.mockRejectedValue(new Error('primary fail')); mockFetch('https://public.ip/', new Error('fallback fetch failed')); const tool = new WebFetchTool(mockConfig, bus); @@ -559,7 +576,9 @@ describe('WebFetchTool', () => { }); it('should log telemetry when falling back due to primary fetch failure', async () => { - vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([ + '8.8.8.8', + ]); // Mock primary fetch to return empty response, triggering fallback mockGenerateContent.mockResolvedValueOnce({ candidates: [], @@ -591,7 +610,9 @@ describe('WebFetchTool', () => { describe('execute (fallback)', () => { beforeEach(() => { // Force fallback by mocking primary fetch to fail - vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([ + '8.8.8.8', + ]); mockGenerateContent.mockResolvedValueOnce({ candidates: [], }); @@ -929,7 +950,9 @@ describe('WebFetchTool', () => { }); it('should execute normally after confirmation approval', async () => { - vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([ + '8.8.8.8', + ]); mockGenerateContent.mockResolvedValue({ candidates: [ { @@ -963,7 +986,9 @@ describe('WebFetchTool', () => { describe('execute (experimental)', () => { beforeEach(() => { vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(true); - vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([ + '8.8.8.8', + ]); }); it('should perform direct fetch and return text for plain text content', async () => { @@ -989,6 +1014,7 @@ describe('WebFetchTool', () => { 'https://example.com/', expect.any(Number), expect.objectContaining({ + dispatcher: expect.any(Object), headers: expect.objectContaining({ Accept: expect.stringContaining('text/plain'), }), @@ -1142,7 +1168,7 @@ describe('WebFetchTool', () => { }); it('should block private IP (experimental)', async () => { - vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true); + vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([]); const tool = new WebFetchTool(mockConfig, bus); const invocation = tool['createInvocation']( { url: 'http://localhost' }, @@ -1158,6 +1184,101 @@ describe('WebFetchTool', () => { expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR); }); + describe('SSRF guard — DNS hostname-to-private-IP bypass (CVE-class)', () => { + it.each([ + { + label: 'localhost (literal name)', + url: 'http://localhost', + // caught by explicit loopback check, no DNS mock needed + dnsPrivate: false, + }, + { + label: '127.0.0.1 (IPv4 loopback literal)', + url: 'http://127.0.0.1', + dnsPrivate: false, + }, + { + label: '::1 (IPv6 loopback literal)', + url: 'http://[::1]', + dnsPrivate: false, + }, + { + label: '0.0.0.0 (unspecified literal)', + url: 'http://0.0.0.0', + dnsPrivate: false, + }, + { + label: '127.0.0.1.nip.io (nip.io loopback bypass)', + url: 'http://127.0.0.1.nip.io', + // DNS resolves to 127.0.0.1 — mocked via resolveAndValidateDns + dnsPrivate: true, + }, + { + label: '169.254.169.254.nip.io (cloud metadata bypass)', + url: 'http://169.254.169.254.nip.io', + dnsPrivate: true, + }, + ])('should block $label', async ({ url, dnsPrivate }) => { + if (dnsPrivate) { + vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([]); + } + const tool = new WebFetchTool(mockConfig, bus); + const invocation = tool['createInvocation']({ url }, bus); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(result.error?.type).toBe( + ToolErrorType.WEB_FETCH_PROCESSING_ERROR, + ); + expect(result.llmContent).toContain( + 'Access to blocked or private host', + ); + }); + + it('should allow a public URL (https://google.com)', async () => { + vi.spyOn(fetchUtils, 'resolveAndValidateDns').mockResolvedValue([ + '8.8.8.8', + ]); + const content = 'Google homepage'; + mockFetch('https://google.com/', { + status: 200, + headers: new Headers({ 'content-type': 'text/plain' }), + text: () => Promise.resolve(content), + }); + + const tool = new WebFetchTool(mockConfig, bus); + const invocation = tool.build({ url: 'https://google.com' }); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(result.error).toBeUndefined(); + expect(result.llmContent).toBe(content); + }); + + it('should block nip.io bypass in standard (non-experimental) mode', async () => { + // Override the outer beforeEach that enables experimental mode + vi.spyOn(mockConfig, 'getDirectWebFetch').mockReturnValue(false); + // Simulate DNS resolving the hostname to a private IP + vi.mocked(fetchUtils.resolveAndValidateDns).mockImplementation( + async (url) => (url.includes('127.0.0.1.nip.io') ? [] : ['8.8.8.8']), + ); + + const tool = new WebFetchTool(mockConfig, bus); + const params = { prompt: 'fetch http://127.0.0.1.nip.io/secret' }; + const invocation = tool.build(params); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(result.error?.type).toBe( + ToolErrorType.WEB_FETCH_PROCESSING_ERROR, + ); + expect(result.llmContent).toContain('[Blocked Host]'); + }); + }); + it('should bypass truncation if isContextManagementEnabled is true', async () => { vi.spyOn(mockConfig, 'isContextManagementEnabled').mockReturnValue(true); const largeContent = 'a'.repeat(300000); // Larger than MAX_CONTENT_LENGTH (250000) diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index d468064f237..c89a37b23a9 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -19,7 +19,12 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { ToolErrorType } from './tool-error.js'; import { getErrorMessage } from '../utils/errors.js'; import { getResponseText } from '../utils/partUtils.js'; -import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js'; +import { + createPinnedDispatcher, + fetchWithTimeout, + isLoopbackHost, + resolveAndValidateDns, +} from '../utils/fetch.js'; import { truncateString, wrapUntrusted } from '../utils/textUtils.js'; import { convert } from 'html-to-text'; import { @@ -267,16 +272,34 @@ class WebFetchToolInvocation extends BaseToolInvocation< ); } - private isBlockedHost(urlStr: string): boolean { + private async isBlockedHost(urlStr: string): Promise { try { const url = new URL(urlStr); const hostname = url.hostname.toLowerCase(); - if (hostname === 'localhost' || hostname === '127.0.0.1') { - return true; + // Block loopback and unspecified addresses by literal name/IP without DNS + // (covers localhost, 127.0.0.1, ::1, [::1], 0.0.0.0, [::]) + if ( + isLoopbackHost(hostname) || + hostname === '0.0.0.0' || + hostname === '[::]' + ) { + return null; + } + // Resolve DNS to catch hostname-to-private-IP bypasses such as + // 127.0.0.1.nip.io, 169.254.169.254.nip.io, or attacker-controlled DNS + try { + const safeIps = await resolveAndValidateDns(urlStr); + if (safeIps.length === 0) { + return null; + } + return safeIps[0]; + } catch { + // DNS resolution failed — deny by default to avoid SSRF via + // unresolvable or ambiguous hostnames + return null; } - return isPrivateIp(urlStr); } catch { - return true; + return null; } } @@ -285,17 +308,21 @@ class WebFetchToolInvocation extends BaseToolInvocation< signal: AbortSignal, ): Promise { const url = convertGithubUrlToRaw(urlStr); - if (this.isBlockedHost(url)) { + const pinnedIp = await this.isBlockedHost(url); + if (!pinnedIp) { debugLogger.warn(`[WebFetchTool] Blocked access to host: ${url}`); throw new Error( `Access to blocked or private host ${url} is not allowed.`, ); } + const dispatcher = createPinnedDispatcher(pinnedIp); + const response = await retryWithBackoff( async () => { const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, { signal, + dispatcher, headers: { 'User-Agent': USER_AGENT, }, @@ -350,16 +377,16 @@ class WebFetchToolInvocation extends BaseToolInvocation< return textContent; } - private filterAndValidateUrls(urls: string[]): { + private async filterAndValidateUrls(urls: string[]): Promise<{ toFetch: string[]; skipped: string[]; - } { + }> { const uniqueUrls = [...new Set(urls.map(normalizeUrl))]; const toFetch: string[] = []; const skipped: string[] = []; for (const url of uniqueUrls) { - if (this.isBlockedHost(url)) { + if (!(await this.isBlockedHost(url))) { debugLogger.warn( `[WebFetchTool] Skipped private or local host: ${url}`, ); @@ -615,7 +642,8 @@ ${aggregatedContent} // Convert GitHub blob URL to raw URL url = convertGithubUrlToRaw(url); - if (this.isBlockedHost(url)) { + const pinnedIp = await this.isBlockedHost(url); + if (!pinnedIp) { const errorMessage = `Access to blocked or private host ${url} is not allowed.`; debugLogger.warn( `[WebFetchTool] Blocked experimental fetch to host: ${url}`, @@ -631,10 +659,13 @@ ${aggregatedContent} } try { + const dispatcher = createPinnedDispatcher(pinnedIp); + const response = await retryWithBackoff( async () => { const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, { signal, + dispatcher, headers: { Accept: 'text/markdown, text/plain;q=0.9, application/json;q=0.9, text/html;q=0.8, application/pdf;q=0.7, video/*;q=0.7, */*;q=0.5', @@ -769,7 +800,7 @@ Response: ${rawResponseText}`; const userPrompt = this.params.prompt!; const { validUrls } = parsePrompt(userPrompt); - const { toFetch, skipped } = this.filterAndValidateUrls(validUrls); + const { toFetch, skipped } = await this.filterAndValidateUrls(validUrls); // If everything was skipped, fail early if (toFetch.length === 0 && skipped.length > 0) { diff --git a/packages/core/src/utils/fetch.test.ts b/packages/core/src/utils/fetch.test.ts index c455a6b2804..1d97bb68dbd 100644 --- a/packages/core/src/utils/fetch.test.ts +++ b/packages/core/src/utils/fetch.test.ts @@ -29,7 +29,7 @@ vi.mock('node:dns/promises', () => ({ // Import after mocks are established const { isPrivateIp, - isPrivateIpAsync, + resolveAndValidateDns, isAddressPrivate, fetchWithTimeout, setGlobalProxy, @@ -138,9 +138,9 @@ describe('fetch utils', () => { }); }); - describe('isPrivateIpAsync', () => { + describe('resolveAndValidateDns', () => { it('should identify private IPs directly', async () => { - expect(await isPrivateIpAsync('http://10.0.0.1/')).toBe(true); + expect(await resolveAndValidateDns('http://10.0.0.1/')).toEqual([]); }); it('should identify domains resolving to private IPs', async () => { @@ -150,7 +150,7 @@ describe('fetch utils', () => { options: LookupAllOptions, ) => Promise, ).mockImplementation(async () => [{ address: '10.0.0.1', family: 4 }]); - expect(await isPrivateIpAsync('http://malicious.com/')).toBe(true); + expect(await resolveAndValidateDns('http://malicious.com/')).toEqual([]); }); it('should identify domains resolving to public IPs as non-private', async () => { @@ -160,18 +160,20 @@ describe('fetch utils', () => { options: LookupAllOptions, ) => Promise, ).mockImplementation(async () => [{ address: '8.8.8.8', family: 4 }]); - expect(await isPrivateIpAsync('http://google.com/')).toBe(false); + expect(await resolveAndValidateDns('http://google.com/')).toEqual([ + '8.8.8.8', + ]); }); - it('should throw error if DNS resolution fails (fail closed)', async () => { + it('should return empty array if DNS resolution fails (fail closed)', async () => { vi.mocked(dnsPromises.lookup).mockRejectedValue(new Error('DNS Error')); - await expect(isPrivateIpAsync('http://unreachable.com/')).rejects.toThrow( - 'Failed to verify if URL resolves to private IP', + expect(await resolveAndValidateDns('http://unreachable.com/')).toEqual( + [], ); }); - it('should return false for invalid URLs instead of throwing verification error', async () => { - expect(await isPrivateIpAsync('not-a-url')).toBe(false); + it('should return empty array for invalid URLs', async () => { + expect(await resolveAndValidateDns('not-a-url')).toEqual([]); }); }); diff --git a/packages/core/src/utils/fetch.ts b/packages/core/src/utils/fetch.ts index e0c34cf4f69..c8f4a6d58cd 100644 --- a/packages/core/src/utils/fetch.ts +++ b/packages/core/src/utils/fetch.ts @@ -6,6 +6,7 @@ import { getErrorMessage, isAbortError } from './errors.js'; import { URL } from 'node:url'; +import type { Dispatcher } from 'undici'; import { Agent, EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; import ipaddr from 'ipaddr.js'; import { lookup } from 'node:dns/promises'; @@ -145,26 +146,52 @@ export function isAddressPrivate(address: string): boolean { } /** - * Checks if a URL resolves to a private IP address. + * Creates an undici dispatcher that pins DNS resolution to a specific IP + * address while leaving the request URL and TLS SNI/hostname untouched. + * + * This is used to prevent DNS rebinding: the hostname is resolved and + * validated once by the caller (see resolveAndValidateDns), and the + * connection is then forced to that already-validated address instead of + * re-resolving the hostname at connect time. */ -export async function isPrivateIpAsync(url: string): Promise { +export function createPinnedDispatcher(pinnedIp: string): Dispatcher { + return new Agent({ + connect: { + lookup: ( + _hostname: string, + _options: unknown, + callback: ( + err: NodeJS.ErrnoException | null, + addresses: Array<{ address: string; family: number }>, + ) => void, + ) => { + callback(null, [ + { address: pinnedIp, family: pinnedIp.includes(':') ? 6 : 4 }, + ]); + }, + }, + }) as Dispatcher; +} + +export async function resolveAndValidateDns(url: string): Promise { try { const parsedUrl = new URL(url); const hostname = parsedUrl.hostname; if (isLoopbackHost(hostname)) { - return false; + return []; } const addresses = await lookup(hostname, { all: true }); - return addresses.some((addr) => isAddressPrivate(addr.address)); + if (addresses.some((addr) => isAddressPrivate(addr.address))) { + return []; + } + return addresses.map((addr) => addr.address); } catch (error) { if (error instanceof TypeError) { - return false; + return []; } - throw new Error('Failed to verify if URL resolves to private IP', { - cause: error, - }); + return []; // Fail closed on DNS errors } } @@ -190,7 +217,7 @@ export function createSafeProxyAgent(proxyUrl: string): EnvHttpProxyAgent { export async function fetchWithTimeout( url: string, timeout: number, - options?: RequestInit, + options?: RequestInit & { dispatcher?: Dispatcher }, ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout);