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

View File

@@ -399,5 +399,31 @@ describe('A2AClientManager', () => {
'.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,
);
});
});
});

View File

@@ -26,6 +26,7 @@ import { GrpcTransportFactory } from '@a2a-js/sdk/client/grpc';
import { v4 as uuidv4 } from 'uuid';
import { Agent as UndiciAgent } from 'undici';
import { getGrpcCredentials, normalizeAgentCard } from './a2aUtils.js';
import { isPrivateIp } from '../utils/fetch.js';
import { debugLogger } from '../utils/debugLogger.js';
// Remote agents can take 10+ minutes (e.g. Deep Research).
@@ -97,7 +98,7 @@ export class A2AClientManager {
const fetchImpl = this.getFetchImpl(authHandler);
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
const clientOptions = ClientFactoryOptions.createFrom(
@@ -247,8 +248,10 @@ export class A2AClientManager {
/**
* Resolves and normalizes an agent card from a given URL.
* 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(
agentName: string,
url: string,
resolver: DefaultAgentCardResolver,
): Promise<AgentCard> {
@@ -256,10 +259,26 @@ export class A2AClientManager {
let baseUrl = url;
let path: string | undefined;
if (baseUrl.includes(standardPath)) {
const parts = baseUrl.split(standardPath);
baseUrl = parts[0] || '';
path = standardPath;
// Validate URL to prevent SSRF
if (isPrivateIp(url)) {
// Local/private IPs are allowed ONLY for localhost for testing.
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);

View File

@@ -22,6 +22,7 @@ setGlobalDispatcher(
const PRIVATE_IP_RANGES = [
/^10\./,
/^127\./,
/^169\.254\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^::1$/,