├── .github └── workflows │ └── test.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── githubhook.go ├── githubhook_test.go └── go.mod /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | go-version: ["1.7", "1.x"] 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Setup Go ${{ matrix.go-version }} 15 | uses: actions/setup-go@v3 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | - name: Test 19 | run: go test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | =============================================================================== 3 | 4 | Have something to add? Feature requests, bug reports, and contributions are 5 | enormously welcome! 6 | 7 | 1. Fork this repo 8 | 2. Update the tests and implement the change 9 | 3. Submit a [pull request][github-pull-request] 10 | 11 | (hint: following the conventions in the [the code review 12 | checklist][code-review-checklist] will expedite review and merge) 13 | 14 | [github-pull-request]: help.github.com/pull-requests/ 15 | [code-review-checklist]: https://github.com/rjz/code-review-checklist 16 | 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | // MIT License 2 | 3 | Copyright (C) RJ Zaworski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | githubhook 2 | =============================================== 3 | 4 | Golang parser for [github webhooks][gh-webhook]. Not a server, though it could 5 | be integrated with one. 6 | 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/rjz/githubhook)](https://goreportcard.com/report/github.com/rjz/githubhook) 8 | [![GoDoc](https://godoc.org/github.com/rjz/githubhook?status.svg)](https://godoc.org/github.com/rjz/githubhook) 9 | 10 | Installation 11 | ----------------------------------------------- 12 | 13 | ```ShellSession 14 | $ go get gopkg.in/rjz/githubhook.v0 15 | ``` 16 | 17 | Usage 18 | ----------------------------------------------- 19 | 20 | Given an incoming `*http.Request` representing a webhook signed with a `secret`, 21 | use `githubhook` to validate and parse its content: 22 | 23 | ```go 24 | secret := []byte("don't tell!") 25 | hook, err := githubhook.Parse(secret, req) 26 | ``` 27 | 28 | Plays nicely with the [google/go-github][gh-go-github] client! 29 | 30 | ```go 31 | evt := github.PullRequestEvent{} 32 | if err := json.Unmarshal(hook.Payload, &evt); err != nil { 33 | fmt.Println("Invalid JSON?", err) 34 | } 35 | ``` 36 | 37 | [gh-webhook]: https://developer.github.com/webhooks/ 38 | [gh-go-github]: https://github.com/google/go-github 39 | -------------------------------------------------------------------------------- /githubhook.go: -------------------------------------------------------------------------------- 1 | // Package githubhook implements handling and verification of github webhooks 2 | package githubhook 3 | 4 | import ( 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "encoding/json" 9 | "errors" 10 | "io/ioutil" 11 | "net/http" 12 | "strings" 13 | ) 14 | 15 | // Hook is an inbound github webhook 16 | type Hook struct { 17 | 18 | // Id specifies the Id of a github webhook request. 19 | // 20 | // Id is extracted from the inbound request's `X-Github-Delivery` header. 21 | Id string 22 | 23 | // Event specifies the event name of a github webhook request. 24 | // 25 | // Event is extracted from the inbound request's `X-GitHub-Event` header. 26 | // See: https://developer.github.com/webhooks/#events 27 | Event string 28 | 29 | // Signature specifies the signature of a github webhook request. 30 | // 31 | // Signature is extracted from the inbound request's `X-Hub-Signature` header. 32 | Signature string 33 | 34 | // Payload contains the raw contents of the webhook request. 35 | // 36 | // Payload is extracted from the JSON-formatted body of the inbound request. 37 | Payload []byte 38 | } 39 | 40 | const signaturePrefix = "sha256=" 41 | const prefixLength = len(signaturePrefix) 42 | const signatureLength = prefixLength + (sha256.Size * 2) 43 | 44 | func signBody(secret, body []byte) []byte { 45 | computed := hmac.New(sha256.New, secret) 46 | computed.Write(body) 47 | return []byte(computed.Sum(nil)) 48 | } 49 | 50 | // SignedBy checks that the provided secret matches the hook Signature 51 | // 52 | // Implements validation described in github's documentation: 53 | // https://developer.github.com/webhooks/securing/ 54 | func (h *Hook) SignedBy(secret []byte) bool { 55 | if len(h.Signature) != signatureLength || !strings.HasPrefix(h.Signature, signaturePrefix) { 56 | return false 57 | } 58 | 59 | actual := make([]byte, sha256.Size) 60 | hex.Decode(actual, []byte(h.Signature[prefixLength:])) 61 | 62 | expected := signBody(secret, h.Payload) 63 | 64 | return hmac.Equal(expected, actual) 65 | } 66 | 67 | // Extract hook's JSON payload into dst 68 | func (h *Hook) Extract(dst interface{}) error { 69 | return json.Unmarshal(h.Payload, dst) 70 | } 71 | 72 | // New reads a Hook from an incoming HTTP Request. 73 | func New(req *http.Request) (hook *Hook, err error) { 74 | hook = new(Hook) 75 | if !strings.EqualFold(req.Method, "POST") { 76 | return nil, errors.New("Unknown method!") 77 | } 78 | 79 | if hook.Signature = req.Header.Get("x-hub-signature-256"); len(hook.Signature) == 0 { 80 | return nil, errors.New("No signature!") 81 | } 82 | 83 | if hook.Event = req.Header.Get("x-github-event"); len(hook.Event) == 0 { 84 | return nil, errors.New("No event!") 85 | } 86 | 87 | if hook.Id = req.Header.Get("x-github-delivery"); len(hook.Id) == 0 { 88 | return nil, errors.New("No event Id!") 89 | } 90 | 91 | hook.Payload, err = ioutil.ReadAll(req.Body) 92 | return 93 | } 94 | 95 | // Parse reads and verifies the hook in an inbound request. 96 | func Parse(secret []byte, req *http.Request) (hook *Hook, err error) { 97 | hook, err = New(req) 98 | if err == nil && !hook.SignedBy(secret) { 99 | err = errors.New("Invalid signature") 100 | } 101 | return 102 | } 103 | -------------------------------------------------------------------------------- /githubhook_test.go: -------------------------------------------------------------------------------- 1 | package githubhook 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "fmt" 8 | "net/http" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | const testSecret = "foobar" 14 | 15 | func expectErrorMessage(t *testing.T, msg string, err error) { 16 | if err == nil || err.Error() != msg { 17 | t.Error(fmt.Sprintf("Expected '%s', got %s", msg, err)) 18 | } 19 | } 20 | 21 | func expectNewError(t *testing.T, msg string, r *http.Request) { 22 | _, err := New(r) 23 | expectErrorMessage(t, msg, err) 24 | } 25 | 26 | func expectParseError(t *testing.T, msg string, r *http.Request) { 27 | _, err := Parse([]byte(testSecret), r) 28 | expectErrorMessage(t, msg, err) 29 | } 30 | 31 | func signature(body string) string { 32 | dst := make([]byte, sha256.Size*2) 33 | computed := hmac.New(sha256.New, []byte(testSecret)) 34 | computed.Write([]byte(body)) 35 | hex.Encode(dst, computed.Sum(nil)) 36 | return signaturePrefix + string(dst) 37 | } 38 | 39 | func TestNonPost(t *testing.T) { 40 | r, _ := http.NewRequest("GET", "/path", nil) 41 | expectNewError(t, "Unknown method!", r) 42 | } 43 | 44 | func TestMissingSignature(t *testing.T) { 45 | r, _ := http.NewRequest("POST", "/path", nil) 46 | expectNewError(t, "No signature!", r) 47 | } 48 | 49 | func TestMissingEvent(t *testing.T) { 50 | r, _ := http.NewRequest("POST", "/path", nil) 51 | r.Header.Add("x-hub-signature-256", "bogus signature") 52 | expectNewError(t, "No event!", r) 53 | } 54 | 55 | func TestMissingEventId(t *testing.T) { 56 | r, _ := http.NewRequest("POST", "/path", nil) 57 | r.Header.Add("x-hub-signature-256", "bogus signature") 58 | r.Header.Add("x-github-event", "bogus event") 59 | expectNewError(t, "No event Id!", r) 60 | } 61 | 62 | func TestInvalidSignature(t *testing.T) { 63 | r, _ := http.NewRequest("POST", "/path", strings.NewReader("...")) 64 | r.Header.Add("x-hub-signature-256", "bogus signature") 65 | r.Header.Add("x-github-event", "bogus event") 66 | r.Header.Add("x-github-delivery", "bogus id") 67 | expectParseError(t, "Invalid signature", r) 68 | } 69 | 70 | func TestValidSignature(t *testing.T) { 71 | 72 | body := "{}" 73 | 74 | r, _ := http.NewRequest("POST", "/path", strings.NewReader(body)) 75 | r.Header.Add("x-hub-signature-256", signature(body)) 76 | r.Header.Add("x-github-event", "bogus event") 77 | r.Header.Add("x-github-delivery", "bogus id") 78 | 79 | if _, err := Parse([]byte(testSecret), r); err != nil { 80 | t.Error(fmt.Sprintf("Unexpected error '%s'", err)) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rjz/githubhook 2 | 3 | go 1.7 4 | --------------------------------------------------------------------------------