@@ -6,6 +6,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
66import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' ;
77import { AzethError , AZETH_CONTRACTS , TOKENS , formatTokenAmount } from '@azeth/common' ;
88import type { AzethKit } from '@azeth/sdk' ;
9+ import { secureFetch , type SecureFetchGuard } from '@azeth/sdk' ;
910import { createClient , resolveChain , validateAddress } from '../utils/client.js' ;
1011import { resolveAddress } from '../utils/resolve.js' ;
1112import { success , error , handleError , guardianRequiredError } from '../utils/response.js' ;
@@ -174,6 +175,66 @@ async function validateExternalUrl(urlStr: string): Promise<ValidatedUrl> {
174175 return { url : urlStr , pinnedIPv4 } ;
175176}
176177
178+ /** Lazily load undici's `Agent` (an OPTIONAL dependency). undici is Node's built-in HTTP
179+ * engine, but it must be installed as a package to construct a custom connection
180+ * dispatcher. If unavailable, the connection pin is skipped — validate + redirect-refusal
181+ * still apply (graceful degradation). */
182+ let agentCtorPromise : Promise < ( new ( opts : unknown ) => unknown ) | undefined > | undefined ;
183+ function loadAgentCtor ( ) : Promise < ( new ( opts : unknown ) => unknown ) | undefined > {
184+ if ( ! agentCtorPromise ) {
185+ // `as string` keeps this a runtime-only dynamic import so the build does not require
186+ // undici's type declarations to be present.
187+ agentCtorPromise = import ( 'undici' as string )
188+ . then ( ( m : { Agent ?: new ( opts : unknown ) => unknown } ) => m . Agent )
189+ . catch ( ( ) => undefined ) ;
190+ }
191+ return agentCtorPromise ;
192+ }
193+
194+ /** Build a connection dispatcher pinned to the already-validated public IPv4 address(es).
195+ * Closes the DNS-rebinding TOCTOU: after validateExternalUrl confirms the host resolves to
196+ * public IPs, the actual connection is pinned to those IPs and cannot be re-resolved to a
197+ * private target. TLS SNI / certificate validation still uses the URL hostname. Returns
198+ * undefined when there is nothing to pin (e.g. IPv6-only host) or undici is unavailable —
199+ * the request then proceeds with validate-only protection. (F9) */
200+ /** A net.LookupFunction-compatible resolver that pins DNS resolution to the given
201+ * already-validated public IPv4 address(es), ignoring the hostname. This is what makes
202+ * the connection immune to DNS rebinding: no matter what an attacker-controlled DNS
203+ * record returns at connect time, the socket only ever connects to the pre-validated IPs.
204+ * Supports both the single-address and `{ all }` callback forms. Exported for tests. (F9) */
205+ export function createPinnedLookup (
206+ pinnedIPv4 : string [ ] ,
207+ ) : (
208+ hostname : string ,
209+ opts : { all ?: boolean } | undefined ,
210+ cb : ( err : Error | null , address : unknown , family ?: number ) => void ,
211+ ) => void {
212+ return ( _hostname , opts , cb ) : void => {
213+ if ( opts ?. all ) cb ( null , pinnedIPv4 . map ( ( address ) => ( { address, family : 4 } ) ) ) ;
214+ else cb ( null , pinnedIPv4 [ 0 ] , 4 ) ;
215+ } ;
216+ }
217+
218+ async function buildPinnedDispatcher ( pinnedIPv4 : string [ ] ) : Promise < unknown | undefined > {
219+ if ( pinnedIPv4 . length === 0 ) return undefined ;
220+ const AgentCtor = await loadAgentCtor ( ) ;
221+ if ( ! AgentCtor ) return undefined ;
222+ try {
223+ return new AgentCtor ( { connect : { lookup : createPinnedLookup ( pinnedIPv4 ) } } ) ;
224+ } catch {
225+ return undefined ;
226+ }
227+ }
228+
229+ /** SSRF guard injected into the SDK's secureFetch for untrusted URLs (x402 targets,
230+ * registry-discovered endpoints): validates the URL (HTTPS, non-private/reserved IP,
231+ * DNS-resolvable) and returns a connection dispatcher pinned to the validated IPs.
232+ * Throws (AzethError) to BLOCK the request. (F9) */
233+ const secureGuard : SecureFetchGuard = async ( urlStr : string ) => {
234+ const validated = await validateExternalUrl ( urlStr ) ;
235+ return { dispatcher : await buildPinnedDispatcher ( validated . pinnedIPv4 ) } ;
236+ } ;
237+
177238/**
178239 * Apply smart account selection from the `smartAccount` tool parameter.
179240 * Accepts "#N" (1-based index from azeth_accounts) or a full address.
@@ -278,6 +339,8 @@ export function registerPaymentTools(server: McpServer): void {
278339 method : args . method ,
279340 body : args . body ,
280341 maxAmount,
342+ // Validate + pin every connection hop and refuse credential-carrying redirects. (F9)
343+ secureGuard,
281344 } ) ;
282345
283346 // F-5/H-1: Stream response body with size limit. Uses Uint8Array chunks
@@ -430,6 +493,10 @@ export function registerPaymentTools(server: McpServer): void {
430493 maxAmount,
431494 minReputation : args . minReputation ,
432495 autoFeedback : args . autoFeedback ?? false ,
496+ // SSRF guard for discovered (third-party-published) endpoints — validates + pins
497+ // every connection hop, the same protection azeth_pay applies to user URLs.
498+ // Throwing makes smartFetch402 skip that service and try the next one. (F9)
499+ secureGuard,
433500 } ) ;
434501
435502 // Stream response body with size limit (same pattern as azeth_pay)
@@ -721,10 +788,13 @@ export function registerPaymentTools(server: McpServer): void {
721788 try {
722789 client = await createClient ( args . chain ) ;
723790
724- // Fetch the URL to get 402 response with agreement terms
725- const response = await fetch ( validated . url , {
791+ // Fetch the URL to get the 402 response with agreement terms. Untrusted URL →
792+ // validate + pin the connection and refuse redirects (no credential leakage). (F9)
793+ const response = await secureFetch ( validated . url , {
726794 method : 'GET' ,
727795 signal : AbortSignal . timeout ( 15_000 ) ,
796+ guard : secureGuard ,
797+ guardedRedirect : 'error' ,
728798 } ) ;
729799
730800 if ( response . status !== 402 ) {
0 commit comments