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

WIP: Add authorization header support for Gitea webhooks #20267

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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 (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`.
9 changes: 9 additions & 0 deletions models/webhook/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions modules/secret/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>token</code> followed by a space.
Comment on lines +1928 to +1937
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, I am not sure about the naming here. Based on MDN docs12 the naming would be "Authentication schema" instead of "Header content type" and the "Token authentication" would better fit with "Bearer scheme".

Footnotes

  1. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization

  2. https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes

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
Expand Down
17 changes: 17 additions & 0 deletions routers/web/repo/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 11 additions & 4 deletions services/forms/repo_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down
16 changes: 16 additions & 0 deletions services/webhook/deliver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be up to the admin if and what is prepended?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be the use-case for that?

Copy link
Member Author

@justusbunsi justusbunsi Jul 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes the token must be provided with token <value> or Bearer <value>. Right now the second one is not possible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of handling this in the backend, maybe it could be exclusively handled in the frontend:

  • if the user chooses "basic auth", the frontend computes the base64 and sends Basic <computedbase64>
  • if the user chooses "bearer token", the frontend sends Bearer <token>
  • and maybe a 3rd option for the use to exactly choose the header value

This way the backend logic can be simplified a bit, while keeping maximum flexibility.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I also thought about frontend code for it. But IIRC, it is preferred to do most logical code in the backend. So I went that way. 🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That token part definitely needs to be configurable, it's called the "schema" IIRC. Many APIs take Bearer but in the future, other schema could be introduced.

}
req.Header.Add(meta.AuthHeader.Name, content)
}
}

defer func() {
t.Delivered = time.Now().UnixNano()
if t.IsSucceed {
Expand Down
111 changes: 111 additions & 0 deletions services/webhook/gitea.go
Original file line number Diff line number Diff line change
@@ -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
}
justusbunsi marked this conversation as resolved.
Show resolved Hide resolved

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
}
Loading