Skip to content

Commit 4128c8f

Browse files
committed
feat(a2a): add DNS rebinding protection and robust URL reconstruction
1 parent 9aaecc3 commit 4128c8f

5 files changed

Lines changed: 534 additions & 99 deletions

File tree

packages/core/src/agents/a2a-client-manager.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ vi.mock('../utils/debugLogger.js', () => ({
3838
}));
3939

4040
vi.mock('node:dns/promises', () => ({
41-
lookup: vi.fn().mockResolvedValue([{ address: '93.184.216.34' }]),
41+
lookup: vi.fn().mockImplementation(async (hostname, options) => {
42+
const addr = { address: '93.184.216.34', family: 4 };
43+
if (options?.all) {
44+
return [addr];
45+
}
46+
return addr;
47+
}),
4248
}));
4349

4450
describe('A2AClientManager', () => {
@@ -415,9 +421,11 @@ describe('A2AClientManager', () => {
415421
it('should throw if a domain resolves to a private IP (DNS SSRF protection)', async () => {
416422
const maliciousDomainUrl =
417423
'http://malicious.com/.well-known/agent-card.json';
418-
vi.mocked(lookup).mockResolvedValueOnce([
419-
{ address: '10.0.0.1', family: 4 },
420-
]);
424+
425+
vi.mocked(lookup).mockImplementationOnce(async () =>
426+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
427+
[{ address: '10.0.0.1', family: 4 }] as any
428+
);
421429

422430
await expect(
423431
manager.loadAgent('dns-ssrf-agent', maliciousDomainUrl),
@@ -463,5 +471,24 @@ describe('A2AClientManager', () => {
463471
undefined,
464472
);
465473
});
474+
475+
it('should correctly handle URLs with standardPath in the hash fragment', async () => {
476+
const fragmentUrl =
477+
'http://localhost:9001/.well-known/agent-card.json#.well-known/agent-card.json';
478+
const resolverInstance = {
479+
resolve: vi.fn().mockResolvedValue({ name: 'test' } as AgentCard),
480+
};
481+
vi.mocked(sdkClient.DefaultAgentCardResolver).mockReturnValue(
482+
resolverInstance as unknown as sdkClient.DefaultAgentCardResolver,
483+
);
484+
485+
await manager.loadAgent('fragment-agent', fragmentUrl);
486+
487+
// Should correctly ignore the hash fragment and use the path from the URL object
488+
expect(resolverInstance.resolve).toHaveBeenCalledWith(
489+
'http://localhost:9001/',
490+
'.well-known/agent-card.json',
491+
);
492+
});
466493
});
467494
});

packages/core/src/agents/a2a-client-manager.ts

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,45 +12,55 @@ import type {
1212
TaskStatusUpdateEvent,
1313
TaskArtifactUpdateEvent,
1414
} from '@a2a-js/sdk';
15+
import type { AuthenticationHandler, Client } from '@a2a-js/sdk/client';
1516
import {
1617
ClientFactory,
1718
ClientFactoryOptions,
1819
DefaultAgentCardResolver,
19-
RestTransportFactory,
2020
JsonRpcTransportFactory,
21-
type AuthenticationHandler,
21+
RestTransportFactory,
2222
createAuthenticatingFetchWithRetry,
23-
type Client,
2423
} from '@a2a-js/sdk/client';
2524
import { GrpcTransportFactory } from '@a2a-js/sdk/client/grpc';
2625
import { v4 as uuidv4 } from 'uuid';
2726
import { Agent as UndiciAgent } from 'undici';
28-
import { getGrpcCredentials, normalizeAgentCard } from './a2aUtils.js';
29-
import { isPrivateIpAsync } from '../utils/fetch.js';
27+
import {
28+
getGrpcChannelOptions,
29+
getGrpcCredentials,
30+
normalizeAgentCard,
31+
pinUrlToIp,
32+
} from './a2aUtils.js';
33+
import { isPrivateIpAsync, safeLookup } from '../utils/fetch.js';
3034
import { debugLogger } from '../utils/debugLogger.js';
3135

36+
/**
37+
* Result of sending a message, which can be a full message, a task,
38+
* or an incremental status/artifact update.
39+
*/
40+
export type SendMessageResult =
41+
| Message
42+
| Task
43+
| TaskStatusUpdateEvent
44+
| TaskArtifactUpdateEvent;
45+
3246
// Remote agents can take 10+ minutes (e.g. Deep Research).
3347
// Use a dedicated dispatcher so the global 5-min timeout isn't affected.
3448
const A2A_TIMEOUT = 1800000; // 30 minutes
3549
const a2aDispatcher = new UndiciAgent({
3650
headersTimeout: A2A_TIMEOUT,
3751
bodyTimeout: A2A_TIMEOUT,
52+
connect: {
53+
// SSRF protection at the connection level (mitigates DNS rebinding)
54+
lookup: safeLookup,
55+
},
3856
});
3957
const a2aFetch: typeof fetch = (input, init) =>
4058
// @ts-expect-error The `dispatcher` property is a Node.js extension to fetch not present in standard types.
4159
fetch(input, { ...init, dispatcher: a2aDispatcher });
4260

43-
export type SendMessageResult =
44-
| Message
45-
| Task
46-
| TaskStatusUpdateEvent
47-
| TaskArtifactUpdateEvent;
48-
4961
/**
50-
* Orchestrates communication with A2A agents.
51-
*
52-
* This manager handles agent discovery, card caching, and client lifecycle.
53-
* It provides a unified messaging interface using the standard A2A SDK.
62+
* Orchestrates communication with remote A2A agents.
63+
* Manages protocol negotiation, authentication, and transport selection.
5464
*/
5565
export class A2AClientManager {
5666
private static instance: A2AClientManager;
@@ -83,7 +93,7 @@ export class A2AClientManager {
8393
/**
8494
* Loads an agent by fetching its AgentCard and caches the client.
8595
* @param name The name to assign to the agent.
86-
* @param agentCardUrl The full URL to the agent's card.
96+
* @param agentCardUrl {string} The full URL to the agent's card.
8797
* @param authHandler Optional authentication handler to use for this agent.
8898
* @returns The loaded AgentCard.
8999
*/
@@ -100,16 +110,26 @@ export class A2AClientManager {
100110
const resolver = new DefaultAgentCardResolver({ fetchImpl });
101111
const agentCard = await this.resolveAgentCard(name, agentCardUrl, resolver);
102112

113+
// Pin URL to IP to prevent DNS rebinding for gRPC (connection-level SSRF protection)
114+
const { pinnedUrl, hostname } = await pinUrlToIp(agentCard.url, name);
115+
103116
// Configure standard SDK client for tool registration and discovery
104117
const clientOptions = ClientFactoryOptions.createFrom(
105118
ClientFactoryOptions.default,
106119
{
107120
transports: [
108121
new RestTransportFactory({ fetchImpl }),
109122
new JsonRpcTransportFactory({ fetchImpl }),
110-
new GrpcTransportFactory({
111-
grpcChannelCredentials: getGrpcCredentials(agentCard.url),
112-
}),
123+
new GrpcTransportFactory(
124+
// gRPC transport options extension is supported by runtime but missing in SDK types
125+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
126+
{
127+
target: pinnedUrl,
128+
grpcChannelCredentials: getGrpcCredentials(agentCard.url),
129+
grpcChannelOptions: getGrpcChannelOptions(hostname),
130+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
131+
} as any,
132+
),
113133
],
114134
cardResolver: resolver,
115135
},
@@ -166,7 +186,7 @@ export class A2AClientManager {
166186
try {
167187
yield* client.sendMessageStream(messageParams, {
168188
signal: options?.signal,
169-
});
189+
}) as AsyncIterable<SendMessageResult>;
170190
} catch (error: unknown) {
171191
const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`;
172192
if (error instanceof Error) {
@@ -275,7 +295,14 @@ export class A2AClientManager {
275295
if (parsedUrl.pathname.endsWith(standardPath)) {
276296
// Correctly split the URL into baseUrl and standard path
277297
path = standardPath;
278-
baseUrl = url.substring(0, url.lastIndexOf(standardPath));
298+
// Reconstruct baseUrl from parsed components to avoid issues with hashes or query params.
299+
parsedUrl.pathname = parsedUrl.pathname.substring(
300+
0,
301+
parsedUrl.pathname.lastIndexOf(standardPath),
302+
);
303+
parsedUrl.search = '';
304+
parsedUrl.hash = '';
305+
baseUrl = parsedUrl.toString();
279306
}
280307
} catch (e) {
281308
throw new Error(`Invalid agent card URL: ${url}`, { cause: e });

0 commit comments

Comments
 (0)