diff --git a/docs/content/doc/features/webhooks.en-us.md b/docs/content/doc/features/webhooks.en-us.md index 2dba7b7f83c74..fdceb3d326570 100644 --- a/docs/content/doc/features/webhooks.en-us.md +++ b/docs/content/doc/features/webhooks.en-us.md @@ -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 (Gitea hook only) + +**With 1.18**, 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. Supported header types are _Basic Authentication_ and _Token Authentication_. The header key can be changed in case the webhook target requires a different one. The key defaults to `Authorization`. diff --git a/models/webhook/webhook.go b/models/webhook/webhook.go index 1b79a414ade52..378169276b787 100644 --- a/models/webhook/webhook.go +++ b/models/webhook/webhook.go @@ -165,6 +165,15 @@ const ( PACKAGIST HookType = "packagist" ) +// AuthHeaderType is the type authentication header of a webhook +type AuthHeaderType string + +// Types of authentication headers +const ( + BASICAUTH AuthHeaderType = "basic" + TOKENAUTH AuthHeaderType = "token" +) + // HookStatus is the status of a web hook type HookStatus int diff --git a/modules/secret/secret.go b/modules/secret/secret.go index e7edc7a95e528..3ef1b30c994c3 100644 --- a/modules/secret/secret.go +++ b/modules/secret/secret.go @@ -15,6 +15,11 @@ import ( "io" ) +type ( + EncryptSecretCallable func(key, str string) (string, error) + DecryptSecretCallable func(key, cipherhex string) (string, error) +) + // AesEncrypt encrypts text and given key with AES. func AesEncrypt(key, text []byte) ([]byte, error) { block, err := aes.NewCipher(key) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 9e8a0303393b6..ae79459254d7e 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1925,6 +1925,16 @@ settings.webhook.payload = Content settings.webhook.body = Body settings.webhook.replay.description = Replay this webhook. settings.webhook.delivery.success = An event has been added to the delivery queue. It may take few seconds before it shows up in the delivery history. +settings.webhook.auth_header.section = Authorization Header +settings.webhook.auth_header.description = Add authorization header to webhook delivery. +settings.webhook.auth_header.name = Header name +settings.webhook.auth_header.type = Header content type +settings.webhook.auth_header.type_basic = Basic authentication +settings.webhook.auth_header.type_token = Token authentication +settings.webhook.auth_header.username = Username +settings.webhook.auth_header.password = Password +settings.webhook.auth_header.token = Token +settings.webhook.auth_header.token_description = During webhook delivery, the given value will be prepended with token followed by a space. settings.githooks_desc = "Git Hooks are powered by Git itself. You can edit hook files below to set up custom operations." settings.githook_edit_desc = If the hook is inactive, sample content will be presented. Leaving content to an empty value will disable this hook. settings.githook_name = Hook Name diff --git a/routers/web/repo/webhook.go b/routers/web/repo/webhook.go index a9b14ee21f453..e8af050eb4d0d 100644 --- a/routers/web/repo/webhook.go +++ b/routers/web/repo/webhook.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/convert" "code.gitea.io/gitea/modules/git" "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" "code.gitea.io/gitea/modules/util" @@ -211,6 +212,12 @@ func GiteaHooksNewPost(ctx *context.Context) { contentType = webhook.ContentTypeForm } + meta, err := webhook_service.CreateGiteaHook(form, secret.EncryptSecret) + if err != nil { + ctx.ServerError("Meta", err) + return + } + w := &webhook.Webhook{ RepoID: orCtx.RepoID, URL: form.PayloadURL, @@ -220,6 +227,7 @@ func GiteaHooksNewPost(ctx *context.Context) { HookEvent: ParseHookEvent(form.WebhookForm), IsActive: form.Active, Type: webhook.GITEA, + Meta: meta, OrgID: orCtx.OrgID, IsSystemWebhook: orCtx.IsSystemWebhook, } @@ -761,6 +769,8 @@ func checkWebhook(ctx *context.Context) (*orgRepoCtx, *webhook.Webhook) { ctx.Data["HookType"] = w.Type switch w.Type { + case webhook.GITEA: + ctx.Data["GiteaHook"] = webhook_service.GetGiteaHook(w, secret.DecryptSecret) case webhook.SLACK: ctx.Data["SlackHook"] = webhook_service.GetSlackHook(w) case webhook.DISCORD: @@ -818,10 +828,17 @@ func WebHooksEditPost(ctx *context.Context) { contentType = webhook.ContentTypeForm } + meta, err := webhook_service.CreateGiteaHook(form, secret.EncryptSecret) + if err != nil { + ctx.ServerError("Meta", err) + return + } + w.URL = form.PayloadURL w.ContentType = contentType w.Secret = form.Secret w.HookEvent = ParseHookEvent(form.WebhookForm) + w.Meta = meta w.IsActive = form.Active w.HTTPMethod = form.HTTPMethod if err := w.UpdateEvent(); err != nil { diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index afecc205f31e8..866bb4db67825 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/models" issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" + webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" @@ -265,10 +266,16 @@ func (f WebhookForm) ChooseEvents() bool { // NewWebhookForm form for creating web hook type NewWebhookForm struct { - PayloadURL string `binding:"Required;ValidUrl"` - HTTPMethod string `binding:"Required;In(POST,GET)"` - ContentType int `binding:"Required"` - Secret string + PayloadURL string `binding:"Required;ValidUrl"` + HTTPMethod string `binding:"Required;In(POST,GET)"` + ContentType int `binding:"Required"` + Secret string + AuthHeaderActive bool + AuthHeaderName string + AuthHeaderType webhook_model.AuthHeaderType `binding:"In(basic,token)"` + AuthHeaderUsername string + AuthHeaderPassword string + AuthHeaderToken string WebhookForm } diff --git a/services/webhook/deliver.go b/services/webhook/deliver.go index 77744473f1ce3..d0ccb4251d85b 100644 --- a/services/webhook/deliver.go +++ b/services/webhook/deliver.go @@ -20,12 +20,14 @@ import ( "time" webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/hostmatcher" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/proxy" "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/secret" "code.gitea.io/gitea/modules/setting" "github.com/gobwas/glob" @@ -145,6 +147,20 @@ func Deliver(ctx context.Context, t *webhook_model.HookTask) error { Headers: map[string]string{}, } + if w.Type == webhook_model.GITEA { + meta := GetGiteaHook(w, secret.DecryptSecret) + if meta.AuthHeaderEnabled { + var content string + switch meta.AuthHeader.Type { + case webhook_model.BASICAUTH: + content = fmt.Sprintf("Basic %s", base.BasicAuthEncode(meta.AuthHeader.Username, meta.AuthHeader.Password)) + case webhook_model.TOKENAUTH: + content = fmt.Sprintf("token %s", meta.AuthHeader.Token) + } + req.Header.Add(meta.AuthHeader.Name, content) + } + } + defer func() { t.Delivered = time.Now().UnixNano() if t.IsSucceed { diff --git a/services/webhook/gitea.go b/services/webhook/gitea.go new file mode 100644 index 0000000000000..827e9d85aaf91 --- /dev/null +++ b/services/webhook/gitea.go @@ -0,0 +1,111 @@ +// 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 webhook + +import ( + webhook_model "code.gitea.io/gitea/models/webhook" + "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/services/forms" +) + +type ( + // GiteaAuthHeaderMeta contains the authentication header metadata + GiteaAuthHeaderMeta struct { + Name string `json:"name"` + Type webhook_model.AuthHeaderType `json:"type"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Token string `json:"token,omitempty"` + } + + // GiteaMeta contains the gitea webhook metadata + GiteaMeta struct { + AuthHeaderEnabled bool `json:"auth_header_enabled"` + AuthHeaderData string `json:"auth_header,omitempty"` + AuthHeader GiteaAuthHeaderMeta `json:"-"` + } +) + +// GetGiteaHook returns decrypted gitea metadata +func GetGiteaHook(w *webhook_model.Webhook, decryptFn secret.DecryptSecretCallable) *GiteaMeta { + s := &GiteaMeta{} + + // Legacy webhook configuration has no stored metadata + if w.Meta == "" { + return s + } + + if err := json.Unmarshal([]byte(w.Meta), s); err != nil { + log.Error("webhook.GetGiteaHook(%d): %v", w.ID, err) + return nil + } + + if !s.AuthHeaderEnabled { + return s + } + + headerData, err := decryptFn(setting.SecretKey, s.AuthHeaderData) + if err != nil { + log.Error("webhook.GetGiteaHook(%d): %v", w.ID, err) + return nil + } + + h := GiteaAuthHeaderMeta{} + if err := json.Unmarshal([]byte(headerData), &h); err != nil { + log.Error("webhook.GetGiteaHook(%d): %v", w.ID, err) + return nil + } + + // Replace encrypted content with decrypted settings + s.AuthHeaderData = "" + s.AuthHeader = h + + return s +} + +// CreateGiteaHook creates a gitea metadata string with encrypted auth header data, +// while it ensures to store the least necessary data in the database. +func CreateGiteaHook(form *forms.NewWebhookForm, encryptFn secret.EncryptSecretCallable) (string, error) { + metaObject := &GiteaMeta{ + AuthHeaderEnabled: form.AuthHeaderActive, + } + + if form.AuthHeaderActive { + headerMeta := GiteaAuthHeaderMeta{ + Name: form.AuthHeaderName, + Type: form.AuthHeaderType, + } + + switch form.AuthHeaderType { + case webhook_model.BASICAUTH: + headerMeta.Username = form.AuthHeaderUsername + headerMeta.Password = form.AuthHeaderPassword + case webhook_model.TOKENAUTH: + headerMeta.Token = form.AuthHeaderToken + } + + headerData, err := json.Marshal(headerMeta) + if err != nil { + return "", err + } + + encryptedHeaderData, err := encryptFn(setting.SecretKey, string(headerData)) + if err != nil { + return "", err + } + + metaObject.AuthHeaderData = encryptedHeaderData + } + + meta, err := json.Marshal(metaObject) + if err != nil { + return "", err + } + + return string(meta), nil +} diff --git a/services/webhook/gitea_test.go b/services/webhook/gitea_test.go new file mode 100644 index 0000000000000..9d5653a9d8d3d --- /dev/null +++ b/services/webhook/gitea_test.go @@ -0,0 +1,208 @@ +// 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 webhook + +import ( + "fmt" + "testing" + + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/require" +) + +type GiteaSecretModuleMock struct { + DecryptCalled bool + EncryptCalled bool + SimulateError bool +} + +func (m *GiteaSecretModuleMock) DecryptSecret(key, cipherhex string) (string, error) { + m.DecryptCalled = true + + if m.SimulateError { + return "", fmt.Errorf("Simulated error") + } + + return cipherhex, nil +} + +func (m *GiteaSecretModuleMock) EncryptSecret(key, str string) (string, error) { + m.EncryptCalled = true + + if m.SimulateError { + return "", fmt.Errorf("Simulated error") + } + + return str, nil +} + +func TestGetGiteaHook(t *testing.T) { + t.Run("Legacy configuration", func(t *testing.T) { + s := &webhook_model.Webhook{ + Type: webhook_model.GITEA, + Meta: "", + } + + m := GiteaSecretModuleMock{} + + actual := GetGiteaHook(s, m.DecryptSecret) + + require.NotNil(t, actual) + require.IsType(t, &GiteaMeta{}, actual) + require.False(t, actual.AuthHeaderEnabled) + require.False(t, m.DecryptCalled, "Decrypt function unexpectedly called") + }) + + t.Run("Disabled auth headers", func(t *testing.T) { + s := &webhook_model.Webhook{ + Type: webhook_model.GITEA, + Meta: `{"auth_header_enabled": false}`, + } + + m := GiteaSecretModuleMock{} + + actual := GetGiteaHook(s, m.DecryptSecret) + + require.NotNil(t, actual) + require.IsType(t, &GiteaMeta{}, actual) + require.False(t, actual.AuthHeaderEnabled) + require.False(t, m.DecryptCalled, "Decrypt function unexpectedly called") + }) + + t.Run("Enabled auth headers", func(t *testing.T) { + s := &webhook_model.Webhook{ + Type: webhook_model.GITEA, + Meta: `{"auth_header_enabled": true, "auth_header": "{\"name\": \"X-Test-Authorization\", \"type\": \"basic\", \"username\": \"test-user\", \"password\":\"test-password\"}"}`, + } + + m := GiteaSecretModuleMock{} + + actual := GetGiteaHook(s, m.DecryptSecret) + + require.NotNil(t, actual) + require.IsType(t, &GiteaMeta{}, actual) + require.True(t, actual.AuthHeaderEnabled) + require.True(t, m.DecryptCalled, "Decrypt function was not called") + + require.Equal(t, "X-Test-Authorization", actual.AuthHeader.Name) + require.Empty(t, actual.AuthHeaderData) + }) + + t.Run("Metadata parse error", func(t *testing.T) { + s := &webhook_model.Webhook{ + Type: webhook_model.GITEA, + Meta: `{"`, + } + + m := GiteaSecretModuleMock{} + + actual := GetGiteaHook(s, m.DecryptSecret) + + require.Nil(t, actual) + require.False(t, m.DecryptCalled, "Decrypt function unexpectedly called") + }) + + t.Run("AuthHeaderData parse error", func(t *testing.T) { + s := &webhook_model.Webhook{ + Type: webhook_model.GITEA, + Meta: `{"auth_header_enabled": true, "auth_header": "{\""}`, + } + + m := GiteaSecretModuleMock{} + + actual := GetGiteaHook(s, m.DecryptSecret) + + require.Nil(t, actual) + require.True(t, m.DecryptCalled, "Decrypt function was not called") + }) + + t.Run("Decryption error", func(t *testing.T) { + s := &webhook_model.Webhook{ + Type: webhook_model.GITEA, + Meta: `{"auth_header_enabled": true, "auth_header": "{\"name\": \"X-Test-Authorization\", \"type\": \"basic\", \"username\": \"test-user\", \"password\":\"test-password\"}"}`, + } + + m := GiteaSecretModuleMock{SimulateError: true} + + actual := GetGiteaHook(s, m.DecryptSecret) + + require.Nil(t, actual) + require.True(t, m.DecryptCalled, "Decrypt function was not called") + }) +} + +func TestCreateGiteaHook(t *testing.T) { + t.Run("Disabled auth headers", func(t *testing.T) { + m := GiteaSecretModuleMock{} + + form := &forms.NewWebhookForm{ + AuthHeaderActive: false, + } + + actual, err := CreateGiteaHook(form, m.EncryptSecret) + expected := `{"auth_header_enabled":false}` + + require.Nil(t, err) + require.Equal(t, expected, actual) + require.False(t, m.EncryptCalled, "Encrypt function unexpectedly called") + }) + + t.Run("Enabled auth headers (basic auth)", func(t *testing.T) { + m := GiteaSecretModuleMock{} + + form := &forms.NewWebhookForm{ + AuthHeaderActive: true, + AuthHeaderName: "Authorization", + AuthHeaderType: webhook_model.BASICAUTH, + AuthHeaderUsername: "test-user", + AuthHeaderPassword: "test-password", + } + + actual, err := CreateGiteaHook(form, m.EncryptSecret) + expected := `{"auth_header_enabled":true,"auth_header":"{\"name\":\"Authorization\",\"type\":\"basic\",\"username\":\"test-user\",\"password\":\"test-password\"}"}` + + require.Nil(t, err) + require.Equal(t, expected, actual) + require.True(t, m.EncryptCalled, "Encrypt function was not called") + }) + + t.Run("Enabled auth headers (token auth)", func(t *testing.T) { + m := GiteaSecretModuleMock{} + + form := &forms.NewWebhookForm{ + AuthHeaderActive: true, + AuthHeaderName: "Authorization", + AuthHeaderType: webhook_model.TOKENAUTH, + AuthHeaderToken: "test-token", + } + + actual, err := CreateGiteaHook(form, m.EncryptSecret) + expected := `{"auth_header_enabled":true,"auth_header":"{\"name\":\"Authorization\",\"type\":\"token\",\"token\":\"test-token\"}"}` + + require.Nil(t, err) + require.Equal(t, expected, actual) + require.True(t, m.EncryptCalled, "Encrypt function was not called") + }) + + t.Run("Encyption error", func(t *testing.T) { + m := GiteaSecretModuleMock{SimulateError: true} + + form := &forms.NewWebhookForm{ + AuthHeaderActive: true, + AuthHeaderName: "Authorization", + AuthHeaderType: webhook_model.TOKENAUTH, + AuthHeaderToken: "test-token", + } + + actual, err := CreateGiteaHook(form, m.EncryptSecret) + + require.NotNil(t, err) + require.Errorf(t, err, "Simulated error") + require.Empty(t, actual) + require.True(t, m.EncryptCalled, "Encrypt function was not called") + }) +} diff --git a/templates/repo/settings/webhook/gitea.tmpl b/templates/repo/settings/webhook/gitea.tmpl index 062948b6abefd..4d37b3acd7598 100644 --- a/templates/repo/settings/webhook/gitea.tmpl +++ b/templates/repo/settings/webhook/gitea.tmpl @@ -35,6 +35,51 @@ + +
+ +
+

