Merge pull request #2097 from Molecule-AI/fix/ssrf-dispatch-a2a-1483

fix(a2a): isSafeURL guard inside dispatchA2A (closes #1483)
This commit is contained in:
Hongming Wang 2026-04-26 14:21:26 +00:00 committed by GitHub
commit 0a2c8e25bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 43 additions and 0 deletions

View File

@ -483,6 +483,16 @@ func normalizeA2APayload(body []byte) ([]byte, string, *proxyA2AError) {
// canvas (callerID == "") = 5 min, agent-to-agent = 30 min. Callers can
// override via the X-Timeout header (applied to ctx upstream in ProxyA2A).
func (h *WorkspaceHandler) dispatchA2A(ctx context.Context, agentURL string, body []byte, callerID string) (*http.Response, context.CancelFunc, error) {
// #1483 SSRF defense-in-depth: the primary call path through
// proxyA2ARequest → resolveAgentURL already validates via isSafeURL
// (a2a_proxy.go:424), but adding the check here closes the gap for
// any future code path that calls dispatchA2A directly without
// going through resolveAgentURL. Wrapping as proxyDispatchBuildError
// keeps the caller's error-classification path unchanged — the same
// shape it already produces a 500 for.
if err := isSafeURL(agentURL); err != nil {
return nil, nil, &proxyDispatchBuildError{err: err}
}
forwardCtx := context.WithoutCancel(ctx)
var cancel context.CancelFunc
if _, hasDeadline := ctx.Deadline(); !hasDeadline {

View File

@ -1155,6 +1155,39 @@ func TestDispatchA2A_ContextDeadline_NoCancelAdded(t *testing.T) {
}
}
// TestDispatchA2A_RejectsUnsafeURL is the #1483 defense-in-depth
// regression. setupTestDB disables SSRF for normal tests so existing
// dispatchA2A unit tests can hit httptest.NewServer (loopback) — we
// re-enable it here to verify the new in-function isSafeURL guard.
// Production callers go through resolveAgentURL which already
// validates; this test pins that dispatchA2A is now safe even when
// called directly by a future caller that skips resolveAgentURL.
func TestDispatchA2A_RejectsUnsafeURL(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
restoreSSRF := setSSRFCheckForTest(true)
t.Cleanup(restoreSSRF)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// Cloud metadata IP — must be rejected before any HTTP call goes out.
_, cancel, err := handler.dispatchA2A(
context.Background(),
"http://169.254.169.254/latest/meta-data/",
[]byte(`{}`),
"",
)
if cancel != nil {
cancel()
t.Error("cancel must be nil when the URL is rejected pre-request")
}
if err == nil {
t.Fatal("expected SSRF rejection error, got nil")
}
if _, ok := err.(*proxyDispatchBuildError); !ok {
t.Errorf("expected *proxyDispatchBuildError (caller maps to 500), got %T: %v", err, err)
}
}
// --- handleA2ADispatchError ---
func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {