Skip to content

Commit b6e8135

Browse files
oliverpoollunnyGusteddelvh
authored
Add Webhook authorization header (#20926)
_This is a different approach to #20267, I took the liberty of adapting some parts, see below_ ## Context In some cases, a weebhook endpoint requires some kind of authentication. The usual way is by sending a static `Authorization` header, with a given token. For instance: - Matrix expects a `Bearer <token>` (already implemented, by storing the header cleartext in the metadata - which is buggy on retry #19872) - TeamCity #18667 - Gitea instances #20267 - SourceHut https://man.sr.ht/graphql.md#authentication-strategies (this is my actual personal need :) ## Proposed solution Add a dedicated encrypt column to the webhook table (instead of storing it as meta as proposed in #20267), so that it gets available for all present and future hook types (especially the custom ones #19307). This would also solve the buggy matrix retry #19872. As a first step, I would recommend focusing on the backend logic and improve the frontend at a later stage. For now the UI is a simple `Authorization` field (which could be later customized with `Bearer` and `Basic` switches): ![2022-08-23-142911](https://user-images.githubusercontent.com/3864879/186162483-5b721504-eef5-4932-812e-eb96a68494cc.png) The header name is hard-coded, since I couldn't fine any usecase justifying otherwise. ## Questions - What do you think of this approach? @justusbunsi @Gusted @silverwind - ~~How are the migrations generated? Do I have to manually create a new file, or is there a command for that?~~ - ~~I started adding it to the API: should I complete it or should I drop it? (I don't know how much the API is actually used)~~ ## Done as well: - add a migration for the existing matrix webhooks and remove the `Authorization` logic there _Closes #19872_ Co-authored-by: Lunny Xiao <[email protected]> Co-authored-by: Gusted <[email protected]> Co-authored-by: delvh <[email protected]>
1 parent 085f717 commit b6e8135

File tree

25 files changed

+671
-263
lines changed

25 files changed

+671
-263
lines changed

docs/content/doc/features/webhooks.en-us.md

+4
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,7 @@ if (json_last_error() !== JSON_ERROR_NONE) {
188188
```
189189

190190
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.
191+
192+
### Authorization header
193+
194+
**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 numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# for matrix, the access_token has been moved to "header_authorization"
2+
-
3+
id: 1
4+
meta: '{"homeserver_url":"https://matrix.example.com","room_id":"roomID","message_type":1}'
5+
header_authorization: "Bearer s3cr3t"
6+
-
7+
id: 2
8+
meta: ''
9+
header_authorization: ""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# unsafe payload
2+
- id: 1
3+
hook_id: 1
4+
payload_content: '{"homeserver_url":"https://matrix.example.com","room_id":"roomID","access_token":"s3cr3t","message_type":1}'
5+
# safe payload
6+
- id: 2
7+
hook_id: 2
8+
payload_content: '{"homeserver_url":"https://matrix.example.com","room_id":"roomID","message_type":1}'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# matrix webhook
2+
- id: 1
3+
type: matrix
4+
meta: '{"homeserver_url":"https://matrix.example.com","room_id":"roomID","access_token":"s3cr3t","message_type":1}'
5+
header_authorization_encrypted: ''
6+
# gitea webhook
7+
- id: 2
8+
type: gitea
9+
meta: ''
10+
header_authorization_encrypted: ''

models/migrations/migrations.go

+2
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,8 @@ var migrations = []Migration{
435435
NewMigration("Add index for hook_task", v1_19.AddIndexForHookTask),
436436
// v232 -> v233
437437
NewMigration("Alter package_version.metadata_json to LONGTEXT", v1_19.AlterPackageVersionMetadataToLongText),
438+
// v233 -> v234
439+
NewMigration("Add header_authorization_encrypted column to webhook table", v1_19.AddHeaderAuthorizationEncryptedColWebhook),
438440
}
439441

440442
// GetCurrentDBVersion returns the current db version

models/migrations/v1_19/v233.go

+183
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package v1_19 //nolint
6+
7+
import (
8+
"fmt"
9+
10+
"code.gitea.io/gitea/models/webhook"
11+
"code.gitea.io/gitea/modules/json"
12+
"code.gitea.io/gitea/modules/secret"
13+
"code.gitea.io/gitea/modules/setting"
14+
api "code.gitea.io/gitea/modules/structs"
15+
16+
"xorm.io/builder"
17+
"xorm.io/xorm"
18+
)
19+
20+
func batchProcess[T any](x *xorm.Engine, buf []T, query func(limit, start int) *xorm.Session, process func(*xorm.Session, T) error) error {
21+
size := cap(buf)
22+
start := 0
23+
for {
24+
err := query(size, start).Find(&buf)
25+
if err != nil {
26+
return err
27+
}
28+
if len(buf) == 0 {
29+
return nil
30+
}
31+
32+
err = func() error {
33+
sess := x.NewSession()
34+
defer sess.Close()
35+
if err := sess.Begin(); err != nil {
36+
return fmt.Errorf("unable to allow start session. Error: %w", err)
37+
}
38+
for _, record := range buf {
39+
if err := process(sess, record); err != nil {
40+
return err
41+
}
42+
}
43+
return sess.Commit()
44+
}()
45+
if err != nil {
46+
return err
47+
}
48+
49+
if len(buf) < size {
50+
return nil
51+
}
52+
start += size
53+
buf = buf[:0]
54+
}
55+
}
56+
57+
func AddHeaderAuthorizationEncryptedColWebhook(x *xorm.Engine) error {
58+
// Add the column to the table
59+
type Webhook struct {
60+
ID int64 `xorm:"pk autoincr"`
61+
Type webhook.HookType `xorm:"VARCHAR(16) 'type'"`
62+
Meta string `xorm:"TEXT"` // store hook-specific attributes
63+
64+
// HeaderAuthorizationEncrypted should be accessed using HeaderAuthorization() and SetHeaderAuthorization()
65+
HeaderAuthorizationEncrypted string `xorm:"TEXT"`
66+
}
67+
err := x.Sync(new(Webhook))
68+
if err != nil {
69+
return err
70+
}
71+
72+
// Migrate the matrix webhooks
73+
74+
type MatrixMeta struct {
75+
HomeserverURL string `json:"homeserver_url"`
76+
Room string `json:"room_id"`
77+
MessageType int `json:"message_type"`
78+
}
79+
type MatrixMetaWithAccessToken struct {
80+
MatrixMeta
81+
AccessToken string `json:"access_token"`
82+
}
83+
84+
err = batchProcess(x,
85+
make([]*Webhook, 0, 50),
86+
func(limit, start int) *xorm.Session {
87+
return x.Where("type=?", "matrix").OrderBy("id").Limit(limit, start)
88+
},
89+
func(sess *xorm.Session, hook *Webhook) error {
90+
// retrieve token from meta
91+
var withToken MatrixMetaWithAccessToken
92+
err := json.Unmarshal([]byte(hook.Meta), &withToken)
93+
if err != nil {
94+
return fmt.Errorf("unable to unmarshal matrix meta for webhook[id=%d]: %w", hook.ID, err)
95+
}
96+
if withToken.AccessToken == "" {
97+
return nil
98+
}
99+
100+
// encrypt token
101+
authorization := "Bearer " + withToken.AccessToken
102+
hook.HeaderAuthorizationEncrypted, err = secret.EncryptSecret(setting.SecretKey, authorization)
103+
if err != nil {
104+
return fmt.Errorf("unable to encrypt access token for webhook[id=%d]: %w", hook.ID, err)
105+
}
106+
107+
// remove token from meta
108+
withoutToken, err := json.Marshal(withToken.MatrixMeta)
109+
if err != nil {
110+
return fmt.Errorf("unable to marshal matrix meta for webhook[id=%d]: %w", hook.ID, err)
111+
}
112+
hook.Meta = string(withoutToken)
113+
114+
// save in database
115+
count, err := sess.ID(hook.ID).Cols("meta", "header_authorization_encrypted").Update(hook)
116+
if count != 1 || err != nil {
117+
return fmt.Errorf("unable to update header_authorization_encrypted for webhook[id=%d]: %d,%w", hook.ID, count, err)
118+
}
119+
return nil
120+
})
121+
if err != nil {
122+
return err
123+
}
124+
125+
// Remove access_token from HookTask
126+
127+
type HookTask struct {
128+
ID int64 `xorm:"pk autoincr"`
129+
HookID int64
130+
PayloadContent string `xorm:"LONGTEXT"`
131+
}
132+
133+
type MatrixPayloadSafe struct {
134+
Body string `json:"body"`
135+
MsgType string `json:"msgtype"`
136+
Format string `json:"format"`
137+
FormattedBody string `json:"formatted_body"`
138+
Commits []*api.PayloadCommit `json:"io.gitea.commits,omitempty"`
139+
}
140+
type MatrixPayloadUnsafe struct {
141+
MatrixPayloadSafe
142+
AccessToken string `json:"access_token"`
143+
}
144+
145+
err = batchProcess(x,
146+
make([]*HookTask, 0, 50),
147+
func(limit, start int) *xorm.Session {
148+
return x.Where(builder.And(
149+
builder.In("hook_id", builder.Select("id").From("webhook").Where(builder.Eq{"type": "matrix"})),
150+
builder.Like{"payload_content", "access_token"},
151+
)).OrderBy("id").Limit(limit, 0) // ignore the provided "start", since other payload were already converted and don't contain 'payload_content' anymore
152+
},
153+
func(sess *xorm.Session, hookTask *HookTask) error {
154+
// retrieve token from payload_content
155+
var withToken MatrixPayloadUnsafe
156+
err := json.Unmarshal([]byte(hookTask.PayloadContent), &withToken)
157+
if err != nil {
158+
return fmt.Errorf("unable to unmarshal payload_content for hook_task[id=%d]: %w", hookTask.ID, err)
159+
}
160+
if withToken.AccessToken == "" {
161+
return nil
162+
}
163+
164+
// remove token from payload_content
165+
withoutToken, err := json.Marshal(withToken.MatrixPayloadSafe)
166+
if err != nil {
167+
return fmt.Errorf("unable to marshal payload_content for hook_task[id=%d]: %w", hookTask.ID, err)
168+
}
169+
hookTask.PayloadContent = string(withoutToken)
170+
171+
// save in database
172+
count, err := sess.ID(hookTask.ID).Cols("payload_content").Update(hookTask)
173+
if count != 1 || err != nil {
174+
return fmt.Errorf("unable to update payload_content for hook_task[id=%d]: %d,%w", hookTask.ID, count, err)
175+
}
176+
return nil
177+
})
178+
if err != nil {
179+
return err
180+
}
181+
182+
return nil
183+
}

models/migrations/v1_19/v233_test.go

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package v1_19 //nolint
6+
7+
import (
8+
"testing"
9+
10+
"code.gitea.io/gitea/models/migrations/base"
11+
"code.gitea.io/gitea/models/webhook"
12+
"code.gitea.io/gitea/modules/json"
13+
"code.gitea.io/gitea/modules/secret"
14+
"code.gitea.io/gitea/modules/setting"
15+
16+
"github.com/stretchr/testify/assert"
17+
)
18+
19+
func Test_addHeaderAuthorizationEncryptedColWebhook(t *testing.T) {
20+
// Create Webhook table
21+
type Webhook struct {
22+
ID int64 `xorm:"pk autoincr"`
23+
Type webhook.HookType `xorm:"VARCHAR(16) 'type'"`
24+
Meta string `xorm:"TEXT"` // store hook-specific attributes
25+
26+
// HeaderAuthorizationEncrypted should be accessed using HeaderAuthorization() and SetHeaderAuthorization()
27+
HeaderAuthorizationEncrypted string `xorm:"TEXT"`
28+
}
29+
30+
type ExpectedWebhook struct {
31+
ID int64 `xorm:"pk autoincr"`
32+
Meta string
33+
HeaderAuthorization string
34+
}
35+
36+
type HookTask struct {
37+
ID int64 `xorm:"pk autoincr"`
38+
HookID int64
39+
PayloadContent string `xorm:"LONGTEXT"`
40+
}
41+
42+
// Prepare and load the testing database
43+
x, deferable := base.PrepareTestEnv(t, 0, new(Webhook), new(ExpectedWebhook), new(HookTask))
44+
defer deferable()
45+
if x == nil || t.Failed() {
46+
return
47+
}
48+
49+
if err := AddHeaderAuthorizationEncryptedColWebhook(x); err != nil {
50+
assert.NoError(t, err)
51+
return
52+
}
53+
54+
expected := []ExpectedWebhook{}
55+
if err := x.Table("expected_webhook").Asc("id").Find(&expected); !assert.NoError(t, err) {
56+
return
57+
}
58+
59+
got := []Webhook{}
60+
if err := x.Table("webhook").Select("id, meta, header_authorization_encrypted").Asc("id").Find(&got); !assert.NoError(t, err) {
61+
return
62+
}
63+
64+
for i, e := range expected {
65+
assert.Equal(t, e.Meta, got[i].Meta)
66+
67+
if e.HeaderAuthorization == "" {
68+
assert.Equal(t, "", got[i].HeaderAuthorizationEncrypted)
69+
} else {
70+
cipherhex := got[i].HeaderAuthorizationEncrypted
71+
cleartext, err := secret.DecryptSecret(setting.SecretKey, cipherhex)
72+
assert.NoError(t, err)
73+
assert.Equal(t, e.HeaderAuthorization, cleartext)
74+
}
75+
}
76+
77+
// ensure that no hook_task has some remaining "access_token"
78+
hookTasks := []HookTask{}
79+
if err := x.Table("hook_task").Select("id, payload_content").Asc("id").Find(&hookTasks); !assert.NoError(t, err) {
80+
return
81+
}
82+
for _, h := range hookTasks {
83+
var m map[string]interface{}
84+
err := json.Unmarshal([]byte(h.PayloadContent), &m)
85+
assert.NoError(t, err)
86+
assert.Nil(t, m["access_token"])
87+
}
88+
}

models/webhook/webhook.go

+28
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import (
1313
"code.gitea.io/gitea/models/db"
1414
"code.gitea.io/gitea/modules/json"
1515
"code.gitea.io/gitea/modules/log"
16+
"code.gitea.io/gitea/modules/secret"
17+
"code.gitea.io/gitea/modules/setting"
1618
"code.gitea.io/gitea/modules/timeutil"
1719
"code.gitea.io/gitea/modules/util"
1820

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

200+
// HeaderAuthorizationEncrypted should be accessed using HeaderAuthorization() and SetHeaderAuthorization()
201+
HeaderAuthorizationEncrypted string `xorm:"TEXT"`
202+
198203
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
199204
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
200205
}
@@ -401,6 +406,29 @@ func (w *Webhook) EventsArray() []string {
401406
return events
402407
}
403408

409+
// HeaderAuthorization returns the decrypted Authorization header.
410+
// Not on the reference (*w), to be accessible on WebhooksNew.
411+
func (w Webhook) HeaderAuthorization() (string, error) {
412+
if w.HeaderAuthorizationEncrypted == "" {
413+
return "", nil
414+
}
415+
return secret.DecryptSecret(setting.SecretKey, w.HeaderAuthorizationEncrypted)
416+
}
417+
418+
// SetHeaderAuthorization encrypts and sets the Authorization header.
419+
func (w *Webhook) SetHeaderAuthorization(cleartext string) error {
420+
if cleartext == "" {
421+
w.HeaderAuthorizationEncrypted = ""
422+
return nil
423+
}
424+
ciphertext, err := secret.EncryptSecret(setting.SecretKey, cleartext)
425+
if err != nil {
426+
return err
427+
}
428+
w.HeaderAuthorizationEncrypted = ciphertext
429+
return nil
430+
}
431+
404432
// CreateWebhook creates a new web hook.
405433
func CreateWebhook(ctx context.Context, w *Webhook) error {
406434
w.Type = strings.TrimSpace(w.Type)

0 commit comments

Comments
 (0)