internal/{gaby,github}: add gaby endpoint github-event (webhook listener)
Add a gaby endpoint, /github-event, which listens for
POST requests from GitHub webhooks and logs the payload
if the request is valid.
(This can be extended to take immediate action when,
for example, a new issue is posted.)
The validation logic is in the internal/github package.
The request is validated by checking the payload against
the HMAC in the request header, using a secret stored
in GCP secret manager.
Change-Id: I96f6779799be702ee8dc1b7631cce5bd3eaad3f1
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/610335
Reviewed-by: Jonathan Amsterdam <jba@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/gaby/github_event.go b/internal/gaby/github_event.go
new file mode 100644
index 0000000..5976734
--- /dev/null
+++ b/internal/gaby/github_event.go
@@ -0,0 +1,31 @@
+// 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 main
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "golang.org/x/oscar/internal/github"
+)
+
+// handleGitHubEvent takes action when an event occurs on GitHub.
+// Currently, the function only logs that it was able to validate
+// and parse the request.
+func (g *Gaby) handleGitHubEvent(r *http.Request) error {
+ payload, err := github.ValidateWebhookRequest(r, g.secret)
+ if err != nil {
+ return fmt.Errorf("invalid request: %w", err)
+ }
+
+ var event map[string]any
+ if err := json.Unmarshal(payload, &event); err != nil {
+ return fmt.Errorf("could not unmarshal payload: %w", err)
+ }
+
+ g.slog.Info("new GitHub event", "event", event)
+ return nil
+}
diff --git a/internal/gaby/github_event_test.go b/internal/gaby/github_event_test.go
new file mode 100644
index 0000000..e9189a5
--- /dev/null
+++ b/internal/gaby/github_event_test.go
@@ -0,0 +1,28 @@
+// 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 main
+
+import (
+ "log/slog"
+ "testing"
+
+ "golang.org/x/oscar/internal/github"
+)
+
+func TestHandleGitHubEvent(t *testing.T) {
+ validPayload := `{"number":1}`
+ r, db := github.ValidWebhookTestdata(t, validPayload)
+ g := &Gaby{secret: db, slog: slog.Default()}
+ if err := g.handleGitHubEvent(r); err != nil {
+ t.Fatalf("handleGitHubEvent err = %v, want nil", err)
+ }
+
+ invalidPayload := "not JSON"
+ r2, db2 := github.ValidWebhookTestdata(t, invalidPayload)
+ g2 := &Gaby{secret: db2, slog: slog.Default()}
+ if err := g2.handleGitHubEvent(r2); err == nil {
+ t.Fatal("handleGitHubEvent err = nil, want err")
+ }
+}
diff --git a/internal/gaby/main.go b/internal/gaby/main.go
index 908cac4..7100e39 100644
--- a/internal/gaby/main.go
+++ b/internal/gaby/main.go
@@ -269,7 +269,13 @@
// serveHTTP serves HTTP endpoints for Gaby.
func (g *Gaby) serveHTTP() {
- cronCounter := g.newCounter("crons", "number of /cron requests")
+ const (
+ cronEndpoint = "cron"
+ setLevelEndpoint = "setlevel"
+ githubEventEndpoint = "github-event"
+ )
+ cronEndpointCounter := g.newEndpointCounter(cronEndpoint)
+ githubEventEndpointCounter := g.newEndpointCounter(githubEventEndpoint)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Gaby\n")
@@ -280,7 +286,7 @@
// setlevel changes the log level dynamically.
// Usage: /setlevel?l=LEVEL
- http.HandleFunc("/setlevel", func(w http.ResponseWriter, r *http.Request) {
+ http.HandleFunc("/"+setLevelEndpoint, func(w http.ResponseWriter, r *http.Request) {
if err := g.slogLevel.UnmarshalText([]byte(r.FormValue("l"))); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -289,18 +295,36 @@
g.slog.Info("log level set", "new-level", g.slogLevel.Level())
})
- // cron is called periodically by a Cloud Scheduler job.
- http.HandleFunc("/cron", func(w http.ResponseWriter, r *http.Request) {
- g.slog.Info("cron start")
- defer g.slog.Info("cron end")
+ // cronEndpoint is called periodically by a Cloud Scheduler job.
+ http.HandleFunc("/"+cronEndpoint, func(w http.ResponseWriter, r *http.Request) {
+ g.slog.Info(cronEndpoint + " start")
+ defer g.slog.Info(cronEndpoint + " end")
- g.db.Lock("gabycron")
- defer g.db.Unlock("gabycron")
+ const cronLock = "gabycron"
+ g.db.Lock(cronLock)
+ defer g.db.Unlock(cronLock)
for _, cron := range g.crons {
cron(g.ctx)
}
- cronCounter.Add(r.Context(), 1)
+ cronEndpointCounter.Add(r.Context(), 1)
+ })
+
+ // githubEventEndpoint is called by a GitHub webhook when a new
+ // event occurs on the golang/go repo.
+ http.HandleFunc("/"+githubEventEndpoint, func(w http.ResponseWriter, r *http.Request) {
+ g.slog.Info(githubEventEndpoint + " start")
+ defer g.slog.Info(githubEventEndpoint + " end")
+
+ const githubEventLock = "gabygithubevent"
+ g.db.Lock(githubEventLock)
+ defer g.db.Unlock(githubEventLock)
+
+ if err := g.handleGitHubEvent(r); err != nil {
+ slog.Warn(githubEventEndpoint, "err", err)
+ }
+
+ githubEventEndpointCounter.Add(r.Context(), 1)
})
// /search: display a form for vector similarity search.
diff --git a/internal/gaby/metrics.go b/internal/gaby/metrics.go
index bff0b1f..8bdb72c 100644
--- a/internal/gaby/metrics.go
+++ b/internal/gaby/metrics.go
@@ -6,6 +6,7 @@
import (
"context"
+ "fmt"
"go.opentelemetry.io/otel/attribute"
ometric "go.opentelemetry.io/otel/metric"
@@ -23,6 +24,14 @@
return c
}
+// newEndpointCounter creates an integer counter instrument, intended
+// to count the number of times the given endpoint is requested.
+// It panics if the counter cannot be created.
+func (g *Gaby) newEndpointCounter(endpoint string) ometric.Int64Counter {
+ name, desc := fmt.Sprintf("%ss", endpoint), fmt.Sprintf("number of /%s requests", endpoint)
+ return g.newCounter(name, desc)
+}
+
// registerWatcherMetric adds a metric called "watcher-latest" for the latest times of Watchers.
// The latests map contains the functions to compute the latest times, each labeled
// by a string which becomes the value of the "name" attribute in the metric.
diff --git a/internal/github/webhook.go b/internal/github/webhook.go
new file mode 100644
index 0000000..e1a55bd
--- /dev/null
+++ b/internal/github/webhook.go
@@ -0,0 +1,165 @@
+// 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 (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "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 the raw payload.
+//
+// It is intended to validate authenticated POST requests received
+// from GitHub webhooks.
+//
+// It expects:
+// - a POST request with a non-empty body
+// - a "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) ([]byte, error) {
+ if r.Method != http.MethodPost {
+ return nil, fmt.Errorf("unexpected HTTP method %s, want %s", r.Method, http.MethodPost)
+ }
+
+ if r.Body == nil {
+ return nil, errNoPayload
+ }
+
+ data, err := io.ReadAll(r.Body)
+ r.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(data) == 0 {
+ return nil, errNoPayload
+ }
+
+ key, ok := db.Get(githubWebhookSecretName)
+ if !ok {
+ return nil, errNoKey
+ }
+
+ mac, err := parseMAC(&r.Header)
+ if err != nil {
+ return nil, err
+ }
+
+ if !validMAC(data, mac, []byte(key)) {
+ return nil, errInvalidHMAC
+ }
+
+ return data, nil
+}
+
+const (
+ githubWebhookSecretName = "github-webhook"
+ xHubSignature256Name = "X-Hub-Signature-256"
+)
+
+var (
+ errNoKey = fmt.Errorf("no secret for %q", githubWebhookSecretName)
+ errNoPayload = errors.New("empty payload")
+ errInvalidHMAC = errors.New("invalid HMAC")
+ errNoHeader = fmt.Errorf("missing %q header entry", xHubSignature256Name)
+ errMalformedHeader = fmt.Errorf("malformed %q header entry", xHubSignature256Name)
+)
+
+// parseMAC reads the value of the SHA-256 HMAC tag from the
+// X-Hub-Signature-256 header entry of h, which must be of the
+// form "sha256=HMAC", where HMAC is a hex-encoded HMAC tag.
+// It returns an error if the header entry is not present or malformed.
+func parseMAC(h *http.Header) ([]byte, error) {
+ entry := h.Get(xHubSignature256Name)
+ if entry == "" {
+ return nil, errNoHeader
+ }
+ hexMAC, ok := strings.CutPrefix(entry, "sha256=")
+ if !ok {
+ return nil, errMalformedHeader
+ }
+ return hex.DecodeString(hexMAC)
+}
+
+// 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 the body of the returned request.
+//
+// For testing.
+func ValidWebhookTestdata(t *testing.T, payload string) (*http.Request, secret.DB) {
+ key := "test-key"
+ signature := computeXHubSignature256(t, payload, key)
+ return newWebhookRequest(t, signature, payload), 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, key string) string {
+ t.Helper()
+
+ h := computeMAC([]byte(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, xHubSignature256, payload string) *http.Request {
+ t.Helper()
+
+ r, err := http.NewRequest(http.MethodPost, "", strings.NewReader(payload))
+ if err != nil {
+ t.Fatal("could not create test request")
+ }
+ if xHubSignature256 != "" {
+ r.Header.Set(xHubSignature256Name, xHubSignature256)
+ }
+ return r
+}
diff --git a/internal/github/webhook_test.go b/internal/github/webhook_test.go
new file mode 100644
index 0000000..69ec51b
--- /dev/null
+++ b/internal/github/webhook_test.go
@@ -0,0 +1,123 @@
+// 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"
+ "errors"
+ "testing"
+
+ "golang.org/x/oscar/internal/secret"
+)
+
+func TestValidateWebhookRequest(t *testing.T) {
+ // Example test case from GitHub
+ // (https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries#testing-the-webhook-payload-validation).
+ defaultHeaderEntry := "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
+ defaultPayload := "Hello, World!"
+ defaultKey := "It's a Secret to Everybody"
+
+ t.Run("success", func(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ headerEntry string
+ payload string
+ key string
+ }{
+ {
+ name: "hardcoded",
+ headerEntry: defaultHeaderEntry,
+ payload: defaultPayload,
+ key: defaultKey,
+ },
+ {
+ name: "computed",
+ headerEntry: computeXHubSignature256(t, "a payload", "a key"),
+ payload: "a payload",
+ key: "a key",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ r := newWebhookRequest(t, tc.headerEntry, tc.payload)
+ db := newWebhookSecretDB(t, tc.key)
+
+ got, err := ValidateWebhookRequest(r, db)
+ if err != nil {
+ t.Fatalf("ValidateWebhookRequest err = %s, want nil", err)
+ }
+ want := []byte(tc.payload)
+ if !bytes.Equal(got, want) {
+ t.Errorf("ValidateWebhookRequest = %q, want %q", got, want)
+ }
+ })
+ }
+ })
+
+ t.Run("error", func(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ headerEntry string
+ payload string
+ key string
+ wantErr error
+ }{
+ {
+ name: "no X-Hub-Signature-256 header entry",
+ headerEntry: "",
+ payload: defaultPayload,
+ key: defaultKey,
+ wantErr: errNoHeader,
+ },
+ {
+ name: "malformed X-Hub-Signature-256 header entry",
+ headerEntry: "sha=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17",
+ payload: defaultPayload,
+ key: defaultKey,
+ wantErr: errMalformedHeader,
+ },
+ {
+ name: "wrong payload",
+ headerEntry: defaultHeaderEntry,
+ payload: "a different payload",
+ key: defaultKey,
+ wantErr: errInvalidHMAC,
+ },
+ {
+ name: "wrong key",
+ headerEntry: defaultHeaderEntry,
+ payload: defaultPayload,
+ key: "a different key",
+ wantErr: errInvalidHMAC,
+ },
+ {
+ name: "no key",
+ headerEntry: defaultHeaderEntry,
+ payload: defaultPayload,
+ key: "",
+ wantErr: errNoKey,
+ },
+ {
+ name: "no payload",
+ headerEntry: defaultHeaderEntry,
+ payload: "",
+ key: defaultKey,
+ wantErr: errNoPayload,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ r := newWebhookRequest(t, tc.headerEntry, tc.payload)
+ db := secret.Empty()
+ if tc.key != "" {
+ db = newWebhookSecretDB(t, tc.key)
+ }
+
+ _, err := ValidateWebhookRequest(r, db)
+ if !errors.Is(err, tc.wantErr) {
+ t.Errorf("ValidateWebhookRequest err = %v, want error %v", err, tc.wantErr)
+ }
+ })
+ }
+ })
+}