{{.locale.Tr "repo.settings.webhook.auth_header.section"}}

+
+
+ + + {{.locale.Tr "repo.settings.webhook.auth_header.description"}} +
+
+ + + + + +
+ +
+ {{template "repo/settings/webhook/settings" .}} {{end}} diff --git a/web_src/js/features/comp/WebHookEditor.js b/web_src/js/features/comp/WebHookEditor.js index 85a4f92809fa1..760c119b5c2ec 100644 --- a/web_src/js/features/comp/WebHookEditor.js +++ b/web_src/js/features/comp/WebHookEditor.js @@ -1,11 +1,69 @@ import $ from 'jquery'; const {csrfToken} = window.config; +const initAuthenticationHeaderSection = function() { + const $authHeaderSection = $('.auth-headers'); + + if ($authHeaderSection.length === 0) { + return; + } + + const $checkbox = $authHeaderSection.find('.checkbox input'); + + const updateHeaderContentType = function() { + const isBasicAuth = $authHeaderSection.find('#auth_header_type').val() === 'basic'; + const $basicAuthFields = $authHeaderSection.find('.basic-auth'); + const $tokenAuthFields = $authHeaderSection.find('.token-auth'); + + if (isBasicAuth) { + $basicAuthFields.addClass('required'); + $basicAuthFields.find('input').attr('required', ''); + $basicAuthFields.show(); + + $tokenAuthFields.removeClass('required'); + $tokenAuthFields.find('input').removeAttr('required'); + $tokenAuthFields.hide(); + } else { + $basicAuthFields.removeClass('required'); + $basicAuthFields.find('input').removeAttr('required'); + $basicAuthFields.hide(); + + $tokenAuthFields.addClass('required'); + $tokenAuthFields.find('input').attr('required', ''); + $tokenAuthFields.show(); + } + }; + + const updateHeaderCheckbox = function() { + if ($checkbox.is(':checked')) { + const $headerName = $authHeaderSection.find('#auth_header_name'); + $headerName.attr('required', ''); + $headerName.parent().addClass('required'); + $headerName.parent().show(); + $authHeaderSection.find('#auth_header_type').parent().parent().show(); + updateHeaderContentType(); + } else { + $authHeaderSection.find('.auth-header').hide(); + $authHeaderSection.find('.auth-header').removeClass('required'); + $authHeaderSection.find('.auth-header input').removeAttr('required'); + } + }; + + if ($checkbox.is(':checked')) { + updateHeaderCheckbox(); + } + + $checkbox.on('change', updateHeaderCheckbox); + $authHeaderSection.find('#auth_header_type').on('change', updateHeaderContentType); +}; + export function initCompWebHookEditor() { if ($('.new.webhook').length === 0) { return; } + initAuthenticationHeaderSection(); + $('.events.checkbox input').on('change', function () { if ($(this).is(':checked')) { $('.events.fields').show();