db6e0d0bc324eebee999aa459610f32875891604

Author
Kieran Klukas <kieran@dunkirk.sh>
Committer
Kieran Klukas <kieran@dunkirk.sh>
Date

Message

fix: stop granting read access to no-access collaborators on private repos

A collaborator's access was raised to at least the server's anonymous
access level before the private repository check ran. With the default
anonymous read-only setting, a user added as a no-access collaborator on
a private repository could clone it, and on servers that allow anonymous
writes a read-only collaborator could push. A collaborator's own level is
now final on private repositories. (GHSA-pmgj-8mr6-9rff)

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5

Diff

  1diff --git a/pkg/backend/user.go b/pkg/backend/user.go
  2index 736842f9fdddbc7fc83501ad02da1c5bd5f063f9..aa93aeb10177eb4500c6f8a9bbe3dd3e3ef7814a 100644
  3--- a/pkg/backend/user.go
  4+++ b/pkg/backend/user.go
  5@@ -70,9 +70,12 @@ func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user prot
  6 		}
  7 
  8 		// If the user is a collaborator, they have return their access level.
  9+		// On a private repository the collaborator's level is authoritative;
 10+		// the anonymous floor only applies to public repositories, otherwise
 11+		// a no-access collaborator would be granted read access.
 12 		collabAccess, isCollab, _ := d.IsCollaborator(ctx, repo, username)
 13 		if isCollab {
 14-			if anon > collabAccess {
 15+			if !r.IsPrivate() && anon > collabAccess {
 16 				return anon
 17 			}
 18 			return collabAccess
 19diff --git a/pkg/backend/user_test.go b/pkg/backend/user_test.go
 20new file mode 100644
 21index 0000000000000000000000000000000000000000..7140725d1e06fc8f5ff2193ccfcd251e97d71f38
 22--- /dev/null
 23+++ b/pkg/backend/user_test.go
 24@@ -0,0 +1,69 @@
 25+package backend
 26+
 27+import (
 28+	"context"
 29+	"testing"
 30+
 31+	"github.com/charmbracelet/soft-serve/pkg/access"
 32+	"github.com/charmbracelet/soft-serve/pkg/db"
 33+	"github.com/charmbracelet/soft-serve/pkg/proto"
 34+	"github.com/matryer/is"
 35+)
 36+
 37+// TestAccessLevelCollaboratorPrivateRepo verifies that the anonymous access
 38+// floor does not raise a collaborator's explicit level on a private
 39+// repository, while still applying on public repositories.
 40+func TestAccessLevelCollaboratorPrivateRepo(t *testing.T) {
 41+	tests := []struct {
 42+		name    string
 43+		private bool
 44+		anon    access.AccessLevel
 45+		collab  access.AccessLevel
 46+		want    access.AccessLevel
 47+	}{
 48+		{"private no-access collab, anon read-only", true, access.ReadOnlyAccess, access.NoAccess, access.NoAccess},
 49+		{"private read-only collab, anon read-write", true, access.ReadWriteAccess, access.ReadOnlyAccess, access.ReadOnlyAccess},
 50+		{"private read-write collab, anon read-only", true, access.ReadOnlyAccess, access.ReadWriteAccess, access.ReadWriteAccess},
 51+		{"public no-access collab, anon read-only", false, access.ReadOnlyAccess, access.NoAccess, access.ReadOnlyAccess},
 52+		{"public read-only collab, anon read-write", false, access.ReadWriteAccess, access.ReadOnlyAccess, access.ReadWriteAccess},
 53+		{"public no-access collab, anon no-access", false, access.NoAccess, access.NoAccess, access.NoAccess},
 54+	}
 55+
 56+	for _, tt := range tests {
 57+		t.Run(tt.name, func(t *testing.T) {
 58+			is := is.New(t)
 59+			be, _ := newTestBackend(t)
 60+			ctx := context.Background()
 61+
 62+			is.NoErr(be.SetAnonAccess(ctx, tt.anon))
 63+
 64+			_, err := be.CreateRepository(ctx, "repo", nil, proto.RepositoryOptions{Private: tt.private})
 65+			is.NoErr(err)
 66+
 67+			user, err := be.CreateUser(ctx, "collab", proto.UserOptions{})
 68+			is.NoErr(err)
 69+			is.NoErr(be.db.TransactionContext(ctx, func(tx *db.Tx) error {
 70+				return be.store.AddCollabByUsernameAndRepo(ctx, tx, "collab", "repo", tt.collab)
 71+			}))
 72+
 73+			is.Equal(be.AccessLevelForUser(ctx, "repo", user), tt.want)
 74+		})
 75+	}
 76+}
 77+
 78+// TestAccessLevelPrivateRepoNonCollaborator verifies that a signed-in user
 79+// who is not a collaborator has no access to a private repository.
 80+func TestAccessLevelPrivateRepoNonCollaborator(t *testing.T) {
 81+	is := is.New(t)
 82+	be, _ := newTestBackend(t)
 83+	ctx := context.Background()
 84+
 85+	_, err := be.CreateRepository(ctx, "repo", nil, proto.RepositoryOptions{Private: true})
 86+	is.NoErr(err)
 87+
 88+	user, err := be.CreateUser(ctx, "stranger", proto.UserOptions{})
 89+	is.NoErr(err)
 90+
 91+	is.Equal(be.AccessLevelForUser(ctx, "repo", user), access.NoAccess)
 92+	is.Equal(be.AccessLevelForUser(ctx, "repo", nil), access.NoAccess)
 93+}
 94diff --git a/testscript/testdata/repo-collab.txtar b/testscript/testdata/repo-collab.txtar
 95index 9a1f75eed8d5008aeaec87d26e2ba61f0cf26260..3cc4edf8ea5767ced59f79a75c9eef675d20f282 100644
 96--- a/testscript/testdata/repo-collab.txtar
 97+++ b/testscript/testdata/repo-collab.txtar
 98@@ -35,6 +35,17 @@ stderr '.*already exists.*'
 99 # a placeholder to reset stderr
100 soft help
101 
102+# a no-access collaborator cannot read a private repo (GHSA-pmgj-8mr6-9rff)
103+soft repo create secret -p
104+! usoft repo description secret
105+stderr '.*repository not found.*'
106+soft repo collab add secret foo no-access
107+! usoft repo description secret
108+stderr '.*repository not found.*'
109+soft repo collab remove secret foo
110+soft repo collab add secret foo read-only
111+usoft repo description secret
112+
113 # stop the server
114 [windows] stopserver
115 [windows] ! stderr .