blob: a4496c029925ad2de5db95d47afa53e280481527 [file] [log] [blame]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package github
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"golang.org/x/oscar/internal/secret"
)
// ValidateWebhookRequest verifies that the request's payload matches
// the HMAC tag in the header and returns a WebhookEvent containing the
// unmarshaled payload.
//
// The function is intended to validate authenticated POST requests
// received from GitHub webhooks.
//
// It expects:
// - a POST request
// - an "X-GitHub-Event" header entry with a non-empty event type
// (see [WebhookEventType])
// - a non-empty request body containing valid JSON representing an event
// of the specified event type
// - an "X-Hub-Signature-256" header entry of the form
// "sha256=HMAC", where HMAC is a valid hex-encoded HMAC tag of the
// request body computed with the key in db named "github-webhook"
//
// The function returns an error if any of these conditions is not met.
func ValidateWebhookRequest(r *http.Request, db secret.DB) (*WebhookEvent, error) {
if r.Method != http.MethodPost {
return nil, fmt.Errorf("%w %s, want %s", errBadHTTPMethod, r.Method, http.MethodPost)
}
if r.Body == nil {
return nil, errNoPayload
}
body, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
return nil, err
}
if len(body) == 0 {
return nil, errNoPayload
}
if err := validateWebhookSignature(body, r.Header.Get(xHubSignature256Header), db); err != nil {
return nil, err
}
event, err := toWebhookEvent(WebhookEventType(r.Header.Get(xGitHubEventHeader)), body)
if err != nil {
return nil, err
}
return event, nil
}
// validateWebhookSignature verifies that signature is a string of the
// form "sha256=HMAC", where HMAC s a valid hex-encoded HMAC tag of
// payload, computed with the key in db named "github-webhook".
//
// The function returns an error if any of these conditions is not met.
func validateWebhookSignature(payload []byte, signature string, db secret.DB) error {
mac, err := parseMAC(signature)
if err != nil {
return err
}
key, ok := db.Get(githubWebhookSecretName)
if !ok {
return errNoKey
}
if !validMAC(payload, mac, []byte(key)) {
return errInvalidHMAC
}
return nil
}
// WebhookEvent contains the data sent in a GitHub webhook request that
// is relevant for responding to the event.
type WebhookEvent struct {
// Type specifies the type of event's Payload.
Type WebhookEventType
// Payload is the unmarshaled JSON payload of the webhook event,
// of the Go type specified by Type.
//
// Event types that are not implemented use Go type [json.RawMessage].
Payload any
}
// WebhookEventType is the name GitHub uses to refer to a type of
// GitHub webhook event.
//
// Event types that are not implemented use Go type [json.RawMessage].
//
// See https://docs.github.com/en/webhooks/webhook-events-and-payloads
// for all possible event types.
type WebhookEventType string
const (
// An issue event.
// Corresponds to Go type [*WebhookIssueEvent].
WebhookEventTypeIssue WebhookEventType = "issues"
// An issue comment event.
// Corresponds to Go type [*WebhookIssueCommentEvent].
WebhookEventTypeIssueComment WebhookEventType = "issue_comment"
)
// Project returns the GitHub project (e.g., "golang/go") for the event,
// or an empty string if the project cannot be determined.
func (e *WebhookEvent) Project() string {
if e == nil || e.Payload == nil {
return ""
}
switch t := e.Payload.(type) {
case *WebhookIssueEvent:
return t.Repository.Project
case *WebhookIssueCommentEvent:
return t.Repository.Project
}
return ""
}
// WebhookIssueEvent is the structure of the JSON payload
// for a GitHub "issues" event (for example, a new issue created).
// https://docs.github.com/en/webhooks/webhook-events-and-payloads#issues
type WebhookIssueEvent struct {
Action WebhookIssueAction `json:"action"`
Issue Issue `json:"issue"`
Repository Repository `json:"repository"`
// Additional fields omitted.
}
type WebhookIssueAction string
const (
WebhookIssueActionOpened WebhookIssueAction = "opened"
// Additional actions omitted.
)
// WebhookIssueEvent is the structure of the JSON payload
// for a GitHub "issue_comment" event (for example, a new comment posted).
// https://docs.github.com/en/webhooks/webhook-events-and-payloads#issue_comment
type WebhookIssueCommentEvent struct {
Action WebhookIssueCommentAction `json:"action"`
Issue Issue `json:"issue"`
Repository Repository `json:"repository"`
// Additional fields omitted.
}
type WebhookIssueCommentAction string
const (
WebhookIssueCommentActionCreated WebhookIssueCommentAction = "created"
// Additional actions omitted.
)
// Repository is the repository in which an event occurred.
// https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository
type Repository struct {
Project string `json:"full_name"`
// Additional fields omitted.
}
// toWebhookEvent converts data into a WebhookEvent with a Payload of the
// type corresponding to eventType, or [json.RawMessage] if the type
// is not supported.
//
// It returns an error if the payload cannot be unmarshaled into the
// expected event type.
func toWebhookEvent(eventType WebhookEventType, data []byte) (*WebhookEvent, error) {
w := &WebhookEvent{
Type: eventType,
}
switch eventType {
case "":
return nil, errNoEventType
case WebhookEventTypeIssue:
w.Payload = new(WebhookIssueEvent)
case WebhookEventTypeIssueComment:
w.Payload = new(WebhookIssueCommentEvent)
default:
w.Payload = json.RawMessage(data)
return w, nil
}
if err := json.Unmarshal(data, w.Payload); err != nil {
return nil, err
}
return w, nil
}
const (
githubWebhookSecretName = "github-webhook"
xHubSignature256Header = "X-Hub-Signature-256"
xGitHubEventHeader = "X-GitHub-Event"
)
var (
errNoKey = fmt.Errorf("no secret for %q", githubWebhookSecretName)
errNoPayload = errors.New("empty payload")
errInvalidHMAC = errors.New("invalid HMAC")
errNoSignatureHeader = fmt.Errorf("missing %q header entry", xHubSignature256Header)
errBadSignatureHeader = fmt.Errorf("malformed %q header entry", xHubSignature256Header)
errBadHTTPMethod = errors.New("unexpected HTTP method")
errNoEventType = errors.New("no GitHub event type")
)
// parseMAC reads the value of the SHA-256 HMAC tag from the
// signature, which must be of the form "sha256=HMAC", where HMAC is
// a hex-encoded HMAC tag.
// It returns an error if the signature is empty or malformed.
func parseMAC(signature string) ([]byte, error) {
if signature == "" {
return nil, errNoSignatureHeader
}
hexMAC, ok := strings.CutPrefix(signature, "sha256=")
if !ok {
return nil, errBadSignatureHeader
}
b, err := hex.DecodeString(hexMAC)
if err != nil {
return nil, errBadSignatureHeader
}
return b, nil
}
// computeMAC computes the SHA-256 HMAC tag for message with key.
func computeMAC(message []byte, key []byte) []byte {
mac := hmac.New(sha256.New, key)
mac.Write(message)
return mac.Sum(nil)
}
// validMAC reports whether messageMAC is a valid SHA-256 HMAC tag
// for message with key.
func validMAC(message, messageMAC, key []byte) bool {
expectedMAC := computeMAC(message, key)
return hmac.Equal(messageMAC, expectedMAC)
}
// ValidWebhookTestdata returns an HTTP request and a secret DB
// (inputs to ValidateWebhookRequest) that will pass validation.
// payload is marshaled into JSON as the body of the returned request.
//
// For testing.
func ValidWebhookTestdata(t *testing.T, event WebhookEventType, payload any) (*http.Request, secret.DB) {
key := "test-key"
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
signature := computeXHubSignature256(t, body, key)
return newWebhookRequest(t, event, signature, body), newWebhookSecretDB(t, key)
}
// computeXHubSignature256 returns the expected value of the
// X-Hub-Signature-256 header entry in a GitHub webhook request, of the
// form "sha256=HMAC" where HMAC is the hex-encoded SHA-256 HMAC tag of
// the given payload created with key.
//
// For testing.
func computeXHubSignature256(t *testing.T, payload []byte, key string) string {
t.Helper()
h := computeMAC(payload, []byte(key))
return fmt.Sprintf("sha256=%s", hex.EncodeToString(h))
}
// newWebhookSecretDB returns an in-memory secret DB with a single
// key-value pair {"github-webhook": key}.
//
// For testing.
func newWebhookSecretDB(t *testing.T, key string) secret.DB {
t.Helper()
db := secret.Map{}
db.Set(githubWebhookSecretName, key)
return db
}
// newWebhookRequest returns an HTTP POST request of the form that would
// be sent by a GitHub webhook, with the request body set to payload,
// and the "X-Hub-Signature-256" header entry set to xHubSignature256.
//
// For testing.
func newWebhookRequest(t *testing.T, event WebhookEventType, xHubSignature256 string, payload []byte) *http.Request {
t.Helper()
r, err := http.NewRequest(http.MethodPost, "", bytes.NewReader(payload))
if err != nil {
t.Fatal("could not create test request")
}
if xHubSignature256 != "" {
r.Header.Set(xHubSignature256Header, xHubSignature256)
}
if event != "" {
r.Header.Set(xGitHubEventHeader, string(event))
}
return r
}