diff --git a/workspace-server/internal/handlers/a2a_proxy.go b/workspace-server/internal/handlers/a2a_proxy.go index 13c46641..934af511 100644 --- a/workspace-server/internal/handlers/a2a_proxy.go +++ b/workspace-server/internal/handlers/a2a_proxy.go @@ -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 { diff --git a/workspace-server/internal/handlers/a2a_proxy_test.go b/workspace-server/internal/handlers/a2a_proxy_test.go index dcad98e2..81733ad5 100644 --- a/workspace-server/internal/handlers/a2a_proxy_test.go +++ b/workspace-server/internal/handlers/a2a_proxy_test.go @@ -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) {