diff --git a/github/messages.go b/github/messages.go new file mode 100644 index 0000000..9f0aba9 --- /dev/null +++ b/github/messages.go @@ -0,0 +1,119 @@ +// Copyright 2016 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This file provides functions for validating payloads from GitHub Webhooks. +// GitHub docs: https://developer.github.com/webhooks/securing/#validating-payloads-from-github + +package github + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "errors" + "fmt" + "hash" + "io/ioutil" + "net/http" + "strings" +) + +const ( + // sha1Prefix is the prefix used by GitHub before the HMAC hexdigest. + sha1Prefix = "sha1" + // sha256Prefix and sha512Prefix are provided for future compatibility. + sha256Prefix = "sha256" + sha512Prefix = "sha512" + // signatureHeader is the GitHub header key used to pass the HMAC hexdigest. + signatureHeader = "X-Hub-Signature" +) + +// genMAC generates the HMAC signature for a message provided the secret key +// and hashFunc. +func genMAC(message, key []byte, hashFunc func() hash.Hash) []byte { + mac := hmac.New(hashFunc, key) + mac.Write(message) + return mac.Sum(nil) +} + +// checkMAC reports whether messageMAC is a valid HMAC tag for message. +func checkMAC(message, messageMAC, key []byte, hashFunc func() hash.Hash) bool { + expectedMAC := genMAC(message, key, hashFunc) + return hmac.Equal(messageMAC, expectedMAC) +} + +// messageMAC returns the hex-decoded HMAC tag from the signature and its +// corresponding hash function. +func messageMAC(signature string) ([]byte, func() hash.Hash, error) { + if signature == "" { + return nil, nil, errors.New("missing signature") + } + sigParts := strings.SplitN(signature, "=", 2) + if len(sigParts) != 2 { + return nil, nil, fmt.Errorf("error parsing signature %q", signature) + } + + var hashFunc func() hash.Hash + switch sigParts[0] { + case sha1Prefix: + hashFunc = sha1.New + case sha256Prefix: + hashFunc = sha256.New + case sha512Prefix: + hashFunc = sha512.New + default: + return nil, nil, fmt.Errorf("unknown hash type prefix: %q", sigParts[0]) + } + + buf, err := hex.DecodeString(sigParts[1]) + if err != nil { + return nil, nil, fmt.Errorf("error decoding signature %q: %v", signature, err) + } + return buf, hashFunc, nil +} + +// ValidatePayload validates an incoming GitHub Webhook event request +// and returns the (JSON) payload. +// secretKey is the GitHub Webhook secret message. +// +// Example usage: +// +// func (s *GitHubEventMonitor) ServeHTTP(w http.ResponseWriter, r *http.Request) { +// payload, err := github.ValidatePayload(r, s.webhookSecretKey) +// if err != nil { ... } +// // Process payload... +// } +// +func ValidatePayload(r *http.Request, secretKey []byte) (payload []byte, err error) { + payload, err = ioutil.ReadAll(r.Body) + if err != nil { + return nil, err + } + + sig := r.Header.Get(signatureHeader) + if err := validateSignature(sig, payload, secretKey); err != nil { + return nil, err + } + return payload, nil +} + +// validateSignature validates the signature for the given payload. +// signature is the GitHub hash signature delivered in the X-Hub-Signature header. +// payload is the JSON payload sent by GitHub Webhooks. +// secretKey is the GitHub Webhook secret message. +// +// GitHub docs: https://developer.github.com/webhooks/securing/#validating-payloads-from-github +func validateSignature(signature string, payload, secretKey []byte) error { + messageMAC, hashFunc, err := messageMAC(signature) + if err != nil { + return err + } + if !checkMAC(payload, messageMAC, secretKey, hashFunc) { + return errors.New("payload signature check failed") + } + return nil +} diff --git a/github/messages_test.go b/github/messages_test.go new file mode 100644 index 0000000..5373b6a --- /dev/null +++ b/github/messages_test.go @@ -0,0 +1,81 @@ +// Copyright 2016 The go-github 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" + "net/http" + "testing" +) + +func TestValidatePayload(t *testing.T) { + const defaultBody = `{"yo":true}` // All tests below use the default request body and signature. + const defaultSignature = "sha1=126f2c800419c60137ce748d7672e77b65cf16d6" + secretKey := []byte("0123456789abcdef") + tests := []struct { + signature string + eventID string + event string + wantEventID string + wantEvent string + wantPayload string + }{ + // The following tests generate expected errors: + {}, // Missing signature + {signature: "yo"}, // Missing signature prefix + {signature: "sha1=yo"}, // Signature not hex string + {signature: "sha1=012345"}, // Invalid signature + // The following tests expect err=nil: + { + signature: defaultSignature, + eventID: "dead-beef", + event: "ping", + wantEventID: "dead-beef", + wantEvent: "ping", + wantPayload: defaultBody, + }, + { + signature: defaultSignature, + event: "ping", + wantEvent: "ping", + wantPayload: defaultBody, + }, + { + signature: "sha256=b1f8020f5b4cd42042f807dd939015c4a418bc1ff7f604dd55b0a19b5d953d9b", + event: "ping", + wantEvent: "ping", + wantPayload: defaultBody, + }, + { + signature: "sha512=8456767023c1195682e182a23b3f5d19150ecea598fde8cb85918f7281b16079471b1329f92b912c4d8bd7455cb159777db8f29608b20c7c87323ba65ae62e1f", + event: "ping", + wantEvent: "ping", + wantPayload: defaultBody, + }, + } + + for _, test := range tests { + buf := bytes.NewBufferString(defaultBody) + req, err := http.NewRequest("GET", "http://localhost/event", buf) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + if test.signature != "" { + req.Header.Set(signatureHeader, test.signature) + } + + got, err := ValidatePayload(req, secretKey) + if err != nil { + if test.wantPayload != "" { + t.Errorf("ValidatePayload(%#v): err = %v, want nil", test, err) + } + continue + } + if string(got) != test.wantPayload { + t.Errorf("ValidatePayload = %q, want %q", got, test.wantPayload) + } + } +}