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 @@
+
+