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:
commit
0a2c8e25bf
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user