code.gitea.io/gitea@v1.22.3/services/webhook/deliver.go (about)

     1  // Copyright 2019 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package webhook
     5  
     6  import (
     7  	"context"
     8  	"crypto/hmac"
     9  	"crypto/sha1"
    10  	"crypto/sha256"
    11  	"crypto/tls"
    12  	"encoding/hex"
    13  	"fmt"
    14  	"io"
    15  	"net/http"
    16  	"net/url"
    17  	"strings"
    18  	"sync"
    19  	"time"
    20  
    21  	webhook_model "code.gitea.io/gitea/models/webhook"
    22  	"code.gitea.io/gitea/modules/graceful"
    23  	"code.gitea.io/gitea/modules/hostmatcher"
    24  	"code.gitea.io/gitea/modules/log"
    25  	"code.gitea.io/gitea/modules/process"
    26  	"code.gitea.io/gitea/modules/proxy"
    27  	"code.gitea.io/gitea/modules/queue"
    28  	"code.gitea.io/gitea/modules/setting"
    29  	"code.gitea.io/gitea/modules/timeutil"
    30  	webhook_module "code.gitea.io/gitea/modules/webhook"
    31  
    32  	"github.com/gobwas/glob"
    33  )
    34  
    35  func newDefaultRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (req *http.Request, body []byte, err error) {
    36  	switch w.HTTPMethod {
    37  	case "":
    38  		log.Info("HTTP Method for %s webhook %s [ID: %d] is not set, defaulting to POST", w.Type, w.URL, w.ID)
    39  		fallthrough
    40  	case http.MethodPost:
    41  		switch w.ContentType {
    42  		case webhook_model.ContentTypeJSON:
    43  			req, err = http.NewRequest("POST", w.URL, strings.NewReader(t.PayloadContent))
    44  			if err != nil {
    45  				return nil, nil, err
    46  			}
    47  
    48  			req.Header.Set("Content-Type", "application/json")
    49  		case webhook_model.ContentTypeForm:
    50  			forms := url.Values{
    51  				"payload": []string{t.PayloadContent},
    52  			}
    53  
    54  			req, err = http.NewRequest("POST", w.URL, strings.NewReader(forms.Encode()))
    55  			if err != nil {
    56  				return nil, nil, err
    57  			}
    58  
    59  			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    60  		default:
    61  			return nil, nil, fmt.Errorf("invalid content type: %v", w.ContentType)
    62  		}
    63  	case http.MethodGet:
    64  		u, err := url.Parse(w.URL)
    65  		if err != nil {
    66  			return nil, nil, fmt.Errorf("invalid URL: %w", err)
    67  		}
    68  		vals := u.Query()
    69  		vals["payload"] = []string{t.PayloadContent}
    70  		u.RawQuery = vals.Encode()
    71  		req, err = http.NewRequest("GET", u.String(), nil)
    72  		if err != nil {
    73  			return nil, nil, err
    74  		}
    75  	case http.MethodPut:
    76  		switch w.Type {
    77  		case webhook_module.MATRIX: // used when t.Version == 1
    78  			txnID, err := getMatrixTxnID([]byte(t.PayloadContent))
    79  			if err != nil {
    80  				return nil, nil, err
    81  			}
    82  			url := fmt.Sprintf("%s/%s", w.URL, url.PathEscape(txnID))
    83  			req, err = http.NewRequest("PUT", url, strings.NewReader(t.PayloadContent))
    84  			if err != nil {
    85  				return nil, nil, err
    86  			}
    87  		default:
    88  			return nil, nil, fmt.Errorf("invalid http method: %v", w.HTTPMethod)
    89  		}
    90  	default:
    91  		return nil, nil, fmt.Errorf("invalid http method: %v", w.HTTPMethod)
    92  	}
    93  
    94  	body = []byte(t.PayloadContent)
    95  	return req, body, addDefaultHeaders(req, []byte(w.Secret), t, body)
    96  }
    97  
    98  func addDefaultHeaders(req *http.Request, secret []byte, t *webhook_model.HookTask, payloadContent []byte) error {
    99  	var signatureSHA1 string
   100  	var signatureSHA256 string
   101  	if len(secret) > 0 {
   102  		sig1 := hmac.New(sha1.New, secret)
   103  		sig256 := hmac.New(sha256.New, secret)
   104  		_, err := io.MultiWriter(sig1, sig256).Write(payloadContent)
   105  		if err != nil {
   106  			// this error should never happen, since the hashes are writing to []byte and always return a nil error.
   107  			return fmt.Errorf("prepareWebhooks.sigWrite: %w", err)
   108  		}
   109  		signatureSHA1 = hex.EncodeToString(sig1.Sum(nil))
   110  		signatureSHA256 = hex.EncodeToString(sig256.Sum(nil))
   111  	}
   112  
   113  	event := t.EventType.Event()
   114  	eventType := string(t.EventType)
   115  	req.Header.Add("X-Gitea-Delivery", t.UUID)
   116  	req.Header.Add("X-Gitea-Event", event)
   117  	req.Header.Add("X-Gitea-Event-Type", eventType)
   118  	req.Header.Add("X-Gitea-Signature", signatureSHA256)
   119  	req.Header.Add("X-Gogs-Delivery", t.UUID)
   120  	req.Header.Add("X-Gogs-Event", event)
   121  	req.Header.Add("X-Gogs-Event-Type", eventType)
   122  	req.Header.Add("X-Gogs-Signature", signatureSHA256)
   123  	req.Header.Add("X-Hub-Signature", "sha1="+signatureSHA1)
   124  	req.Header.Add("X-Hub-Signature-256", "sha256="+signatureSHA256)
   125  	req.Header["X-GitHub-Delivery"] = []string{t.UUID}
   126  	req.Header["X-GitHub-Event"] = []string{event}
   127  	req.Header["X-GitHub-Event-Type"] = []string{eventType}
   128  	return nil
   129  }
   130  
   131  // Deliver creates the [http.Request] (depending on the webhook type), sends it
   132  // and records the status and response.
   133  func Deliver(ctx context.Context, t *webhook_model.HookTask) error {
   134  	w, err := webhook_model.GetWebhookByID(ctx, t.HookID)
   135  	if err != nil {
   136  		return err
   137  	}
   138  
   139  	defer func() {
   140  		err := recover()
   141  		if err == nil {
   142  			return
   143  		}
   144  		// There was a panic whilst delivering a hook...
   145  		log.Error("PANIC whilst trying to deliver webhook task[%d] to webhook %s Panic: %v\nStacktrace: %s", t.ID, w.URL, err, log.Stack(2))
   146  	}()
   147  
   148  	t.IsDelivered = true
   149  
   150  	newRequest := webhookRequesters[w.Type]
   151  	if t.PayloadVersion == 1 || newRequest == nil {
   152  		newRequest = newDefaultRequest
   153  	}
   154  
   155  	req, body, err := newRequest(ctx, w, t)
   156  	if err != nil {
   157  		return fmt.Errorf("cannot create http request for webhook %s[%d %s]: %w", w.Type, w.ID, w.URL, err)
   158  	}
   159  
   160  	// Record delivery information.
   161  	t.RequestInfo = &webhook_model.HookRequest{
   162  		URL:        req.URL.String(),
   163  		HTTPMethod: req.Method,
   164  		Headers:    map[string]string{},
   165  		Body:       string(body),
   166  	}
   167  	for k, vals := range req.Header {
   168  		t.RequestInfo.Headers[k] = strings.Join(vals, ",")
   169  	}
   170  
   171  	// Add Authorization Header
   172  	authorization, err := w.HeaderAuthorization()
   173  	if err != nil {
   174  		return fmt.Errorf("cannot get Authorization header for webhook %s[%d %s]: %w", w.Type, w.ID, w.URL, err)
   175  	}
   176  	if authorization != "" {
   177  		req.Header.Set("Authorization", authorization)
   178  		t.RequestInfo.Headers["Authorization"] = "******"
   179  	}
   180  
   181  	t.ResponseInfo = &webhook_model.HookResponse{
   182  		Headers: map[string]string{},
   183  	}
   184  
   185  	// OK We're now ready to attempt to deliver the task - we must double check that it
   186  	// has not been delivered in the meantime
   187  	updated, err := webhook_model.MarkTaskDelivered(ctx, t)
   188  	if err != nil {
   189  		log.Error("MarkTaskDelivered[%d]: %v", t.ID, err)
   190  		return fmt.Errorf("unable to mark task[%d] delivered in the db: %w", t.ID, err)
   191  	}
   192  	if !updated {
   193  		// This webhook task has already been attempted to be delivered or is in the process of being delivered
   194  		log.Trace("Webhook Task[%d] already delivered", t.ID)
   195  		return nil
   196  	}
   197  
   198  	// All code from this point will update the hook task
   199  	defer func() {
   200  		t.Delivered = timeutil.TimeStampNanoNow()
   201  		if t.IsSucceed {
   202  			log.Trace("Hook delivered: %s", t.UUID)
   203  		} else if !w.IsActive {
   204  			log.Trace("Hook delivery skipped as webhook is inactive: %s", t.UUID)
   205  		} else {
   206  			log.Trace("Hook delivery failed: %s", t.UUID)
   207  		}
   208  
   209  		if err := webhook_model.UpdateHookTask(ctx, t); err != nil {
   210  			log.Error("UpdateHookTask [%d]: %v", t.ID, err)
   211  		}
   212  
   213  		// Update webhook last delivery status.
   214  		if t.IsSucceed {
   215  			w.LastStatus = webhook_module.HookStatusSucceed
   216  		} else {
   217  			w.LastStatus = webhook_module.HookStatusFail
   218  		}
   219  		if err = webhook_model.UpdateWebhookLastStatus(ctx, w); err != nil {
   220  			log.Error("UpdateWebhookLastStatus: %v", err)
   221  			return
   222  		}
   223  	}()
   224  
   225  	if setting.DisableWebhooks {
   226  		return fmt.Errorf("webhook task skipped (webhooks disabled): [%d]", t.ID)
   227  	}
   228  
   229  	if !w.IsActive {
   230  		log.Trace("Webhook %s in Webhook Task[%d] is not active", w.URL, t.ID)
   231  		return nil
   232  	}
   233  
   234  	resp, err := webhookHTTPClient.Do(req.WithContext(ctx))
   235  	if err != nil {
   236  		t.ResponseInfo.Body = fmt.Sprintf("Delivery: %v", err)
   237  		return fmt.Errorf("unable to deliver webhook task[%d] in %s due to error in http client: %w", t.ID, w.URL, err)
   238  	}
   239  	defer resp.Body.Close()
   240  
   241  	// Status code is 20x can be seen as succeed.
   242  	t.IsSucceed = resp.StatusCode/100 == 2
   243  	t.ResponseInfo.Status = resp.StatusCode
   244  	for k, vals := range resp.Header {
   245  		t.ResponseInfo.Headers[k] = strings.Join(vals, ",")
   246  	}
   247  
   248  	p, err := io.ReadAll(resp.Body)
   249  	if err != nil {
   250  		t.ResponseInfo.Body = fmt.Sprintf("read body: %s", err)
   251  		return fmt.Errorf("unable to deliver webhook task[%d] in %s as unable to read response body: %w", t.ID, w.URL, err)
   252  	}
   253  	t.ResponseInfo.Body = string(p)
   254  	return nil
   255  }
   256  
   257  var (
   258  	webhookHTTPClient *http.Client
   259  	once              sync.Once
   260  	hostMatchers      []glob.Glob
   261  )
   262  
   263  func webhookProxy(allowList *hostmatcher.HostMatchList) func(req *http.Request) (*url.URL, error) {
   264  	if setting.Webhook.ProxyURL == "" {
   265  		return proxy.Proxy()
   266  	}
   267  
   268  	once.Do(func() {
   269  		for _, h := range setting.Webhook.ProxyHosts {
   270  			if g, err := glob.Compile(h); err == nil {
   271  				hostMatchers = append(hostMatchers, g)
   272  			} else {
   273  				log.Error("glob.Compile %s failed: %v", h, err)
   274  			}
   275  		}
   276  	})
   277  
   278  	return func(req *http.Request) (*url.URL, error) {
   279  		for _, v := range hostMatchers {
   280  			if v.Match(req.URL.Host) {
   281  				if !allowList.MatchHostName(req.URL.Host) {
   282  					return nil, fmt.Errorf("webhook can only call allowed HTTP servers (check your %s setting), deny '%s'", allowList.SettingKeyHint, req.URL.Host)
   283  				}
   284  				return http.ProxyURL(setting.Webhook.ProxyURLFixed)(req)
   285  			}
   286  		}
   287  		return http.ProxyFromEnvironment(req)
   288  	}
   289  }
   290  
   291  // Init starts the hooks delivery thread
   292  func Init() error {
   293  	timeout := time.Duration(setting.Webhook.DeliverTimeout) * time.Second
   294  
   295  	allowedHostListValue := setting.Webhook.AllowedHostList
   296  	if allowedHostListValue == "" {
   297  		allowedHostListValue = hostmatcher.MatchBuiltinExternal
   298  	}
   299  	allowedHostMatcher := hostmatcher.ParseHostMatchList("webhook.ALLOWED_HOST_LIST", allowedHostListValue)
   300  
   301  	webhookHTTPClient = &http.Client{
   302  		Timeout: timeout,
   303  		Transport: &http.Transport{
   304  			TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify},
   305  			Proxy:           webhookProxy(allowedHostMatcher),
   306  			DialContext:     hostmatcher.NewDialContext("webhook", allowedHostMatcher, nil, setting.Webhook.ProxyURLFixed),
   307  		},
   308  	}
   309  
   310  	hookQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "webhook_sender", handler)
   311  	if hookQueue == nil {
   312  		return fmt.Errorf("unable to create webhook_sender queue")
   313  	}
   314  	go graceful.GetManager().RunWithCancel(hookQueue)
   315  
   316  	go graceful.GetManager().RunWithShutdownContext(populateWebhookSendingQueue)
   317  
   318  	return nil
   319  }
   320  
   321  func populateWebhookSendingQueue(ctx context.Context) {
   322  	ctx, _, finished := process.GetManager().AddContext(ctx, "Webhook: Populate sending queue")
   323  	defer finished()
   324  
   325  	lowerID := int64(0)
   326  	for {
   327  		taskIDs, err := webhook_model.FindUndeliveredHookTaskIDs(ctx, lowerID)
   328  		if err != nil {
   329  			log.Error("Unable to populate webhook queue as FindUndeliveredHookTaskIDs failed: %v", err)
   330  			return
   331  		}
   332  		if len(taskIDs) == 0 {
   333  			return
   334  		}
   335  		lowerID = taskIDs[len(taskIDs)-1]
   336  
   337  		for _, taskID := range taskIDs {
   338  			select {
   339  			case <-ctx.Done():
   340  				log.Warn("Shutdown before Webhook Sending queue finishing being populated")
   341  				return
   342  			default:
   343  			}
   344  			if err := enqueueHookTask(taskID); err != nil {
   345  				log.Error("Unable to push HookTask[%d] to the Webhook Sending queue: %v", taskID, err)
   346  			}
   347  		}
   348  	}
   349  }