From fb60eecba0481e9525c6e8e9a6822e49ce53fc7d Mon Sep 17 00:00:00 2001 From: Alisa Novikova <62909685+alisa-alisa@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:00:25 -0800 Subject: [PATCH] feat(a2a): add deep SSRF validation for all transport URLs in agent card --- .../src/agents/a2a-client-manager.test.ts | 19 ++++++++++ .../core/src/agents/a2a-client-manager.ts | 38 ++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agents/a2a-client-manager.test.ts b/packages/core/src/agents/a2a-client-manager.test.ts index 4bba06a7b4..484a68314b 100644 --- a/packages/core/src/agents/a2a-client-manager.test.ts +++ b/packages/core/src/agents/a2a-client-manager.test.ts @@ -407,6 +407,25 @@ describe('A2AClientManager', () => { ); }); + it('should throw if a public agent card contains a private transport URL (Deep SSRF protection)', async () => { + const publicUrl = 'https://public.agent.com/card.json'; + const resolverInstance = { + resolve: vi.fn().mockResolvedValue({ + ...mockAgentCard, + url: 'http://192.168.1.1/api', // Malicious private transport in public card + } as AgentCard), + }; + vi.mocked(sdkClient.DefaultAgentCardResolver).mockReturnValue( + resolverInstance as unknown as sdkClient.DefaultAgentCardResolver, + ); + + await expect( + manager.loadAgent('malicious-agent', publicUrl), + ).rejects.toThrow( + /contains transport URL pointing to private IP range: http:\/\/192.168.1.1\/api/, + ); + }); + it('should handle URLs where .well-known appears in the domain/subdomain', async () => { const trickyUrl = 'http://.well-known/agent-card.json.attacker.com/my-agent'; diff --git a/packages/core/src/agents/a2a-client-manager.ts b/packages/core/src/agents/a2a-client-manager.ts index 5e1527b3f6..4619a4c4fe 100644 --- a/packages/core/src/agents/a2a-client-manager.ts +++ b/packages/core/src/agents/a2a-client-manager.ts @@ -282,6 +282,42 @@ export class A2AClientManager { } const rawCard = await resolver.resolve(baseUrl, path); - return normalizeAgentCard(rawCard); + const agentCard = normalizeAgentCard(rawCard); + + // Deep validation of all transport URLs within the card to prevent SSRF + this.validateAgentCardUrls(agentName, agentCard); + + return agentCard; + } + + /** + * Validates all URLs (top-level and interfaces) within an AgentCard for SSRF. + */ + private validateAgentCardUrls(agentName: string, card: AgentCard): void { + const urlsToValidate = [card.url]; + if (card.additionalInterfaces) { + for (const intf of card.additionalInterfaces) { + if (intf.url) urlsToValidate.push(intf.url); + } + } + + for (const url of urlsToValidate) { + if (!url) continue; + + // Ensure URL has a scheme for the parser (gRPC often provides raw IP:port) + const validationUrl = url.includes('://') ? url : `http://${url}`; + + if (isPrivateIp(validationUrl)) { + const parsed = new URL(validationUrl); + if ( + parsed.hostname !== 'localhost' && + parsed.hostname !== '127.0.0.1' + ) { + throw new Error( + `Refusing to load agent '${agentName}': contains transport URL pointing to private IP range: ${url}.`, + ); + } + } + } } }