mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-26 04:54:25 -07:00
feat(a2a): implement robust URL parsing and SSRF protection for agent cards
This commit is contained in:
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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$/,
|
||||||
|
|||||||
Reference in New Issue
Block a user