07d47364a8d7aa09b6a11fa895127d6f2d7b2276
- Author
- Kieran Klukas <kieran@dunkirk.sh>
- Committer
- GitHub <noreply@github.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/pkg/backend/webhooks.go b/pkg/backend/webhooks.go
2index 2fd90c2a505cd0e5b4f9526f6f685f5dae7f6ae0..e60f5439cbd9ca91c068953280618669f25423d8 100644
3--- a/pkg/backend/webhooks.go
4+++ b/pkg/backend/webhooks.go
5@@ -200,13 +200,21 @@ func (b *Backend) DeleteWebhook(ctx context.Context, repo proto.Repository, id i
6 })
7 }
8
9-// ListWebhookDeliveries lists webhook deliveries for a webhook.
10-func (b *Backend) ListWebhookDeliveries(ctx context.Context, id int64) ([]webhook.Delivery, error) {
11+// ListWebhookDeliveries lists webhook deliveries for a webhook belonging to
12+// the given repository.
13+//
14+// The webhook is resolved scoped to the repository, so a caller authorized
15+// only for repo A cannot read deliveries of a webhook owned by repo B.
16+func (b *Backend) ListWebhookDeliveries(ctx context.Context, repo proto.Repository, id int64) ([]webhook.Delivery, error) {
17 dbx := db.FromContext(ctx)
18 datastore := store.FromContext(ctx)
19
20 var deliveries []models.WebhookDelivery
21 if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
22+ if _, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id); err != nil {
23+ return db.WrapError(err)
24+ }
25+
26 var err error
27 deliveries, err = datastore.ListWebhookDeliveriesByWebhookID(ctx, tx, id)
28 if err != nil {
29@@ -265,13 +273,23 @@ func (b *Backend) RedeliverWebhookDelivery(ctx context.Context, repo proto.Repos
30 return webhook.SendWebhook(ctx, wh, webhook.Event(delivery.Event), payload)
31 }
32
33-// WebhookDelivery returns a webhook delivery.
34-func (b *Backend) WebhookDelivery(ctx context.Context, webhookID int64, id uuid.UUID) (webhook.Delivery, error) {
35+// WebhookDelivery returns a webhook delivery for a webhook belonging to the
36+// given repository.
37+//
38+// The webhook is resolved scoped to the repository, so a caller authorized
39+// only for repo A cannot read a delivery of a webhook owned by repo B.
40+// Deliveries expose the full request URL, headers, and body, so this scoping
41+// is what keeps them from leaking across repositories.
42+func (b *Backend) WebhookDelivery(ctx context.Context, repo proto.Repository, webhookID int64, id uuid.UUID) (webhook.Delivery, error) {
43 dbx := db.FromContext(ctx)
44 datastore := store.FromContext(ctx)
45
46 var delivery webhook.Delivery
47 if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
48+ if _, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), webhookID); err != nil {
49+ return db.WrapError(err)
50+ }
51+
52 d, err := datastore.GetWebhookDeliveryByID(ctx, tx, webhookID, id)
53 if err != nil {
54 return db.WrapError(err)
55diff --git a/pkg/proto/errors.go b/pkg/proto/errors.go
56index 1ff116e5ef91dc543b2c3303c7e7e3230e41837d..ef126a3546aeb226b292d2039a3ab4b0af7001a6 100644
57--- a/pkg/proto/errors.go
58+++ b/pkg/proto/errors.go
59@@ -25,4 +25,7 @@ var (
60 ErrCollaboratorNotFound = errors.New("collaborator not found")
61 // ErrCollaboratorExist is returned when a collaborator already exists.
62 ErrCollaboratorExist = errors.New("collaborator already exists")
63+ // ErrExceedsAccessLevel is returned when an action would grant or revoke
64+ // access above the caller's own access level.
65+ ErrExceedsAccessLevel = errors.New("cannot change access above your own access level")
66 )
67diff --git a/pkg/ssh/cmd/auth_test.go b/pkg/ssh/cmd/auth_test.go
68new file mode 100644
69index 0000000000000000000000000000000000000000..c9253a017b87e1c3e392a8fe382b5bd16913a765
70--- /dev/null
71+++ b/pkg/ssh/cmd/auth_test.go
72@@ -0,0 +1,170 @@
73+package cmd
74+
75+import (
76+ "errors"
77+ "testing"
78+
79+ "github.com/charmbracelet/soft-serve/pkg/access"
80+ "github.com/charmbracelet/soft-serve/pkg/proto"
81+ "github.com/matryer/is"
82+)
83+
84+// TestGlobalCommandsIgnoreRepositoryAccess is the regression test for the
85+// privilege escalation where a non-admin could run global admin commands by
86+// creating a repository named after the command's first argument. `user
87+// set-admin victim true` used to be authorized by the caller's admin access
88+// to a repository they happened to own called "victim".
89+func TestGlobalCommandsIgnoreRepositoryAccess(t *testing.T) {
90+ is := is.New(t)
91+ ctx, be := newAuthTestContext(t)
92+ attackerCtx := withUser(t, ctx, be, "attacker", false)
93+
94+ // The attacker owns a repository named "victim", so they have
95+ // AdminAccess *to that repository*.
96+ attacker := proto.UserFromContext(attackerCtx)
97+ _, err := be.CreateRepository(attackerCtx, "victim", attacker, proto.RepositoryOptions{})
98+ is.NoErr(err)
99+ is.Equal(be.AccessLevelForUser(attackerCtx, "victim", attacker), access.AdminAccess)
100+
101+ // Repository admin access must not authorize global user commands.
102+ for _, args := range [][]string{
103+ {"set-admin", "victim", "true"},
104+ {"create", "victim2"},
105+ {"delete", "victim"},
106+ {"list"},
107+ {"info", "victim"},
108+ {"set-username", "victim", "victim3"},
109+ } {
110+ if err := runUser(t, attackerCtx, args...); !errors.Is(err, proto.ErrUnauthorized) {
111+ t.Errorf("user %v: expected ErrUnauthorized, got %v", args, err)
112+ }
113+ }
114+
115+ // The attacker must not have become an admin.
116+ reloaded, err := be.User(ctx, "attacker")
117+ is.NoErr(err)
118+ is.Equal(reloaded.IsAdmin(), false)
119+}
120+
121+// TestGlobalCommandsIgnoreAnonAdminAccess covers the variant that needs no
122+// repository at all: with anon-access set to admin-access,
123+// AccessLevelForUser returns AdminAccess to any authenticated user for a
124+// nonexistent repository name.
125+func TestGlobalCommandsIgnoreAnonAdminAccess(t *testing.T) {
126+ is := is.New(t)
127+ ctx, be := newAuthTestContext(t)
128+ is.NoErr(be.SetAnonAccess(ctx, access.AdminAccess))
129+
130+ attackerCtx := withUser(t, ctx, be, "attacker", false)
131+ attacker := proto.UserFromContext(attackerCtx)
132+ is.Equal(be.AccessLevelForUser(attackerCtx, "nonexistent", attacker), access.AdminAccess)
133+
134+ err := runUser(t, attackerCtx, "set-admin", "attacker", "true")
135+ if !errors.Is(err, proto.ErrUnauthorized) {
136+ t.Fatalf("expected ErrUnauthorized, got %v", err)
137+ }
138+
139+ reloaded, err := be.User(ctx, "attacker")
140+ is.NoErr(err)
141+ is.Equal(reloaded.IsAdmin(), false)
142+}
143+
144+// TestUserSubcommandsAreGatedByParent verifies the gate lives on the `user`
145+// parent command, so subcommands cannot be left unprotected by omission.
146+func TestUserSubcommandsAreGatedByParent(t *testing.T) {
147+ is := is.New(t)
148+ c := UserCommand()
149+ is.True(c.PersistentPreRunE != nil)
150+
151+ for _, sub := range c.Commands() {
152+ if sub.PersistentPreRunE != nil {
153+ t.Errorf("subcommand %q sets its own PersistentPreRunE; the parent gate is authoritative", sub.Name())
154+ }
155+ }
156+}
157+
158+// TestSettingsSubcommandsAreGatedByParent is the settings counterpart.
159+func TestSettingsSubcommandsAreGatedByParent(t *testing.T) {
160+ is := is.New(t)
161+ c := SettingsCommand()
162+ is.True(c.PersistentPreRunE != nil)
163+
164+ for _, sub := range c.Commands() {
165+ if sub.PersistentPreRunE != nil {
166+ t.Errorf("subcommand %q sets its own PersistentPreRunE; the parent gate is authoritative", sub.Name())
167+ }
168+ }
169+}
170+
171+// TestCollabAddCannotExceedCallerAccess verifies a read-write collaborator
172diff --git a/pkg/ssh/cmd/branch.go b/pkg/ssh/cmd/branch.go
173index 85474606ed8f285f12f96aa6520ceced1ee67c8c..008222c2e60c76d491f53cf3d89f77c17b529f47 100644
174--- a/pkg/ssh/cmd/branch.go
175+++ b/pkg/ssh/cmd/branch.go
176@@ -88,7 +88,7 @@ func branchDefaultCommand() *cobra.Command {
177
178 cmd.Println(head.Name().Short())
179 case 2:
180- if err := checkIfCollab(cmd, args); err != nil {
181+ if err := checkIfRepoCollab(cmd, args); err != nil {
182 return err
183 }
184
185diff --git a/pkg/ssh/cmd/cmd.go b/pkg/ssh/cmd/cmd.go
186index 2862c5e923da445da6ccd659f5bcb996ed95f8eb..e94f1274a20da6458264db98faebe393e5124090 100644
187--- a/pkg/ssh/cmd/cmd.go
188+++ b/pkg/ssh/cmd/cmd.go
189@@ -1,6 +1,7 @@
190 package cmd
191
192 import (
193+ "context"
194 "fmt"
195 "net/url"
196 "strings"
197@@ -110,17 +111,8 @@ func CommandName(args []string) string {
198 }
199
200 func checkIfReadable(cmd *cobra.Command, args []string) error {
201- var repo string
202- if len(args) > 0 {
203- repo = args[0]
204- }
205-
206 ctx := cmd.Context()
207- be := backend.FromContext(ctx)
208- rn := utils.SanitizeRepo(repo)
209- user := proto.UserFromContext(ctx)
210- auth := be.AccessLevelForUser(cmd.Context(), rn, user)
211- if auth < access.ReadOnlyAccess {
212+ if repoAccessLevel(ctx, repoArg(args)) < access.ReadOnlyAccess {
213 return proto.ErrRepoNotFound
214 }
215 return nil
216@@ -137,50 +129,82 @@ func IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
217 return false
218 }
219
220-func checkIfAdmin(cmd *cobra.Command, args []string) error {
221- var repo string
222- if len(args) > 0 {
223- repo = args[0]
224- }
225-
226- ctx := cmd.Context()
227+// isServerAdmin reports whether the caller is a server administrator: either
228+// their public key is one of the configured admin keys, or their account has
229+// the admin flag set.
230+//
231+// This is the single source of truth for "is this caller a server admin".
232+// Every authorization gate defers to it so the definition cannot drift.
233+func isServerAdmin(ctx context.Context) bool {
234 cfg := config.FromContext(ctx)
235- be := backend.FromContext(ctx)
236- rn := utils.SanitizeRepo(repo)
237 pk := sshutils.PublicKeyFromContext(ctx)
238 if IsPublicKeyAdmin(cfg, pk) {
239- return nil
240+ return true
241 }
242
243 user := proto.UserFromContext(ctx)
244- if user == nil {
245- return proto.ErrUnauthorized
246+ return user != nil && user.IsAdmin()
247+}
248+
249+// repoArg returns the repository name from a repo-scoped command's arguments.
250+// Repo-scoped commands always take the repository as their first argument.
251+func repoArg(args []string) string {
252+ if len(args) == 0 {
253+ return ""
254 }
255+ return utils.SanitizeRepo(args[0])
256+}
257
258- if user.IsAdmin() {
259- return nil
260+// repoAccessLevel returns the caller's access level for the named repository.
261+// The repository name must already be sanitized, e.g. via repoArg.
262+func repoAccessLevel(ctx context.Context, repo string) access.AccessLevel {
263+ be := backend.FromContext(ctx)
264+ return be.AccessLevelForUser(ctx, repo, proto.UserFromContext(ctx))
265+}
266+
267+// checkIfServerAdmin is the authorization gate for global (non-repo-scoped)
268+// commands such as `user` and `settings`. It allows server admins only.
269+//
270+// Unlike checkIfRepoAdmin, it never consults repository access levels, so it
271+// cannot be bypassed by creating a repository whose name matches the command
272+// argument. Attach it to the parent command so that every subcommand,
273+// including ones added later, is gated by default.
274+func checkIfServerAdmin(cmd *cobra.Command, _ []string) error {
275+ if !isServerAdmin(cmd.Context()) {
276+ return proto.ErrUnauthorized
277 }
278+ return nil
279+}
280
281- auth := be.AccessLevelForUser(cmd.Context(), rn, user)
282- if auth >= access.AdminAccess {
283+// checkIfRepoAdmin is the authorization gate for repo-scoped commands that
284+// require admin access to the repository named by the first argument.
285+//
286+// Only use this on commands whose first argument is a repository name. For
287+// global commands, use checkIfServerAdmin instead.
288+func checkIfRepoAdmin(cmd *cobra.Command, args []string) error {
289diff --git a/pkg/ssh/cmd/collab.go b/pkg/ssh/cmd/collab.go
290index df1e059f5ee6820fb9e7774c733dcf9ca37ad7ef..77db6db4a0ef17a3aeebbfbb788ac545b60f7dbf 100644
291--- a/pkg/ssh/cmd/collab.go
292+++ b/pkg/ssh/cmd/collab.go
293@@ -1,8 +1,13 @@
294 package cmd
295
296 import (
297+ "context"
298+ "errors"
299+
300 "github.com/charmbracelet/soft-serve/pkg/access"
301 "github.com/charmbracelet/soft-serve/pkg/backend"
302+ "github.com/charmbracelet/soft-serve/pkg/db"
303+ "github.com/charmbracelet/soft-serve/pkg/proto"
304 "github.com/spf13/cobra"
305 )
306
307@@ -22,6 +27,67 @@ func collabCommand() *cobra.Command {
308 return cmd
309 }
310
311+// checkCollabGrant reports whether the caller may set a collaborator's access
312+// level on a repository to level.
313+//
314+// Server admins may grant anything. Everyone else is bounded by their own
315+// access level on the repository, so a read-write collaborator cannot mint an
316+// admin-access collaborator and escalate beyond their own permissions.
317+func checkCollabGrant(ctx context.Context, repo string, level access.AccessLevel) error {
318+ if isServerAdmin(ctx) {
319+ return nil
320+ }
321+
322+ if proto.UserFromContext(ctx) == nil {
323+ return proto.ErrUnauthorized
324+ }
325+
326+ caller := repoAccessLevel(ctx, repo)
327+ if level > caller {
328+ return proto.ErrExceedsAccessLevel
329+ }
330+
331+ return nil
332+}
333+
334+// checkCollabDemote reports whether the caller may remove or overwrite an
335+// existing collaborator on a repository.
336+//
337+// Removal is a privileged change in the same way granting is: without this,
338+// a read-write collaborator could remove an admin-access collaborator and
339+// then re-add them at a lower level, demoting someone above them.
340+func checkCollabDemote(ctx context.Context, repo string, username string) error {
341+ if isServerAdmin(ctx) {
342+ return nil
343+ }
344+
345+ if proto.UserFromContext(ctx) == nil {
346+ return proto.ErrUnauthorized
347+ }
348+
349+ be := backend.FromContext(ctx)
350+ current, isCollab, err := be.IsCollaborator(ctx, repo, username)
351+ if err != nil {
352+ // A missing row just means the user is not a collaborator yet, which
353+ // is the common case when adding one. Any other error is real and
354+ // must fail closed.
355+ if !errors.Is(err, db.ErrRecordNotFound) {
356+ return err
357+ }
358+ return nil
359+ }
360+
361+ if !isCollab {
362+ return nil
363+ }
364+
365+ if current > repoAccessLevel(ctx, repo) {
366+ return proto.ErrExceedsAccessLevel
367+ }
368+
369+ return nil
370+}
371+
372 func collabAddCommand() *cobra.Command {
373 cmd := &cobra.Command{
374 Use: "add REPOSITORY USERNAME [LEVEL]",
375@@ -32,7 +98,7 @@ func collabAddCommand() *cobra.Command {
376 RunE: func(cmd *cobra.Command, args []string) error {
377 ctx := cmd.Context()
378 be := backend.FromContext(ctx)
379- repo := args[0]
380+ repo := repoArg(args)
381 username := args[1]
382 level := access.ReadWriteAccess
383 if len(args) > 2 {
384@@ -42,6 +108,14 @@ func collabAddCommand() *cobra.Command {
385 }
386 }
387
388+ if err := checkCollabGrant(ctx, repo, level); err != nil {
389+ return err
390+ }
391+
392+ if err := checkCollabDemote(ctx, repo, username); err != nil {
393diff --git a/pkg/ssh/cmd/create.go b/pkg/ssh/cmd/create.go
394index fa8ea3001a6686f3b1826bf0682efc42e639128f..f4291521fcf03f10bdf278e974d449a8bd88e15f 100644
395--- a/pkg/ssh/cmd/create.go
396+++ b/pkg/ssh/cmd/create.go
397@@ -20,7 +20,7 @@ func createCommand() *cobra.Command {
398 Use: "create REPOSITORY",
399 Short: "Create a new repository",
400 Args: cobra.ExactArgs(1),
401- PersistentPreRunE: checkIfCollab,
402+ PersistentPreRunE: checkIfRepoCollab,
403 RunE: func(cmd *cobra.Command, args []string) error {
404 ctx := cmd.Context()
405 cfg := config.FromContext(ctx)
406diff --git a/pkg/ssh/cmd/description.go b/pkg/ssh/cmd/description.go
407index 3e11c61f4e9d60e8725f8ce41cbbbef3f8a56726..f8cf95f7d1d6e198e0cad8e91220ac8840a304a1 100644
408--- a/pkg/ssh/cmd/description.go
409+++ b/pkg/ssh/cmd/description.go
410@@ -27,7 +27,7 @@ func descriptionCommand() *cobra.Command {
411
412 cmd.Println(desc)
413 default:
414- if err := checkIfCollab(cmd, args); err != nil {
415+ if err := checkIfRepoCollab(cmd, args); err != nil {
416 return err
417 }
418 if err := be.SetDescription(ctx, rn, strings.Join(args[1:], " ")); err != nil {
419diff --git a/pkg/ssh/cmd/helpers_test.go b/pkg/ssh/cmd/helpers_test.go
420new file mode 100644
421index 0000000000000000000000000000000000000000..a92699bdba8d859ba41b52154f35ae9fce5750c7
422--- /dev/null
423+++ b/pkg/ssh/cmd/helpers_test.go
424@@ -0,0 +1,90 @@
425+package cmd
426+
427+import (
428+ "bytes"
429+ "context"
430+ "testing"
431+
432+ "github.com/charmbracelet/soft-serve/pkg/backend"
433+ "github.com/charmbracelet/soft-serve/pkg/config"
434+ "github.com/charmbracelet/soft-serve/pkg/db"
435+ "github.com/charmbracelet/soft-serve/pkg/db/migrate"
436+ "github.com/charmbracelet/soft-serve/pkg/proto"
437+ "github.com/charmbracelet/soft-serve/pkg/store"
438+ "github.com/charmbracelet/soft-serve/pkg/store/database"
439+ "github.com/matryer/is"
440+ _ "modernc.org/sqlite"
441+)
442+
443+// newAuthTestContext returns a context wired with a config and a real
444+// migrated SQLite-backed backend, plus the backend itself so tests can set up
445+// users and repositories.
446+//
447+// No user is attached to the returned context; use withUser to authenticate
448+// as somebody.
449+func newAuthTestContext(t *testing.T) (context.Context, *backend.Backend) {
450+ t.Helper()
451+ is := is.New(t)
452+ ctx := context.Background()
453+
454+ dp := t.TempDir()
455+ cfg := config.DefaultConfig()
456+ cfg.DataPath = dp
457+ cfg.DB.Driver = "sqlite"
458+ cfg.DB.DataSource = dp + "/test.db"
459+
460+ ctx = config.WithContext(ctx, cfg)
461+ dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
462+ is.NoErr(err)
463+ t.Cleanup(func() { dbx.Close() }) //nolint:errcheck
464+
465+ is.NoErr(migrate.Migrate(ctx, dbx))
466+ ctx = db.WithContext(ctx, dbx)
467+ dbstore := database.New(ctx, dbx)
468+ ctx = store.WithContext(ctx, dbstore)
469+ be := backend.New(ctx, cfg, dbx, dbstore)
470+ ctx = backend.WithContext(ctx, be)
471+
472+ return ctx, be
473+}
474+
475+// withUser creates a user and returns a context authenticated as them.
476+func withUser(t *testing.T, ctx context.Context, be *backend.Backend, username string, admin bool) context.Context {
477+ t.Helper()
478+ is := is.New(t)
479+ user, err := be.CreateUser(ctx, username, proto.UserOptions{Admin: admin})
480+ is.NoErr(err)
481+ return proto.WithUserContext(ctx, user)
482+}
483+
484+// runRepo runs a `repo` subcommand and returns only its error.
485+func runRepo(t *testing.T, ctx context.Context, args ...string) error {
486+ t.Helper()
487+ _, _, err := runRepoOutput(t, ctx, args...)
488+ return err
489+}
490+
491+// runRepoOutput runs a `repo` subcommand and returns its stdout and stderr
492+// alongside the error, so tests can assert that sensitive values were not
493+// printed even when a command fails.
494+func runRepoOutput(t *testing.T, ctx context.Context, args ...string) (stdout, stderr string, err error) {
495+ t.Helper()
496+ c := RepoCommand()
497+ var outBuf, errBuf bytes.Buffer
498+ c.SetOut(&outBuf)
499+ c.SetErr(&errBuf)
500+ c.SetArgs(args)
501+ err = c.ExecuteContext(ctx)
502+ return outBuf.String(), errBuf.String(), err
503+}
504+
505+// runUser runs a `user` subcommand and returns only its error.
506+func runUser(t *testing.T, ctx context.Context, args ...string) error {
507+ t.Helper()
508+ c := UserCommand()
509+ var outBuf, errBuf bytes.Buffer
510+ c.SetOut(&outBuf)
511+ c.SetErr(&errBuf)
512+ c.SetArgs(args)
513+ return c.ExecuteContext(ctx)
514+}
515diff --git a/pkg/ssh/cmd/hidden.go b/pkg/ssh/cmd/hidden.go
516index 82f544307681985935cd9f365a936bf43c096ceb..567ec1e7c3709c49dfa61fb6053d5b45d98434c7 100644
517--- a/pkg/ssh/cmd/hidden.go
518+++ b/pkg/ssh/cmd/hidden.go
519@@ -25,7 +25,7 @@ func hiddenCommand() *cobra.Command {
520
521 cmd.Println(hidden)
522 case 2:
523- if err := checkIfCollab(cmd, args); err != nil {
524+ if err := checkIfRepoCollab(cmd, args); err != nil {
525 return err
526 }
527
528diff --git a/pkg/ssh/cmd/import.go b/pkg/ssh/cmd/import.go
529index cb71f3c673df7dd325aaf60bd733130bdf99c0ea..70ee49b76cffc50fb3dd479ea57e2307ab1ca701 100644
530--- a/pkg/ssh/cmd/import.go
531+++ b/pkg/ssh/cmd/import.go
532@@ -23,7 +23,7 @@ func importCommand() *cobra.Command {
533 Use: "import REPOSITORY REMOTE",
534 Short: "Import a new repository from remote",
535 Args: cobra.ExactArgs(2),
536- PersistentPreRunE: checkIfCollab,
537+ PersistentPreRunE: checkIfRepoCollab,
538 RunE: func(cmd *cobra.Command, args []string) error {
539 ctx := cmd.Context()
540 be := backend.FromContext(ctx)
541diff --git a/pkg/ssh/cmd/mirror.go b/pkg/ssh/cmd/mirror.go
542index c3e7131262a0aa3589b41dbc5f723782c8df7f3c..ccdc5111197bf4a80ea341c76375c8460c7bda72 100644
543--- a/pkg/ssh/cmd/mirror.go
544+++ b/pkg/ssh/cmd/mirror.go
545@@ -25,7 +25,7 @@ func mirrorCommand() *cobra.Command {
546
547 cmd.Println(isMirror)
548 case 2:
549- if err := checkIfCollab(cmd, args); err != nil {
550+ if err := checkIfRepoCollab(cmd, args); err != nil {
551 return err
552 }
553
554diff --git a/pkg/ssh/cmd/private.go b/pkg/ssh/cmd/private.go
555index 9c91dbcda1e8d89f763cbe4e13300848e7389466..b7198f1c4c008a4b82bd227046c4b694e872369e 100644
556--- a/pkg/ssh/cmd/private.go
557+++ b/pkg/ssh/cmd/private.go
558@@ -32,7 +32,7 @@ func privateCommand() *cobra.Command {
559 if err != nil {
560 return err
561 }
562- if err := checkIfCollab(cmd, args); err != nil {
563+ if err := checkIfRepoCollab(cmd, args); err != nil {
564 return err
565 }
566 if err := be.SetPrivate(ctx, rn, isPrivate); err != nil {
567diff --git a/pkg/ssh/cmd/project_name.go b/pkg/ssh/cmd/project_name.go
568index d24931f5dcf23af7d8656d5d41c37fbe7f901d37..feb3975642be430dbaf306d5baeb2c7042fd9693 100644
569--- a/pkg/ssh/cmd/project_name.go
570+++ b/pkg/ssh/cmd/project_name.go
571@@ -27,7 +27,7 @@ func projectName() *cobra.Command {
572
573 cmd.Println(pn)
574 default:
575- if err := checkIfCollab(cmd, args); err != nil {
576+ if err := checkIfRepoCollab(cmd, args); err != nil {
577 return err
578 }
579 if err := be.SetProjectName(ctx, rn, strings.Join(args[1:], " ")); err != nil {
580diff --git a/pkg/ssh/cmd/settings.go b/pkg/ssh/cmd/settings.go
581index 297ba195a4667a12ea4a7f533eac39f7a252c2cb..86d3c278dfece60794e890e6d503704f5877a3cf 100644
582--- a/pkg/ssh/cmd/settings.go
583+++ b/pkg/ssh/cmd/settings.go
584@@ -15,14 +15,18 @@ func SettingsCommand() *cobra.Command {
585 cmd := &cobra.Command{
586 Use: "settings",
587 Short: "Manage server settings",
588+ // Gate the whole command tree rather than each subcommand. Cobra
589+ // runs the nearest persistent pre-run found when walking up from
590+ // the invoked command, so subcommands added later are gated by
591+ // default instead of silently unprotected.
592+ PersistentPreRunE: checkIfServerAdmin,
593 }
594
595 cmd.AddCommand(
596 &cobra.Command{
597- Use: "allow-keyless [true|false]",
598- Short: "Set or get allow keyless access to repositories",
599- Args: cobra.RangeArgs(0, 1),
600- PersistentPreRunE: checkIfAdmin,
601+ Use: "allow-keyless [true|false]",
602+ Short: "Set or get allow keyless access to repositories",
603+ Args: cobra.RangeArgs(0, 1),
604 RunE: func(cmd *cobra.Command, args []string) error {
605 ctx := cmd.Context()
606 be := backend.FromContext(ctx)
607@@ -46,11 +50,10 @@ func SettingsCommand() *cobra.Command {
608 als := []string{access.NoAccess.String(), access.ReadOnlyAccess.String(), access.ReadWriteAccess.String(), access.AdminAccess.String()}
609 cmd.AddCommand(
610 &cobra.Command{
611- Use: "anon-access [ACCESS_LEVEL]",
612- Short: "Set or get the default access level for anonymous users",
613- Args: cobra.RangeArgs(0, 1),
614- ValidArgs: als,
615- PersistentPreRunE: checkIfAdmin,
616+ Use: "anon-access [ACCESS_LEVEL]",
617+ Short: "Set or get the default access level for anonymous users",
618+ Args: cobra.RangeArgs(0, 1),
619+ ValidArgs: als,
620 RunE: func(cmd *cobra.Command, args []string) error {
621 ctx := cmd.Context()
622 be := backend.FromContext(ctx)
623diff --git a/pkg/ssh/cmd/user.go b/pkg/ssh/cmd/user.go
624index 21981f9cb300e0fee9de4a3dc035b1c796cfafd4..801a9f9f2ad75dae1cd4d2b28b2e26cf2dbce400 100644
625--- a/pkg/ssh/cmd/user.go
626+++ b/pkg/ssh/cmd/user.go
627@@ -17,15 +17,19 @@ func UserCommand() *cobra.Command {
628 Use: "user",
629 Aliases: []string{"users"},
630 Short: "Manage users",
631+ // Gate the whole command tree rather than each subcommand. Cobra
632+ // runs the nearest persistent pre-run found when walking up from
633+ // the invoked command, so subcommands added later are gated by
634+ // default instead of silently unprotected.
635+ PersistentPreRunE: checkIfServerAdmin,
636 }
637
638 var admin bool
639 var key string
640 userCreateCommand := &cobra.Command{
641- Use: "create USERNAME",
642- Short: "Create a new user",
643- Args: cobra.ExactArgs(1),
644- PersistentPreRunE: checkIfAdmin,
645+ Use: "create USERNAME",
646+ Short: "Create a new user",
647+ Args: cobra.ExactArgs(1),
648 RunE: func(cmd *cobra.Command, args []string) error {
649 var pubkeys []ssh.PublicKey
650 ctx := cmd.Context()
651@@ -54,10 +58,9 @@ func UserCommand() *cobra.Command {
652 userCreateCommand.Flags().StringVarP(&key, "key", "k", "", "add a public key to the user")
653
654 userDeleteCommand := &cobra.Command{
655- Use: "delete USERNAME",
656- Short: "Delete a user",
657- Args: cobra.ExactArgs(1),
658- PersistentPreRunE: checkIfAdmin,
659+ Use: "delete USERNAME",
660+ Short: "Delete a user",
661+ Args: cobra.ExactArgs(1),
662 RunE: func(cmd *cobra.Command, args []string) error {
663 ctx := cmd.Context()
664 be := backend.FromContext(ctx)
665@@ -68,11 +71,10 @@ func UserCommand() *cobra.Command {
666 }
667
668 userListCommand := &cobra.Command{
669- Use: "list",
670- Aliases: []string{"ls"},
671- Short: "List users",
672- Args: cobra.NoArgs,
673- PersistentPreRunE: checkIfAdmin,
674+ Use: "list",
675+ Aliases: []string{"ls"},
676+ Short: "List users",
677+ Args: cobra.NoArgs,
678 RunE: func(cmd *cobra.Command, _ []string) error {
679 ctx := cmd.Context()
680 be := backend.FromContext(ctx)
681@@ -91,10 +93,9 @@ func UserCommand() *cobra.Command {
682 }
683
684 userAddPubkeyCommand := &cobra.Command{
685- Use: "add-pubkey USERNAME AUTHORIZED_KEY",
686- Short: "Add a public key to a user",
687- Args: cobra.MinimumNArgs(2),
688- PersistentPreRunE: checkIfAdmin,
689+ Use: "add-pubkey USERNAME AUTHORIZED_KEY",
690+ Short: "Add a public key to a user",
691+ Args: cobra.MinimumNArgs(2),
692 RunE: func(cmd *cobra.Command, args []string) error {
693 ctx := cmd.Context()
694 be := backend.FromContext(ctx)
695@@ -110,10 +111,9 @@ func UserCommand() *cobra.Command {
696 }
697
698 userRemovePubkeyCommand := &cobra.Command{
699- Use: "remove-pubkey USERNAME AUTHORIZED_KEY",
700- Short: "Remove a public key from a user",
701- Args: cobra.MinimumNArgs(2),
702- PersistentPreRunE: checkIfAdmin,
703+ Use: "remove-pubkey USERNAME AUTHORIZED_KEY",
704+ Short: "Remove a public key from a user",
705+ Args: cobra.MinimumNArgs(2),
706 RunE: func(cmd *cobra.Command, args []string) error {
707 ctx := cmd.Context()
708 be := backend.FromContext(ctx)
709@@ -129,10 +129,9 @@ func UserCommand() *cobra.Command {
710 }
711
712 userSetAdminCommand := &cobra.Command{
713- Use: "set-admin USERNAME [true|false]",
714- Short: "Make a user an admin",
715- Args: cobra.ExactArgs(2),
716- PersistentPreRunE: checkIfAdmin,
717+ Use: "set-admin USERNAME [true|false]",
718+ Short: "Make a user an admin",
719+ Args: cobra.ExactArgs(2),
720 RunE: func(cmd *cobra.Command, args []string) error {
721 ctx := cmd.Context()
722 be := backend.FromContext(ctx)
723@@ -143,10 +142,9 @@ func UserCommand() *cobra.Command {
724 }
725
726 userInfoCommand := &cobra.Command{
727diff --git a/pkg/ssh/cmd/webhooks.go b/pkg/ssh/cmd/webhooks.go
728index 6312b909baac7c31fd246683dba33284226778d9..e00effaae538fbf2baa4445481bc1a3b0f07bd78 100644
729--- a/pkg/ssh/cmd/webhooks.go
730+++ b/pkg/ssh/cmd/webhooks.go
731@@ -47,7 +47,7 @@ func webhookListCommand() *cobra.Command {
732 Use: "list REPOSITORY",
733 Short: "List repository webhooks",
734 Args: cobra.ExactArgs(1),
735- PersistentPreRunE: checkIfAdmin,
736+ PersistentPreRunE: checkIfRepoAdmin,
737 RunE: func(cmd *cobra.Command, args []string) error {
738 ctx := cmd.Context()
739 be := backend.FromContext(ctx)
740@@ -94,7 +94,7 @@ func webhookCreateCommand() *cobra.Command {
741 Use: "create REPOSITORY URL",
742 Short: "Create a repository webhook",
743 Args: cobra.ExactArgs(2),
744- PersistentPreRunE: checkIfAdmin,
745+ PersistentPreRunE: checkIfRepoAdmin,
746 RunE: func(cmd *cobra.Command, args []string) error {
747 ctx := cmd.Context()
748 be := backend.FromContext(ctx)
749@@ -141,7 +141,7 @@ func webhookDeleteCommand() *cobra.Command {
750 Use: "delete REPOSITORY WEBHOOK_ID",
751 Short: "Delete a repository webhook",
752 Args: cobra.ExactArgs(2),
753- PersistentPreRunE: checkIfAdmin,
754+ PersistentPreRunE: checkIfRepoAdmin,
755 RunE: func(cmd *cobra.Command, args []string) error {
756 ctx := cmd.Context()
757 be := backend.FromContext(ctx)
758@@ -172,7 +172,7 @@ func webhookUpdateCommand() *cobra.Command {
759 Use: "update REPOSITORY WEBHOOK_ID",
760 Short: "Update a repository webhook",
761 Args: cobra.ExactArgs(2),
762- PersistentPreRunE: checkIfAdmin,
763+ PersistentPreRunE: checkIfRepoAdmin,
764 RunE: func(cmd *cobra.Command, args []string) error {
765 ctx := cmd.Context()
766 be := backend.FromContext(ctx)
767@@ -274,16 +274,21 @@ func webhookDeliveriesListCommand() *cobra.Command {
768 Use: "list REPOSITORY WEBHOOK_ID",
769 Short: "List webhook deliveries",
770 Args: cobra.ExactArgs(2),
771- PersistentPreRunE: checkIfAdmin,
772+ PersistentPreRunE: checkIfRepoAdmin,
773 RunE: func(cmd *cobra.Command, args []string) error {
774 ctx := cmd.Context()
775 be := backend.FromContext(ctx)
776+ repo, err := be.Repository(ctx, args[0])
777+ if err != nil {
778+ return err
779+ }
780+
781 id, err := strconv.ParseInt(args[1], 10, 64)
782 if err != nil {
783 return fmt.Errorf("invalid webhook ID: %w", err)
784 }
785
786- dels, err := be.ListWebhookDeliveries(ctx, id)
787+ dels, err := be.ListWebhookDeliveries(ctx, repo, id)
788 if err != nil {
789 return err
790 }
791@@ -314,7 +319,7 @@ func webhookDeliveriesRedeliverCommand() *cobra.Command {
792 Use: "redeliver REPOSITORY WEBHOOK_ID DELIVERY_ID",
793 Short: "Redeliver a webhook delivery",
794 Args: cobra.ExactArgs(3),
795- PersistentPreRunE: checkIfAdmin,
796+ PersistentPreRunE: checkIfRepoAdmin,
797 RunE: func(cmd *cobra.Command, args []string) error {
798 ctx := cmd.Context()
799 be := backend.FromContext(ctx)
800@@ -345,10 +350,15 @@ func webhookDeliveriesGetCommand() *cobra.Command {
801 Use: "get REPOSITORY WEBHOOK_ID DELIVERY_ID",
802 Short: "Get a webhook delivery",
803 Args: cobra.ExactArgs(3),
804- PersistentPreRunE: checkIfAdmin,
805+ PersistentPreRunE: checkIfRepoAdmin,
806 RunE: func(cmd *cobra.Command, args []string) error {
807 ctx := cmd.Context()
808 be := backend.FromContext(ctx)
809+ repo, err := be.Repository(ctx, args[0])
810+ if err != nil {
811+ return err
812+ }
813+
814 id, err := strconv.ParseInt(args[1], 10, 64)
815 if err != nil {
816 return fmt.Errorf("invalid webhook ID: %w", err)
817@@ -359,7 +369,7 @@ func webhookDeliveriesGetCommand() *cobra.Command {
818 return fmt.Errorf("invalid delivery ID: %w", err)
819 }
820
821- del, err := be.WebhookDelivery(ctx, id, delID)
822+ del, err := be.WebhookDelivery(ctx, repo, id, delID)
823 if err != nil {
824 return err
825 }
826diff --git a/pkg/ssh/cmd/webhooks_test.go b/pkg/ssh/cmd/webhooks_test.go
827new file mode 100644
828index 0000000000000000000000000000000000000000..0be7d69a18881cafe57b764c77decb3a56426d93
829--- /dev/null
830+++ b/pkg/ssh/cmd/webhooks_test.go
831@@ -0,0 +1,100 @@
832+package cmd
833+
834+import (
835+ "strconv"
836+ "strings"
837+ "testing"
838+
839+ "github.com/charmbracelet/soft-serve/pkg/db"
840+ "github.com/charmbracelet/soft-serve/pkg/proto"
841+ "github.com/charmbracelet/soft-serve/pkg/store"
842+ "github.com/charmbracelet/soft-serve/pkg/webhook"
843+ "github.com/google/uuid"
844+ "github.com/matryer/is"
845+ _ "modernc.org/sqlite"
846+)
847+
848+// TestWebhookDeliveriesAreScopedToRepository verifies that webhook deliveries
849+// cannot be read across repositories.
850+//
851+// The `repo webhook deliveries list|get` commands authorize the caller against
852+// the REPOSITORY argument, but the delivery lookup used to be keyed only on
853+// the numeric webhook ID. Since any user who owns a repository has admin
854+// access to it, a user could pass their own repository name together with
855+// another repository's webhook ID and read that webhook's stored deliveries.
856+//
857+// Deliveries store the full request URL, headers (including the HMAC
858+// signature), request body, and response body, so this is a cross-repository
859+// disclosure of private repository event payloads.
860+func TestWebhookDeliveriesAreScopedToRepository(t *testing.T) {
861+ is := is.New(t)
862+ ctx, be := newAuthTestContext(t)
863+
864+ // The victim owns a private repository with a webhook, and that webhook
865+ // has a recorded delivery containing sensitive request/response data.
866+ victimCtx := withUser(t, ctx, be, "victim", false)
867+ victim := proto.UserFromContext(victimCtx)
868+ victimRepo, err := be.CreateRepository(victimCtx, "victim-repo", victim, proto.RepositoryOptions{Private: true})
869+ is.NoErr(err)
870+ is.NoErr(be.CreateWebhook(victimCtx, victimRepo, "http://example.com/hook", webhook.ContentTypeJSON, "s3cret", []webhook.Event{webhook.EventPush}, true))
871+
872+ victimHooks, err := be.ListWebhooks(victimCtx, victimRepo)
873+ is.NoErr(err)
874+ is.Equal(len(victimHooks), 1)
875+ victimHookID := victimHooks[0].ID
876+
877+ const (
878+ secretBody = "private-repo-payload"
879+ secretSignature = "X-SoftServe-Signature: sha256=leaked-signature\n"
880+ )
881+ deliveryID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
882+ is.NoErr(store.FromContext(ctx).CreateWebhookDelivery(
883+ ctx, db.FromContext(ctx), deliveryID, victimHookID, int(webhook.EventPush),
884+ "http://example.com/hook", "POST", nil,
885+ secretSignature, secretBody, 200, "Content-Type: text/plain\n", "victim response body",
886+ ))
887+
888+ // The attacker owns their own repository, so they hold admin access to
889+ // it, but they have no access at all to the victim's repository.
890+ attackerCtx := withUser(t, ctx, be, "attacker", false)
891+ attacker := proto.UserFromContext(attackerCtx)
892+ _, err = be.CreateRepository(attackerCtx, "attacker-repo", attacker, proto.RepositoryOptions{})
893+ is.NoErr(err)
894+
895+ hookID := strconv.FormatInt(victimHookID, 10)
896+
897+ // Listing deliveries by naming their own repository must not enumerate
898+ // the victim webhook's delivery IDs.
899+ stdout, _, err := runRepoOutput(t, attackerCtx, "webhook", "deliveries", "list", "attacker-repo", hookID)
900+ if err == nil {
901+ t.Error("expected error listing another repository's webhook deliveries")
902+ }
903+ if strings.Contains(stdout, deliveryID.String()) {
904+ t.Errorf("leaked delivery ID in output: %q", stdout)
905+ }
906+
907+ // Reading a specific delivery must not disclose its stored request or
908+ // response data either.
909+ stdout, _, err = runRepoOutput(t, attackerCtx, "webhook", "deliveries", "get", "attacker-repo", hookID, deliveryID.String())
910+ if err == nil {
911+ t.Error("expected error getting another repository's webhook delivery")
912+ }
913+ for _, secret := range []string{secretBody, "leaked-signature", "victim response body"} {
914+ if strings.Contains(stdout, secret) {
915+ t.Errorf("leaked %q in output: %q", secret, stdout)
916+ }
917+ }
918+
919+ // The owner can still read their own webhook's deliveries.
920+ stdout, _, err = runRepoOutput(t, victimCtx, "webhook", "deliveries", "list", "victim-repo", hookID)
921+ is.NoErr(err)
922+ if !strings.Contains(stdout, deliveryID.String()) {
923+ t.Errorf("owner could not list own deliveries, got: %q", stdout)
924+ }
925+
926+ stdout, _, err = runRepoOutput(t, victimCtx, "webhook", "deliveries", "get", "victim-repo", hookID, deliveryID.String())
927+ is.NoErr(err)
928+ if !strings.Contains(stdout, secretBody) {
929+ t.Errorf("owner could not read own delivery body, got: %q", stdout)
930+ }
931diff --git a/testscript/testdata/privilege-escalation-regression.txtar b/testscript/testdata/privilege-escalation-regression.txtar
932new file mode 100644
933index 0000000000000000000000000000000000000000..cc0359ea3267ca01001a2edeff7f82398e69d958
934--- /dev/null
935+++ b/testscript/testdata/privilege-escalation-regression.txtar
936@@ -0,0 +1,115 @@
937+# vi: set ft=conf
938+# Regression test for privilege escalation via repository-scoped auth checks
939+#
940+# VULNERABILITY DESCRIPTION:
941+# Global admin commands (`user`, `settings`) were gated by a helper written
942+# for repository-scoped commands. That helper granted access when the caller
943+# had admin access to a repository *named by the command's first argument*.
944+# Since any authenticated user can create a repository, and the owner of a
945+# repository has admin access to it, a regular user could satisfy the check
946+# for a global command simply by creating a repository with the right name.
947+#
948+# ATTACK SCENARIO:
949+# 1. Attacker registers an SSH key (or is given any normal account)
950+# 2. Attacker runs `repo create victim`, becoming owner and therefore
951+# repo-admin of "victim"
952+# 3. Attacker runs `user set-admin victim true`. The auth check reads the
953+# first argument "victim" as a repository name, sees repo-admin, allows it
954+# 4. The account "victim" is now a global server administrator
955+#
956+# A related issue let a read-write collaborator grant admin-access to any
957+# account, and remove collaborators ranked above themselves.
958+#
959+# THIS TEST VERIFIES:
960+# - A non-admin cannot reach `user` or `settings` commands by creating a
961+# repository named after the command argument
962+# - A read-write collaborator cannot grant access above their own level
963+# - A read-write collaborator cannot remove or demote a higher-ranked
964+# collaborator
965+# - Webhook deliveries cannot be read across repositories
966+
967+# start soft serve
968+exec soft serve &
969+# wait for SSH server to start
970+ensureserverrunning SSH_PORT
971+
972+# create regular, non-admin users
973+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
974+soft user create boss --key "$ATTACKER_AUTHORIZED_KEY"
975+soft user info user1
976+stdout 'Admin: false'
977+
978+# The attacker creates repositories named after the arguments they intend to
979+# pass to the global commands. They own these, so they are repo-admin of them.
980+usoft repo create user1
981+usoft repo create victim
982+usoft repo create true
983+usoft repo create admin-access
984+usoft repo create admin
985+
986+# Repo-admin access must not authorize global user commands.
987+! usoft user set-admin user1 true
988+! usoft user set-admin victim true
989+! usoft user create victim
990+! usoft user delete admin
991+! usoft user list
992+! usoft user info admin
993+! usoft user set-username admin victim
994+! usoft user add-pubkey admin "$ADMIN2_AUTHORIZED_KEY"
995+! usoft user remove-pubkey admin "$ADMIN1_AUTHORIZED_KEY"
996+
997+# Repo-admin access must not authorize global settings commands.
998+! usoft settings anon-access admin-access
999+! usoft settings anon-access read-write
1000+! usoft settings allow-keyless true
1001+
1002+# a placeholder to reset stderr
1003+soft help
1004+
1005+# The attacker is still not an admin, and settings are unchanged.
1006+soft user info user1
1007+stdout 'Admin: false'
1008+soft settings anon-access
1009+stdout 'read-only.*'
1010+
1011+# Collaborator grants are bounded by the caller's own access level.
1012+soft repo create shared
1013+soft repo collab add shared user1 read-write
1014+soft repo collab add shared boss admin-access
1015+
1016+# user1 is read-write on "shared" and must not be able to grant admin-access.
1017+! usoft repo collab add shared user1 admin-access
1018+# ...nor remove a collaborator ranked above them.
1019+! usoft repo collab remove shared boss
1020+# ...nor demote one by overwriting the grant.
1021+! usoft repo collab add shared boss read-only
1022+
1023+# a placeholder to reset stderr
1024+soft help
1025+
1026+# boss is still an admin-access collaborator, and user1 did not escalate.
1027+soft repo collab list shared
1028+stdout 'boss'
1029+
1030+# Granting at or below the caller's own level still works.
1031+soft user create peer
1032+usoft repo collab add shared peer read-only
1033+soft repo collab list shared
1034+stdout 'peer'
1035+