webhooks_test.go
4190 bytes
1package cmd
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7
8 "github.com/charmbracelet/soft-serve/pkg/db"
9 "github.com/charmbracelet/soft-serve/pkg/proto"
10 "github.com/charmbracelet/soft-serve/pkg/store"
11 "github.com/charmbracelet/soft-serve/pkg/webhook"
12 "github.com/google/uuid"
13 "github.com/matryer/is"
14 _ "modernc.org/sqlite"
15)
16
17// TestWebhookDeliveriesAreScopedToRepository verifies that webhook deliveries
18// cannot be read across repositories.
19//
20// The `repo webhook deliveries list|get` commands authorize the caller against
21// the REPOSITORY argument, but the delivery lookup used to be keyed only on
22// the numeric webhook ID. Since any user who owns a repository has admin
23// access to it, a user could pass their own repository name together with
24// another repository's webhook ID and read that webhook's stored deliveries.
25//
26// Deliveries store the full request URL, headers (including the HMAC
27// signature), request body, and response body, so this is a cross-repository
28// disclosure of private repository event payloads.
29func TestWebhookDeliveriesAreScopedToRepository(t *testing.T) {
30 is := is.New(t)
31 ctx, be := newAuthTestContext(t)
32
33 // The victim owns a private repository with a webhook, and that webhook
34 // has a recorded delivery containing sensitive request/response data.
35 victimCtx := withUser(t, ctx, be, "victim", false)
36 victim := proto.UserFromContext(victimCtx)
37 victimRepo, err := be.CreateRepository(victimCtx, "victim-repo", victim, proto.RepositoryOptions{Private: true})
38 is.NoErr(err)
39 is.NoErr(be.CreateWebhook(victimCtx, victimRepo, "http://example.com/hook", webhook.ContentTypeJSON, "s3cret", []webhook.Event{webhook.EventPush}, true))
40
41 victimHooks, err := be.ListWebhooks(victimCtx, victimRepo)
42 is.NoErr(err)
43 is.Equal(len(victimHooks), 1)
44 victimHookID := victimHooks[0].ID
45
46 const (
47 secretBody = "private-repo-payload"
48 secretSignature = "X-SoftServe-Signature: sha256=leaked-signature\n"
49 )
50 deliveryID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
51 is.NoErr(store.FromContext(ctx).CreateWebhookDelivery(
52 ctx, db.FromContext(ctx), deliveryID, victimHookID, int(webhook.EventPush),
53 "http://example.com/hook", "POST", nil,
54 secretSignature, secretBody, 200, "Content-Type: text/plain\n", "victim response body",
55 ))
56
57 // The attacker owns their own repository, so they hold admin access to
58 // it, but they have no access at all to the victim's repository.
59 attackerCtx := withUser(t, ctx, be, "attacker", false)
60 attacker := proto.UserFromContext(attackerCtx)
61 _, err = be.CreateRepository(attackerCtx, "attacker-repo", attacker, proto.RepositoryOptions{})
62 is.NoErr(err)
63
64 hookID := strconv.FormatInt(victimHookID, 10)
65
66 // Listing deliveries by naming their own repository must not enumerate
67 // the victim webhook's delivery IDs.
68 stdout, _, err := runRepoOutput(t, attackerCtx, "webhook", "deliveries", "list", "attacker-repo", hookID)
69 if err == nil {
70 t.Error("expected error listing another repository's webhook deliveries")
71 }
72 if strings.Contains(stdout, deliveryID.String()) {
73 t.Errorf("leaked delivery ID in output: %q", stdout)
74 }
75
76 // Reading a specific delivery must not disclose its stored request or
77 // response data either.
78 stdout, _, err = runRepoOutput(t, attackerCtx, "webhook", "deliveries", "get", "attacker-repo", hookID, deliveryID.String())
79 if err == nil {
80 t.Error("expected error getting another repository's webhook delivery")
81 }
82 for _, secret := range []string{secretBody, "leaked-signature", "victim response body"} {
83 if strings.Contains(stdout, secret) {
84 t.Errorf("leaked %q in output: %q", secret, stdout)
85 }
86 }
87
88 // The owner can still read their own webhook's deliveries.
89 stdout, _, err = runRepoOutput(t, victimCtx, "webhook", "deliveries", "list", "victim-repo", hookID)
90 is.NoErr(err)
91 if !strings.Contains(stdout, deliveryID.String()) {
92 t.Errorf("owner could not list own deliveries, got: %q", stdout)
93 }
94
95 stdout, _, err = runRepoOutput(t, victimCtx, "webhook", "deliveries", "get", "victim-repo", hookID, deliveryID.String())
96 is.NoErr(err)
97 if !strings.Contains(stdout, secretBody) {
98 t.Errorf("owner could not read own delivery body, got: %q", stdout)
99 }
100}