fix(security): C2 from #169 — reject spoofed source_id in activity.Report

Cherry-picks the one genuinely new fix from #169 after confirming the
rest of that PR is already covered on main (C1/C3/C5 by wsAuth group,
C6 by #94+#119 SSRF blocklist, C4 ownership by existing WHERE filter).

Pre-existing middleware (WorkspaceAuth on /workspaces/:id/* sub-routes)
proves the caller owns the :id path param. But the body field
source_id was never validated — a workspace authenticated for its own
/activity endpoint could still attribute logs to a different workspace
by setting source_id=<foreign UUID>. Rejected with 403 now.

No schema change, no new middleware. 4-line handler delta. Closes the
only real gap in #169; #169 itself will be closed as superseded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-15 11:15:08 -07:00
parent 519d478ea2
commit a04f7c288d

View File

@ -329,7 +329,18 @@ func (h *ActivityHandler) Report(c *gin.Context) {
if reqBody == nil {
reqBody = body.Metadata
}
// C2 (from #169) — source_id spoof defense. WorkspaceAuth middleware
// already proves the caller owns :id, but that check doesn't cover the
// body field. Without this guard, workspace A authenticated for its own
// /activity endpoint could still set source_id=<workspace B's UUID> in
// the payload and attribute the log to B. Reject any body where
// source_id is non-empty AND differs from the authenticated workspace.
// Empty source_id falls through to the default-to-self branch below.
sourceID := body.SourceID
if sourceID != "" && sourceID != workspaceID {
c.JSON(http.StatusForbidden, gin.H{"error": "source_id must match authenticated workspace"})
return
}
if sourceID == "" {
sourceID = workspaceID
}