feat(a2a): implement robust URL parsing and SSRF protection for agent cards

This commit is contained in:
Alisa Novikova
2026-03-05 16:51:19 -08:00
parent dae8d85a18
commit a42fdee8a8
3 changed files with 51 additions and 5 deletions
@@ -399,5 +399,31 @@ describe('A2AClientManager', () => {
'.well-known/agent-card.json', '.well-known/agent-card.json',
); );
}); });
it('should throw if a remote agent uses a private IP (SSRF protection)', async () => {
const privateUrl = 'http://169.254.169.254/.well-known/agent-card.json';
await expect(manager.loadAgent('ssrf-agent', privateUrl)).rejects.toThrow(
/Refusing to load agent 'ssrf-agent' from private IP range/,
);
});
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';
const resolverInstance = {
resolve: vi.fn().mockResolvedValue({ name: 'test' } as AgentCard),
};
vi.mocked(sdkClient.DefaultAgentCardResolver).mockReturnValue(
resolverInstance as unknown as sdkClient.DefaultAgentCardResolver,
);
await manager.loadAgent('tricky-agent', trickyUrl);
// Should treat the whole thing as baseUrl since it doesn't end with standardPath
expect(resolverInstance.resolve).toHaveBeenCalledWith(
trickyUrl,
undefined,
);
});
}); });
}); });
+24 -5
View File
@@ -26,6 +26,7 @@ import { GrpcTransportFactory } from '@a2a-js/sdk/client/grpc';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Agent as UndiciAgent } from 'undici'; import { Agent as UndiciAgent } from 'undici';
import { getGrpcCredentials, normalizeAgentCard } from './a2aUtils.js'; import { getGrpcCredentials, normalizeAgentCard } from './a2aUtils.js';
import { isPrivateIp } from '../utils/fetch.js';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
// Remote agents can take 10+ minutes (e.g. Deep Research). // Remote agents can take 10+ minutes (e.g. Deep Research).
@@ -97,7 +98,7 @@ export class A2AClientManager {
const fetchImpl = this.getFetchImpl(authHandler); const fetchImpl = this.getFetchImpl(authHandler);
const resolver = new DefaultAgentCardResolver({ fetchImpl }); const resolver = new DefaultAgentCardResolver({ fetchImpl });
const agentCard = await this.resolveAgentCard(agentCardUrl, resolver); const agentCard = await this.resolveAgentCard(name, agentCardUrl, resolver);
// Configure standard SDK client for tool registration and discovery // Configure standard SDK client for tool registration and discovery
const clientOptions = ClientFactoryOptions.createFrom( const clientOptions = ClientFactoryOptions.createFrom(
@@ -247,8 +248,10 @@ export class A2AClientManager {
/** /**
* Resolves and normalizes an agent card from a given URL. * Resolves and normalizes an agent card from a given URL.
* Handles splitting the URL if it already contains the standard .well-known path. * Handles splitting the URL if it already contains the standard .well-known path.
* Also performs basic SSRF validation to prevent internal IP access.
*/ */
private async resolveAgentCard( private async resolveAgentCard(
agentName: string,
url: string, url: string,
resolver: DefaultAgentCardResolver, resolver: DefaultAgentCardResolver,
): Promise<AgentCard> { ): Promise<AgentCard> {
@@ -256,10 +259,26 @@ export class A2AClientManager {
let baseUrl = url; let baseUrl = url;
let path: string | undefined; let path: string | undefined;
if (baseUrl.includes(standardPath)) { // Validate URL to prevent SSRF
const parts = baseUrl.split(standardPath); if (isPrivateIp(url)) {
baseUrl = parts[0] || ''; // Local/private IPs are allowed ONLY for localhost for testing.
path = standardPath; const parsed = new URL(url);
if (parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
throw new Error(
`Refusing to load agent '${agentName}' from private IP range: ${url}. Remote agents must use public URLs.`,
);
}
}
try {
const parsedUrl = new URL(url);
if (parsedUrl.pathname.endsWith(standardPath)) {
// Correctly split the URL into baseUrl and standard path
path = standardPath;
baseUrl = url.substring(0, url.lastIndexOf(standardPath));
}
} catch (e) {
throw new Error(`Invalid agent card URL: ${url}`, { cause: e });
} }
const rawCard = await resolver.resolve(baseUrl, path); const rawCard = await resolver.resolve(baseUrl, path);
+1
View File
@@ -22,6 +22,7 @@ setGlobalDispatcher(
const PRIVATE_IP_RANGES = [ const PRIVATE_IP_RANGES = [
/^10\./, /^10\./,
/^127\./, /^127\./,
/^169\.254\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./, /^192\.168\./,
/^::1$/, /^::1$/,