-
Notifications
You must be signed in to change notification settings - Fork 315
fix(control-plane): authorize execution note writes(#420) #575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,12 +2,14 @@ package handlers | |
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "net/http" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/Agent-Field/agentfield/control-plane/internal/events" | ||
| "github.com/Agent-Field/agentfield/control-plane/internal/server/middleware" | ||
| "github.com/Agent-Field/agentfield/control-plane/pkg/types" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
|
|
@@ -20,6 +22,22 @@ type ExecutionNoteStorage interface { | |
| GetExecutionEventBus() *events.ExecutionEventBus | ||
| } | ||
|
|
||
| type executionNoteDIDDocumentLookup interface { | ||
| GetDIDDocument(ctx context.Context, did string) (*types.DIDDocumentRecord, error) | ||
| } | ||
|
|
||
| type executionNoteAgentDIDLister interface { | ||
| ListAgentDIDs(ctx context.Context) ([]*types.AgentDIDInfo, error) | ||
| } | ||
|
|
||
| type executionNoteAuthorizationError struct { | ||
| message string | ||
| } | ||
|
|
||
| func (e *executionNoteAuthorizationError) Error() string { | ||
| return e.message | ||
| } | ||
|
|
||
| // AddNoteRequest represents the request body for adding a note to an execution | ||
| type AddNoteRequest struct { | ||
| Message string `json:"message" binding:"required"` | ||
|
|
@@ -76,12 +94,21 @@ func AddExecutionNoteHandler(storageProvider ExecutionNoteStorage) gin.HandlerFu | |
| } | ||
|
|
||
| // Update the execution with the new note | ||
| ctx := context.Background() | ||
| ctx := c.Request.Context() | ||
| callerAgentID, err := executionNoteCallerAgentID(ctx, c, storageProvider) | ||
| if err != nil { | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to resolve caller identity: %v", err)}) | ||
| return | ||
| } | ||
|
|
||
| var runID string | ||
| updated, err := storageProvider.UpdateExecutionRecord(ctx, executionID, func(execution *types.Execution) (*types.Execution, error) { | ||
| if execution == nil { | ||
| return nil, fmt.Errorf("execution with ID %s not found", executionID) | ||
| } | ||
| if err := ensureExecutionNoteOwnership(callerAgentID, execution); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Store run ID for SSE event (run_id is the workflow ID equivalent) | ||
| runID = execution.RunID | ||
|
|
@@ -99,6 +126,14 @@ func AddExecutionNoteHandler(storageProvider ExecutionNoteStorage) gin.HandlerFu | |
| }) | ||
|
|
||
| if err != nil { | ||
| var authzErr *executionNoteAuthorizationError | ||
| if errors.As(err, &authzErr) { | ||
| c.JSON(http.StatusForbidden, gin.H{ | ||
| "error": "execution_ownership_mismatch", | ||
| "message": authzErr.message, | ||
| }) | ||
| return | ||
| } | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to add note: %v", err)}) | ||
| return | ||
| } | ||
|
|
@@ -130,6 +165,71 @@ func AddExecutionNoteHandler(storageProvider ExecutionNoteStorage) gin.HandlerFu | |
| } | ||
| } | ||
|
|
||
| func ensureExecutionNoteOwnership(callerAgentID string, execution *types.Execution) error { | ||
| ownerAgentID := strings.TrimSpace(execution.AgentNodeID) | ||
| if ownerAgentID == "" { | ||
| return &executionNoteAuthorizationError{message: "execution owner is required to add notes"} | ||
| } | ||
|
|
||
| if callerAgentID == "" { | ||
| return &executionNoteAuthorizationError{message: "caller agent identity is required to add notes to this execution"} | ||
| } | ||
| if callerAgentID != ownerAgentID { | ||
| return &executionNoteAuthorizationError{message: "this execution does not belong to the requesting agent"} | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func executionNoteCallerAgentID(ctx context.Context, c *gin.Context, storageProvider ExecutionNoteStorage) (string, error) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 [CRITICAL] Fix-F4-expose-F1 trap: enabling DID auth activates divergent resolution paths Operator fixing F4 (default-config bypass) by setting
No transactional sync between SQL and in-memory registry. During register/unregister race windows, the two stores can return different Fix: (1) Unify DID resolution into a single source of truth. (2) Add startup assertion when write routes registered but no auth enabled. (3) Annotate resolved identity with provenance for audit logging.
🤖 Reviewed by AgentField PR-AF |
||
| if callerDID := strings.TrimSpace(middleware.GetVerifiedCallerDID(c)); callerDID != "" { | ||
| return resolveExecutionNoteAgentIDByDID(ctx, storageProvider, callerDID) | ||
| } | ||
|
|
||
| if callerID, exists := c.Get(string(middleware.CallerAgentIDKey)); exists { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [IMPORTANT] Type-unsafe
Attack path: an authenticated request hits a future middleware bug that writes a non-string to the key → identity silently discarded → handler reads attacker-controlled headers → ownership check passes if attacker knows the execution ID. Proof: Fix: (1) Typed setter
🤖 Reviewed by AgentField PR-AF |
||
| if id, ok := callerID.(string); ok { | ||
| if id = strings.TrimSpace(id); id != "" { | ||
| return id, nil | ||
| } | ||
| } | ||
| } | ||
| if agentID := strings.TrimSpace(c.GetHeader("X-Caller-Agent-ID")); agentID != "" { | ||
| return agentID, nil | ||
| } | ||
| if agentID := strings.TrimSpace(c.GetHeader("X-Agent-Node-ID")); agentID != "" { | ||
| return agentID, nil | ||
| } | ||
|
|
||
| return "", nil | ||
| } | ||
|
|
||
| func resolveExecutionNoteAgentIDByDID(ctx context.Context, storageProvider ExecutionNoteStorage, callerDID string) (string, error) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [IMPORTANT] DID resolution reads differently-named columns from independent tables
Both compared against The naming divergence ( Fix: (1) Document the equivalence on
🤖 Reviewed by AgentField PR-AF |
||
| if lookup, ok := storageProvider.(executionNoteDIDDocumentLookup); ok { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM]
Fix: (a) sentinel
🤖 Reviewed by AgentField PR-AF |
||
| if record, err := lookup.GetDIDDocument(ctx, callerDID); err == nil && record != nil { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] Revoked DIDs still pass ownership check At line 208, A caller holding a revoked DID can still resolve to their old agent ID and pass ownership checks on former executions. Fix: After successful retrieval,
🤖 Reviewed by AgentField PR-AF |
||
| return strings.TrimSpace(record.AgentID), nil | ||
| } | ||
| } | ||
|
|
||
| lister, ok := storageProvider.(executionNoteAgentDIDLister) | ||
| if !ok { | ||
| return "", nil | ||
| } | ||
| agentDIDs, err := lister.ListAgentDIDs(ctx) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to resolve caller DID: %w", err) | ||
| } | ||
| for _, info := range agentDIDs { | ||
| if info == nil { | ||
| continue | ||
| } | ||
| if strings.TrimSpace(info.DID) == callerDID { | ||
| return strings.TrimSpace(info.AgentNodeID), nil | ||
| } | ||
| } | ||
|
|
||
| return "", nil | ||
| } | ||
|
|
||
| // GetExecutionNotesHandler handles GET /api/v1/executions/:execution_id/notes | ||
| // Retrieves notes for a specific execution with optional tag filtering | ||
| func GetExecutionNotesHandler(storageProvider ExecutionNoteStorage) gin.HandlerFunc { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [IMPORTANT] Read path leaks execution notes — no ownership check PR fixes IDOR on write path but
PR mentions this is "deliberately NOT modified" but provides no rationale, no tests, no code comment. Likely oversight. Fix: Mirror write path — resolve caller via
🤖 Reviewed by AgentField PR-AF |
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 [CRITICAL] Raw-header fallback becomes sole identity source under default config
executionNoteCallerAgentIDhas 3 tiers: (1) verified DID, (2)CallerAgentIDKeycontext value, (3) rawX-Caller-Agent-ID/X-Agent-Node-IDheaders. Tiers 1 & 2 are config-gated. Under defaults (APIKey="",did_auth_enabled=false), both are skipped — tier 3 accepts attacker-controlled headers with zero validation, flowing directly toensureExecutionNoteOwnership.Evidence:
routes_middleware.go:77— DID middleware not installed when disabledauth.go:26-28— APIKeyAuth no-ops whenAPIKey=="", never sets context keyexecution_notes.go:196-201— raw header read with no validationauth.go:118-124), so the fallback is either dead code or active bypass — never legitimateFix: Delete the raw-header fallback. Add a startup assertion in
routes_middleware.gothat refuses to register write routes when both auth methods are disabled.Compound Analysis· confidence 95%🤖 Reviewed by AgentField PR-AF