Parent directory

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}