Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Webhook authorization header #20926

Merged
merged 23 commits into from
Nov 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9462de6
drone glitch - retrigger
oliverpool Sep 26, 2022
ed56a84
webhook: add Authorization Header
oliverpool Oct 20, 2022
79bb608
add matrix help text comment
oliverpool Oct 21, 2022
223db4c
rename migration
oliverpool Oct 24, 2022
a0d4816
Merge remote-tracking branch 'origin/main' into webhook_authorization
oliverpool Oct 24, 2022
a61f722
remove unused RepoID
oliverpool Oct 24, 2022
91d2709
remove unused HookTask fields
oliverpool Oct 24, 2022
8a4aacd
remove unused Webhook fields
oliverpool Oct 24, 2022
6dc8076
rename migration
oliverpool Oct 24, 2022
6e8ba4c
Merge remote-tracking branch 'origin/main' into webhook_authorization
oliverpool Oct 24, 2022
e75ebeb
use x.Sync
oliverpool Oct 24, 2022
05e6713
filter on hook_id for migration
oliverpool Oct 24, 2022
fa5c04c
Merge branch 'main' into webhook_authorization
lunny Oct 24, 2022
5a4c564
Apply fmt suggestions from gusted
oliverpool Oct 24, 2022
69a22a4
reorder func arguments
oliverpool Oct 24, 2022
7bf090a
fix limit,start bug
oliverpool Oct 24, 2022
adc30d0
Merge branch 'main' into webhook_authorization
lunny Oct 25, 2022
8a3c046
Merge branch 'main' into webhook_authorization
lunny Oct 25, 2022
96c334b
Merge branch 'main' into webhook_authorization
lunny Oct 25, 2022
a66e44d
Didn't make it into 1.18 :confused:
oliverpool Oct 26, 2022
473a225
Merge branch 'main' into webhook_authorization
lunny Oct 26, 2022
3f7cf55
Merge remote-tracking branch 'origin/main' into webhook_authorization
oliverpool Nov 3, 2022
4933fe9
fix migration import
oliverpool Nov 3, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/content/doc/features/webhooks.en-us.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,7 @@ if (json_last_error() !== JSON_ERROR_NONE) {
```

There is a Test Delivery button in the webhook settings that allows to test the configuration as well as a list of the most Recent Deliveries.

### Authorization header

**With 1.19**, Gitea hooks can be configured to send an [authorization header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) to the webhook target.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# for matrix, the access_token has been moved to "header_authorization"
-
id: 1
meta: '{"homeserver_url":"https://matrix.example.com","room_id":"roomID","message_type":1}'
header_authorization: "Bearer s3cr3t"
-
id: 2
meta: ''
header_authorization: ""
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# unsafe payload
- id: 1
hook_id: 1
payload_content: '{"homeserver_url":"https://matrix.example.com","room_id":"roomID","access_token":"s3cr3t","message_type":1}'
# safe payload
- id: 2
hook_id: 2
payload_content: '{"homeserver_url":"https://matrix.example.com","room_id":"roomID","message_type":1}'
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# matrix webhook
- id: 1
type: matrix
meta: '{"homeserver_url":"https://matrix.example.com","room_id":"roomID","access_token":"s3cr3t","message_type":1}'
header_authorization_encrypted: ''
# gitea webhook
- id: 2
type: gitea
meta: ''
header_authorization_encrypted: ''
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,8 @@ var migrations = []Migration{
NewMigration("Add index for hook_task", v1_19.AddIndexForHookTask),
// v232 -> v233
NewMigration("Alter package_version.metadata_json to LONGTEXT", v1_19.AlterPackageVersionMetadataToLongText),
// v233 -> v234
NewMigration("Add header_authorization_encrypted column to webhook table", v1_19.AddHeaderAuthorizationEncryptedColWebhook),
}

// GetCurrentDBVersion returns the current db version
Expand Down
183 changes: 183 additions & 0 deletions models/migrations/v1_19/v233.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package v1_19 //nolint

import (
"fmt"

"code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/secret"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"

"xorm.io/builder"
"xorm.io/xorm"
)

func batchProcess[T any](x *xorm.Engine, buf []T, query func(limit, start int) *xorm.Session, process func(*xorm.Session, T) error) error {
size := cap(buf)
start := 0
for {
err := query(size, start).Find(&buf)
if err != nil {
return err
}
if len(buf) == 0 {
return nil
}

err = func() error {
sess := x.NewSession()
defer sess.Close()
if err := sess.Begin(); err != nil {
return fmt.Errorf("unable to allow start session. Error: %w", err)
}
for _, record := range buf {
if err := process(sess, record); err != nil {
return err
}
}
return sess.Commit()
}()
if err != nil {
return err
}

if len(buf) < size {
return nil
}
start += size
buf = buf[:0]
}
}

func AddHeaderAuthorizationEncryptedColWebhook(x *xorm.Engine) error {
// Add the column to the table
type Webhook struct {
ID int64 `xorm:"pk autoincr"`
Type webhook.HookType `xorm:"VARCHAR(16) 'type'"`
Meta string `xorm:"TEXT"` // store hook-specific attributes

// HeaderAuthorizationEncrypted should be accessed using HeaderAuthorization() and SetHeaderAuthorization()
HeaderAuthorizationEncrypted string `xorm:"TEXT"`
}
err := x.Sync(new(Webhook))
if err != nil {
return err
}

// Migrate the matrix webhooks

type MatrixMeta struct {
HomeserverURL string `json:"homeserver_url"`
Room string `json:"room_id"`
MessageType int `json:"message_type"`
}
type MatrixMetaWithAccessToken struct {
MatrixMeta
AccessToken string `json:"access_token"`
}

err = batchProcess(x,
make([]*Webhook, 0, 50),
func(limit, start int) *xorm.Session {
return x.Where("type=?", "matrix").OrderBy("id").Limit(limit, start)
},
func(sess *xorm.Session, hook *Webhook) error {
// retrieve token from meta
var withToken MatrixMetaWithAccessToken
err := json.Unmarshal([]byte(hook.Meta), &withToken)
if err != nil {
return fmt.Errorf("unable to unmarshal matrix meta for webhook[id=%d]: %w", hook.ID, err)
}
if withToken.AccessToken == "" {
return nil
}

// encrypt token
authorization := "Bearer " + withToken.AccessToken
hook.HeaderAuthorizationEncrypted, err = secret.EncryptSecret(setting.SecretKey, authorization)
if err != nil {
return fmt.Errorf("unable to encrypt access token for webhook[id=%d]: %w", hook.ID, err)
}

// remove token from meta
withoutToken, err := json.Marshal(withToken.MatrixMeta)
if err != nil {
return fmt.Errorf("unable to marshal matrix meta for webhook[id=%d]: %w", hook.ID, err)
}
hook.Meta = string(withoutToken)

// save in database
count, err := sess.ID(hook.ID).Cols("meta", "header_authorization_encrypted").Update(hook)
if count != 1 || err != nil {
return fmt.Errorf("unable to update header_authorization_encrypted for webhook[id=%d]: %d,%w", hook.ID, count, err)
}
return nil
})
if err != nil {
return err
}

// Remove access_token from HookTask

type HookTask struct {
ID int64 `xorm:"pk autoincr"`
HookID int64
PayloadContent string `xorm:"LONGTEXT"`
}

type MatrixPayloadSafe struct {
Body string `json:"body"`
MsgType string `json:"msgtype"`
Format string `json:"format"`
FormattedBody string `json:"formatted_body"`
Commits []*api.PayloadCommit `json:"io.gitea.commits,omitempty"`
}
type MatrixPayloadUnsafe struct {
MatrixPayloadSafe
AccessToken string `json:"access_token"`
}

err = batchProcess(x,
make([]*HookTask, 0, 50),
func(limit, start int) *xorm.Session {
return x.Where(builder.And(
builder.In("hook_id", builder.Select("id").From("webhook").Where(builder.Eq{"type": "matrix"})),
builder.Like{"payload_content", "access_token"},
)).OrderBy("id").Limit(limit, 0) // ignore the provided "start", since other payload were already converted and don't contain 'payload_content' anymore
},
func(sess *xorm.Session, hookTask *HookTask) error {
// retrieve token from payload_content
var withToken MatrixPayloadUnsafe
err := json.Unmarshal([]byte(hookTask.PayloadContent), &withToken)
if err != nil {
return fmt.Errorf("unable to unmarshal payload_content for hook_task[id=%d]: %w", hookTask.ID, err)
}
if withToken.AccessToken == "" {
return nil
}

// remove token from payload_content
withoutToken, err := json.Marshal(withToken.MatrixPayloadSafe)
if err != nil {
return fmt.Errorf("unable to marshal payload_content for hook_task[id=%d]: %w", hookTask.ID, err)
}
hookTask.PayloadContent = string(withoutToken)

// save in database
count, err := sess.ID(hookTask.ID).Cols("payload_content").Update(hookTask)
if count != 1 || err != nil {
return fmt.Errorf("unable to update payload_content for hook_task[id=%d]: %d,%w", hookTask.ID, count, err)
}
return nil
})
if err != nil {
return err
}

return nil
}
88 changes: 88 additions & 0 deletions models/migrations/v1_19/v233_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package v1_19 //nolint

import (
"testing"

"code.gitea.io/gitea/models/migrations/base"
"code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/secret"
"code.gitea.io/gitea/modules/setting"

"github.com/stretchr/testify/assert"
)

func Test_addHeaderAuthorizationEncryptedColWebhook(t *testing.T) {
// Create Webhook table
type Webhook struct {
ID int64 `xorm:"pk autoincr"`
Type webhook.HookType `xorm:"VARCHAR(16) 'type'"`
Meta string `xorm:"TEXT"` // store hook-specific attributes

// HeaderAuthorizationEncrypted should be accessed using HeaderAuthorization() and SetHeaderAuthorization()
HeaderAuthorizationEncrypted string `xorm:"TEXT"`
}

type ExpectedWebhook struct {
ID int64 `xorm:"pk autoincr"`
Meta string
HeaderAuthorization string
}

type HookTask struct {
ID int64 `xorm:"pk autoincr"`
HookID int64
PayloadContent string `xorm:"LONGTEXT"`
}

// Prepare and load the testing database
x, deferable := base.PrepareTestEnv(t, 0, new(Webhook), new(ExpectedWebhook), new(HookTask))
defer deferable()
if x == nil || t.Failed() {
return
}

if err := AddHeaderAuthorizationEncryptedColWebhook(x); err != nil {
assert.NoError(t, err)
return
}

expected := []ExpectedWebhook{}
if err := x.Table("expected_webhook").Asc("id").Find(&expected); !assert.NoError(t, err) {
return
}

got := []Webhook{}
if err := x.Table("webhook").Select("id, meta, header_authorization_encrypted").Asc("id").Find(&got); !assert.NoError(t, err) {
return
}

for i, e := range expected {
assert.Equal(t, e.Meta, got[i].Meta)

if e.HeaderAuthorization == "" {
assert.Equal(t, "", got[i].HeaderAuthorizationEncrypted)
} else {
cipherhex := got[i].HeaderAuthorizationEncrypted
cleartext, err := secret.DecryptSecret(setting.SecretKey, cipherhex)
assert.NoError(t, err)
assert.Equal(t, e.HeaderAuthorization, cleartext)
}
}

// ensure that no hook_task has some remaining "access_token"
hookTasks := []HookTask{}
if err := x.Table("hook_task").Select("id, payload_content").Asc("id").Find(&hookTasks); !assert.NoError(t, err) {
return
}
for _, h := range hookTasks {
var m map[string]interface{}
err := json.Unmarshal([]byte(h.PayloadContent), &m)
assert.NoError(t, err)
assert.Nil(t, m["access_token"])
}
}
28 changes: 28 additions & 0 deletions models/webhook/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/secret"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"

Expand Down Expand Up @@ -195,6 +197,9 @@ type Webhook struct {
Meta string `xorm:"TEXT"` // store hook-specific attributes
LastStatus HookStatus // Last delivery status

// HeaderAuthorizationEncrypted should be accessed using HeaderAuthorization() and SetHeaderAuthorization()
HeaderAuthorizationEncrypted string `xorm:"TEXT"`

CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}
Expand Down Expand Up @@ -401,6 +406,29 @@ func (w *Webhook) EventsArray() []string {
return events
}

// HeaderAuthorization returns the decrypted Authorization header.
// Not on the reference (*w), to be accessible on WebhooksNew.
func (w Webhook) HeaderAuthorization() (string, error) {
if w.HeaderAuthorizationEncrypted == "" {
return "", nil
}
return secret.DecryptSecret(setting.SecretKey, w.HeaderAuthorizationEncrypted)
}

// SetHeaderAuthorization encrypts and sets the Authorization header.
func (w *Webhook) SetHeaderAuthorization(cleartext string) error {
oliverpool marked this conversation as resolved.
Show resolved Hide resolved
if cleartext == "" {
w.HeaderAuthorizationEncrypted = ""
return nil
}
ciphertext, err := secret.EncryptSecret(setting.SecretKey, cleartext)
if err != nil {
return err
}
w.HeaderAuthorizationEncrypted = ciphertext
return nil
}

// CreateWebhook creates a new web hook.
func CreateWebhook(ctx context.Context, w *Webhook) error {
w.Type = strings.TrimSpace(w.Type)
Expand Down
Loading