Skip to content

Commit 0acda12

Browse files
authored
Support validating webhook signature (#12)
1 parent 2b44dc6 commit 0acda12

File tree

6 files changed

+90
-6
lines changed

6 files changed

+90
-6
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ $ mkdir -p custom/conf
3838
$ touch custom/conf/app.ini
3939
```
4040

41-
Please refer to [Local development > Step 2: Create a test GitHub App](#step-2-create-a-test-github-app) for creating a GitHub App, setting up a reverse proxy and filling out necessary configuration options.
41+
Please refer to [Local development > Step 2: Create a test GitHub App](#step-2-create-a-test-github-app) for creating a GitHub App, setting up a reverse proxy and filling out necessary configuration options. View [`conf/app.ini`](conf/app.ini) for all available configuration options.
4242

4343
> **Note**
4444
> The [Caddy web server](https://caddyserver.com/) is recommended for production use with automatic HTTPS.

conf/app.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ CLIENT_ID =
1515
CLIENT_SECRET =
1616
; The "Private key" of the GitHub App.
1717
PRIVATE_KEY =
18+
; The "Webhook secret" of the GitHub App.
19+
WEBHOOK_SECRET =
1820

1921
; Configuration of the Codenotify.
2022
[codenotify]

github.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ package main
77
import (
88
"bytes"
99
"context"
10+
"crypto/hmac"
1011
"crypto/rand"
12+
"crypto/sha256"
13+
"crypto/subtle"
14+
"encoding/hex"
1115
"fmt"
1216
"net/http"
1317
"net/url"
@@ -26,6 +30,22 @@ import (
2630
"github.com/codenotify/codenotify.run/internal/conf"
2731
)
2832

33+
// validateGitHubWebhookSignature256 returns true if the signature matches the
34+
// HMAC hex digested SHA256 hash of the body using the given key.
35+
func validateGitHubWebhookSignature256(signature, key string, body []byte) (bool, error) {
36+
signature = strings.TrimPrefix(signature, "sha256=")
37+
m := hmac.New(sha256.New, []byte(key))
38+
if _, err := m.Write(body); err != nil {
39+
return false, err
40+
}
41+
got := hex.EncodeToString(m.Sum(nil))
42+
43+
// NOTE: Use constant time string comparison helps mitigate certain timing
44+
// attacks against regular equality operators, see
45+
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks#validating-payloads-from-github
46+
return subtle.ConstantTimeCompare([]byte(signature), []byte(got)) == 1, nil
47+
}
48+
2949
func newGitHubClient(ctx context.Context, appID, installationID int64, privateKey string) (*github.Client, string, error) {
3050
tr, err := ghinstallation.NewAppsTransport(http.DefaultTransport, appID, []byte(privateKey))
3151
if err != nil {

github_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright 2022 Unknwon. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package main
6+
7+
import (
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestValidateGitHubWebhookSignature256(t *testing.T) {
14+
//nolint:misspell
15+
const payload = `{"ref":"refs/heads/main","before":"07da1e122bdbb81da8499b6d82c6b6302581a5a7","after":"5a96148ac5ef11a53b838b8cc0d9c929420657f3","repository":{"id":470746482,"node_id":"R_kgDOHA8Fcg","name":"bytebase-test","full_name":"unknwon/bytebase-test","private":true,"owner":{"name":"unknwon","email":"[email protected]","login":"unknwon","id":2946214,"node_id":"MDQ6VXNlcjI5NDYyMTQ=","avatar_url":"https://avatars.githubusercontent.com/u/2946214?v=4","gravatar_id":"","url":"https://api.github.com/users/unknwon","html_url":"https://github.com/unknwon","followers_url":"https://api.github.com/users/unknwon/followers","following_url":"https://api.github.com/users/unknwon/following{/other_user}","gists_url":"https://api.github.com/users/unknwon/gists{/gist_id}","starred_url":"https://api.github.com/users/unknwon/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/unknwon/subscriptions","organizations_url":"https://api.github.com/users/unknwon/orgs","repos_url":"https://api.github.com/users/unknwon/repos","events_url":"https://api.github.com/users/unknwon/events{/privacy}","received_events_url":"https://api.github.com/users/unknwon/received_events","type":"User","site_admin":false},"html_url":"https://github.com/unknwon/bytebase-test","description":null,"fork":false,"url":"https://github.com/unknwon/bytebase-test","forks_url":"https://api.github.com/repos/unknwon/bytebase-test/forks","keys_url":"https://api.github.com/repos/unknwon/bytebase-test/keys{/key_id}","collaborators_url":"https://api.github.com/repos/unknwon/bytebase-test/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/unknwon/bytebase-test/teams","hooks_url":"https://api.github.com/repos/unknwon/bytebase-test/hooks","issue_events_url":"https://api.github.com/repos/unknwon/bytebase-test/issues/events{/number}","events_url":"https://api.github.com/repos/unknwon/bytebase-test/events","assignees_url":"https://api.github.com/repos/unknwon/bytebase-test/assignees{/user}","branches_url":"https://api.github.com/repos/unknwon/bytebase-test/branches{/branch}","tags_url":"https://api.github.com/repos/unknwon/bytebase-test/tags","blobs_url":"https://api.github.com/repos/unknwon/bytebase-test/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/unknwon/bytebase-test/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/unknwon/bytebase-test/git/refs{/sha}","trees_url":"https://api.github.com/repos/unknwon/bytebase-test/git/trees{/sha}","statuses_url":"https://api.github.com/repos/unknwon/bytebase-test/statuses/{sha}","languages_url":"https://api.github.com/repos/unknwon/bytebase-test/languages","stargazers_url":"https://api.github.com/repos/unknwon/bytebase-test/stargazers","contributors_url":"https://api.github.com/repos/unknwon/bytebase-test/contributors","subscribers_url":"https://api.github.com/repos/unknwon/bytebase-test/subscribers","subscription_url":"https://api.github.com/repos/unknwon/bytebase-test/subscription","commits_url":"https://api.github.com/repos/unknwon/bytebase-test/commits{/sha}","git_commits_url":"https://api.github.com/repos/unknwon/bytebase-test/git/commits{/sha}","comments_url":"https://api.github.com/repos/unknwon/bytebase-test/comments{/number}","issue_comment_url":"https://api.github.com/repos/unknwon/bytebase-test/issues/comments{/number}","contents_url":"https://api.github.com/repos/unknwon/bytebase-test/contents/{+path}","compare_url":"https://api.github.com/repos/unknwon/bytebase-test/compare/{base}...{head}","merges_url":"https://api.github.com/repos/unknwon/bytebase-test/merges","archive_url":"https://api.github.com/repos/unknwon/bytebase-test/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/unknwon/bytebase-test/downloads","issues_url":"https://api.github.com/repos/unknwon/bytebase-test/issues{/number}","pulls_url":"https://api.github.com/repos/unknwon/bytebase-test/pulls{/number}","milestones_url":"https://api.github.com/repos/unknwon/bytebase-test/milestones{/number}","notifications_url":"https://api.github.com/repos/unknwon/bytebase-test/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/unknwon/bytebase-test/labels{/name}","releases_url":"https://api.github.com/repos/unknwon/bytebase-test/releases{/id}","deployments_url":"https://api.github.com/repos/unknwon/bytebase-test/deployments","created_at":1647463607,"updated_at":"2022-03-16T20:46:47Z","pushed_at":1658671596,"git_url":"git://github.com/unknwon/bytebase-test.git","ssh_url":"[email protected]:unknwon/bytebase-test.git","clone_url":"https://github.com/unknwon/bytebase-test.git","svn_url":"https://github.com/unknwon/bytebase-test","homepage":null,"size":20,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":0,"license":{"key":"apache-2.0","name":"Apache License 2.0","spdx_id":"Apache-2.0","url":"https://api.github.com/licenses/apache-2.0","node_id":"MDc6TGljZW5zZTI="},"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"private","forks":0,"open_issues":0,"watchers":0,"default_branch":"main","stargazers":0,"master_branch":"main"},"pusher":{"name":"unknwon","email":"[email protected]"},"sender":{"login":"unknwon","id":2946214,"node_id":"MDQ6VXNlcjI5NDYyMTQ=","avatar_url":"https://avatars.githubusercontent.com/u/2946214?v=4","gravatar_id":"","url":"https://api.github.com/users/unknwon","html_url":"https://github.com/unknwon","followers_url":"https://api.github.com/users/unknwon/followers","following_url":"https://api.github.com/users/unknwon/following{/other_user}","gists_url":"https://api.github.com/users/unknwon/gists{/gist_id}","starred_url":"https://api.github.com/users/unknwon/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/unknwon/subscriptions","organizations_url":"https://api.github.com/users/unknwon/orgs","repos_url":"https://api.github.com/users/unknwon/repos","events_url":"https://api.github.com/users/unknwon/events{/privacy}","received_events_url":"https://api.github.com/users/unknwon/received_events","type":"User","site_admin":false},"created":false,"deleted":false,"forced":false,"base_ref":null,"compare":"https://github.com/unknwon/bytebase-test/compare/07da1e122bdb...5a96148ac5ef","commits":[{"id":"5a96148ac5ef11a53b838b8cc0d9c929420657f3","tree_id":"8a842b23b62886d2ee12c152eda741cf39b1ceef","distinct":true,"message":"Create testdb_dev__202101131000__baseline__create_tablefoo_for_bar.sql","timestamp":"2022-07-24T22:06:36+08:00","url":"https://github.com/unknwon/bytebase-test/commit/5a96148ac5ef11a53b838b8cc0d9c929420657f3","author":{"name":"Joe Chen","email":"[email protected]","username":"unknwon"},"committer":{"name":"GitHub","email":"[email protected]","username":"web-flow"},"added":["Dev/testdb_dev__202101131000__baseline__create_tablefoo_for_bar.sql"],"removed":[],"modified":[]}],"head_commit":{"id":"5a96148ac5ef11a53b838b8cc0d9c929420657f3","tree_id":"8a842b23b62886d2ee12c152eda741cf39b1ceef","distinct":true,"message":"Create testdb_dev__202101131000__baseline__create_tablefoo_for_bar.sql","timestamp":"2022-07-24T22:06:36+08:00","url":"https://github.com/unknwon/bytebase-test/commit/5a96148ac5ef11a53b838b8cc0d9c929420657f3","author":{"name":"Joe Chen","email":"[email protected]","username":"unknwon"},"committer":{"name":"GitHub","email":"[email protected]","username":"web-flow"},"added":["Dev/testdb_dev__202101131000__baseline__create_tablefoo_for_bar.sql"],"removed":[],"modified":[]}}`
16+
17+
t.Run("wrong key", func(t *testing.T) {
18+
got, err := validateGitHubWebhookSignature256(
19+
"sha256=6bf313c917fd04a3c6c85270bab6c2a6ae40b7ab37767107bf80ad5c6a0a0deb",
20+
"abadkey",
21+
[]byte(payload),
22+
)
23+
assert.False(t, got)
24+
assert.NoError(t, err)
25+
})
26+
27+
t.Run("wrong signature", func(t *testing.T) {
28+
got, err := validateGitHubWebhookSignature256(
29+
"sha256=8335bc69262e94b20753316d844e155ae4d7826a6c89f12e98083ed0dce8d057",
30+
"bZovosSKsJ8QKCG9",
31+
[]byte(payload),
32+
)
33+
assert.False(t, got)
34+
assert.NoError(t, err)
35+
})
36+
37+
t.Run("success", func(t *testing.T) {
38+
got, err := validateGitHubWebhookSignature256(
39+
"sha256=6bf313c917fd04a3c6c85270bab6c2a6ae40b7ab37767107bf80ad5c6a0a0deb",
40+
"bZovosSKsJ8QKCG9",
41+
[]byte(payload),
42+
)
43+
assert.True(t, got)
44+
assert.NoError(t, err)
45+
})
46+
}

internal/conf/conf.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ type Config struct {
3030
}
3131
// GitHubApp contains the GitHub App configuration.
3232
GitHubApp struct {
33-
AppID int64 `ini:"APP_ID"`
34-
ClientID string `ini:"CLIENT_ID"`
35-
ClientSecret string
36-
PrivateKey string
33+
AppID int64 `ini:"APP_ID"`
34+
ClientID string `ini:"CLIENT_ID"`
35+
ClientSecret string
36+
PrivateKey string
37+
WebhookSecret string
3738
}
3839
// Codenotify contains the Codenotify configuration.
3940
Codenotify struct {

main.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"context"
99
"encoding/json"
1010
"fmt"
11+
"io"
1112
"net/http"
1213
"os"
1314

@@ -55,8 +56,22 @@ func main() {
5556
return http.StatusOK, fmt.Sprintf("Event %q has been received but nothing to do", event)
5657
}
5758

59+
body, err := io.ReadAll(r.Body)
60+
if err != nil {
61+
return http.StatusInternalServerError, fmt.Sprintf("Failed to read request body: %v", err)
62+
}
63+
64+
if config.GitHubApp.WebhookSecret != "" {
65+
ok, err := validateGitHubWebhookSignature256(r.Header.Get("X-Hub-Signature-256"), config.GitHubApp.WebhookSecret, body)
66+
if err != nil {
67+
return http.StatusInternalServerError, fmt.Sprintf("Failed to validate signature: %v", err)
68+
} else if !ok {
69+
return http.StatusBadRequest, `Mismatched payload signature for "X-Hub-Signature-256"`
70+
}
71+
}
72+
5873
var payload github.PullRequestEvent
59-
err = json.NewDecoder(r.Body).Decode(&payload)
74+
err = json.Unmarshal(body, &payload)
6075
if err != nil {
6176
return http.StatusBadRequest, fmt.Sprintf("Failed to decode payload: %v", err)
6277
}

0 commit comments

Comments
 (0)