fix(security): reject link shares in Webhook.ReadAll

The guard on Webhook.CanRead is unreachable: no route exposes a read-one
webhook, and DoReadAll never calls CanRead. Two paths were left open:

- the v2 user-webhook list passes a.GetID() into Webhook.UserID, which is
  negative for a link share, so the w.UserID > 0 branch and its link share
  check were skipped and the request fell through to the project branch with
  project id 0, returning 404 instead of 403.
- the project branch never rejected link shares at all, so any holder of a
  public share link could list the project's webhooks. target_url is a bearer
  secret for Slack, Discord, Teams and Zapier.

Guard both by rejecting link shares at the top of ReadAll.
This commit is contained in:
kolaente
2026-08-04 13:40:11 +02:00
parent a717d64d56
commit ce0d1355cf
2 changed files with 30 additions and 1 deletions
+24
View File
@@ -419,4 +419,28 @@ func TestLinkSharing_CannotActAsCollidingUser(t *testing.T) {
require.NoError(t, err)
assert.False(t, can)
})
t.Run("list the webhooks of the colliding user", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
share := &LinkSharing{ID: 1}
// the route fills UserID from the auth object
_, _, _, err := (&Webhook{UserID: share.GetID()}).ReadAll(s, share, "", 1, 50)
require.Error(t, err)
assert.True(t, IsErrGenericForbidden(err))
})
t.Run("list the webhooks of the project the share points at", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// link share 1 has read permission on project 1
share := &LinkSharing{ID: 1, ProjectID: 1, Permission: PermissionRead}
_, _, _, err := (&Webhook{ProjectID: 1}).ReadAll(s, share, "", 1, 50)
require.Error(t, err)
assert.True(t, IsErrGenericForbidden(err))
})
}
+6 -1
View File
@@ -220,12 +220,17 @@ func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) {
// @Failure 500 {object} models.Message "Internal server error"
// @Router /projects/{id}/webhooks [get]
func (w *Webhook) ReadAll(s *xorm.Session, a web.Auth, _ string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) {
// A link share can read its project, but webhook target_urls are secrets.
if _, is := a.(*LinkSharing); is {
return nil, 0, 0, ErrGenericForbidden{}
}
// w.UserID set selects the user-level list: a user may only see their own
// webhooks. The project list (w.UserID == 0) delegates to the project's read
// permission instead.
var listCond builder.Cond
if w.UserID > 0 {
if _, isShareAuth := a.(*LinkSharing); isShareAuth || w.UserID != a.GetID() {
if w.UserID != a.GetID() {
return nil, 0, 0, ErrGenericForbidden{}
}
listCond = builder.Eq{"user_id": w.UserID}