Compare commits

...

2 Commits

Author SHA1 Message Date
Devv
36b99b9773 test(server): add negative tests for cross-workspace subscription and upload
Address PR review feedback:
- Add tests verifying cross-workspace user_id is rejected with 403 on
  subscribe and unsubscribe
- Add test verifying upload with foreign workspace_id is rejected with 403
- Make isWorkspaceEntity explicitly enumerate "member"/"agent" and reject
  unknown user types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:18:16 +08:00
Devv
c29fe37dce fix(server): validate workspace membership for subscription targets and file uploads
Closes MED-1 (cross-workspace subscription injection) and MED-2 (file upload
missing workspace member validation) from the security audit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:08:29 +08:00
5 changed files with 129 additions and 2 deletions

View File

@@ -171,6 +171,11 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
// If workspace context is available, create an attachment record.
if workspaceID != "" {
if _, err := h.getWorkspaceMember(r.Context(), userID, workspaceID); err != nil {
writeError(w, http.StatusForbidden, "not a member of this workspace")
return
}
uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID)
params := db.CreateAttachmentParams{

View File

@@ -0,0 +1,48 @@
package handler
import (
"bytes"
"context"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
)
type mockStorage struct{}
func (m *mockStorage) Upload(_ context.Context, key string, _ []byte, _ string, _ string) (string, error) {
return fmt.Sprintf("https://cdn.example.com/%s", key), nil
}
func (m *mockStorage) Delete(_ context.Context, _ string) {}
func (m *mockStorage) DeleteKeys(_ context.Context, _ []string) {}
func (m *mockStorage) KeyFromURL(rawURL string) string { return rawURL }
func TestUploadFileForeignWorkspace(t *testing.T) {
origStorage := testHandler.Storage
testHandler.Storage = &mockStorage{}
defer func() { testHandler.Storage = origStorage }()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "test.txt")
if err != nil {
t.Fatal(err)
}
part.Write([]byte("hello world"))
writer.Close()
foreignWorkspaceID := "00000000-0000-0000-0000-000000000099"
req := httptest.NewRequest("POST", "/api/upload-file", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", testUserID)
req.Header.Set("X-Workspace-ID", foreignWorkspaceID)
w := httptest.NewRecorder()
testHandler.UploadFile(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("UploadFile with foreign workspace: expected 403, got %d: %s", w.Code, w.Body.String())
}
}

View File

@@ -246,6 +246,24 @@ func (h *Handler) requireWorkspaceRole(w http.ResponseWriter, r *http.Request, w
return member, true
}
// isWorkspaceEntity checks whether a user_id belongs to the given workspace,
// as either a member or an agent depending on userType.
func (h *Handler) isWorkspaceEntity(ctx context.Context, userType, userID, workspaceID string) bool {
switch userType {
case "member":
_, err := h.getWorkspaceMember(ctx, userID, workspaceID)
return err == nil
case "agent":
_, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: parseUUID(userID),
WorkspaceID: parseUUID(workspaceID),
})
return err == nil
default:
return false
}
}
func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issueID string) (db.Issue, bool) {
if _, ok := requireUserID(w, r); !ok {
return db.Issue{}, false

View File

@@ -76,6 +76,12 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
targetUserType = *req.UserType
}
workspaceID := uuidToString(issue.WorkspaceID)
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
return
}
err := h.Queries.AddIssueSubscriber(r.Context(), db.AddIssueSubscriberParams{
IssueID: issue.ID,
UserType: targetUserType,
@@ -87,7 +93,6 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
return
}
workspaceID := uuidToString(issue.WorkspaceID)
callerID := requestUserID(r)
subActorType, subActorID := h.resolveActor(r, callerID, workspaceID)
h.publish(protocol.EventSubscriberAdded, workspaceID, subActorType, subActorID, map[string]any{
@@ -125,6 +130,12 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
targetUserType = *req.UserType
}
workspaceID := uuidToString(issue.WorkspaceID)
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
return
}
err := h.Queries.RemoveIssueSubscriber(r.Context(), db.RemoveIssueSubscriberParams{
IssueID: issue.ID,
UserType: targetUserType,
@@ -135,7 +146,6 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
return
}
workspaceID := uuidToString(issue.WorkspaceID)
callerID := requestUserID(r)
unsubActorType, unsubActorID := h.resolveActor(r, callerID, workspaceID)
h.publish(protocol.EventSubscriberRemoved, workspaceID, unsubActorType, unsubActorID, map[string]any{

View File

@@ -174,6 +174,52 @@ func TestSubscriberAPI(t *testing.T) {
}
})
t.Run("SubscribeCrossWorkspaceUser", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
foreignUserID := "00000000-0000-0000-0000-000000000099"
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", map[string]any{
"user_id": foreignUserID,
"user_type": "member",
})
req = withURLParam(req, "id", issueID)
testHandler.SubscribeToIssue(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("SubscribeToIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
}
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
IssueID: parseUUID(issueID),
UserType: "member",
UserID: parseUUID(foreignUserID),
})
if err != nil {
t.Fatalf("IsIssueSubscriber: %v", err)
}
if subscribed {
t.Fatal("cross-workspace user should NOT be subscribed in DB")
}
})
t.Run("UnsubscribeCrossWorkspaceUser", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
foreignUserID := "00000000-0000-0000-0000-000000000099"
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", map[string]any{
"user_id": foreignUserID,
"user_type": "member",
})
req = withURLParam(req, "id", issueID)
testHandler.UnsubscribeFromIssue(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("UnsubscribeFromIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
}
})
t.Run("ListAfterUnsubscribe", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)