Add webhook secret validation

Resolves: #4

Signed-off-by: Steven Kriegler <sk.bunsenbrenner@gmail.com>
This commit is contained in:
justusbunsi 2022-05-21 19:03:56 +02:00
parent e203034228
commit 5cb3daab60
No known key found for this signature in database
GPG key ID: 82B29BF2507F9F8B
10 changed files with 234 additions and 14 deletions

View file

@ -9,6 +9,7 @@ import (
giteaSdk "gitea-sonarqube-pr-bot/internal/clients/gitea"
sqSdk "gitea-sonarqube-pr-bot/internal/clients/sonarqube"
"gitea-sonarqube-pr-bot/internal/settings"
webhook "gitea-sonarqube-pr-bot/internal/webhooks/gitea"
)
@ -47,6 +48,14 @@ func (h *GiteaWebhookHandler) HandleSynchronize(rw http.ResponseWriter, r *http.
return
}
ok, err := isValidWebhook(raw, settings.Gitea.Webhook.Secret, r.Header.Get("X-Gitea-Signature"), "Gitea")
if !ok {
log.Print(err.Error())
rw.WriteHeader(http.StatusPreconditionFailed)
io.WriteString(rw, fmt.Sprint(`{"message": "Webhook validation failed. Request rejected."}`))
return
}
w, ok := webhook.NewPullWebhook(raw)
if !ok {
rw.WriteHeader(http.StatusUnprocessableEntity)
@ -74,6 +83,14 @@ func (h *GiteaWebhookHandler) HandleComment(rw http.ResponseWriter, r *http.Requ
return
}
ok, err := isValidWebhook(raw, settings.Gitea.Webhook.Secret, r.Header.Get("X-Gitea-Signature"), "Gitea")
if !ok {
log.Print(err.Error())
rw.WriteHeader(http.StatusPreconditionFailed)
io.WriteString(rw, `{"message": "Webhook validation failed. Request rejected."}`)
return
}
w, ok := webhook.NewCommentWebhook(raw)
if !ok {
rw.WriteHeader(http.StatusUnprocessableEntity)

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,41 @@
package api
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
)
func isValidWebhook(message []byte, key string, signature string, component string) (bool, error) {
log.Printf("'%s'", signature)
if key == "" && signature == "" {
// No webhook token configured and no signature header received. Skipping request validation.
return true, nil
}
if key == "" && signature != "" {
return false, fmt.Errorf("Signature header received but no %s webhook secret configured. Request rejected due to possible configuration mismatch.", component)
}
if key != "" && signature == "" {
return false, fmt.Errorf("%s webhook secret configured but no signature header received. Request rejected due to possible configuration mismatch.", component)
}
decodedSignature, err := hex.DecodeString(signature)
if err != nil {
return false, fmt.Errorf("Error decoding signature for %s webhook.", component)
}
mac := hmac.New(sha256.New, []byte(key))
mac.Write(message)
sum := mac.Sum(nil)
if !hmac.Equal(decodedSignature, sum) {
return false, fmt.Errorf("Signature header does not match the received %s webhook content. Request rejected.", component)
}
return true, nil
}

View file

@ -0,0 +1,46 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func getRequestData() []byte {
return []byte(`{"serverUrl":"https://example.com","status":"SUCCESS","analysedAt":"2022-05-15T16:45:31+0000","revision":"378080777919s07657a07f7a3e2d05dc75f64edd","changedAt":"2022-05-15T16:41:39+0000","project":{"key":"gitea-sonarqube-bot","name":"Gitea SonarQube Bot","url":"https://example.com/dashboard?id=gitea-sonarqube-bot"},"branch":{"name":"PR-1822","type":"PULL_REQUEST","isMain":false,"url":"https://example.com/dashboard?id=gitea-sonarqube-bot&pullRequest=PR-1822"},"qualityGate":{"name":"GiteaSonarQubeBot","status":"OK","conditions":[{"metric":"new_reliability_rating","operator":"GREATER_THAN","value":"1","status":"OK","errorThreshold":"1"},{"metric":"new_security_rating","operator":"GREATER_THAN","value":"1","status":"OK","errorThreshold":"1"},{"metric":"new_maintainability_rating","operator":"GREATER_THAN","value":"1","status":"OK","errorThreshold":"1"},{"metric":"new_security_hotspots_reviewed","operator":"LESS_THAN","status":"OK","errorThreshold":"100"}]},"properties":{"sonar.analysis.sqbot":"378080777919s07657a07f7a3e2d05dc75f64edd"}}`)
}
func TestIsValidWebhookSuccess(t *testing.T) {
actual, _ := isValidWebhook(getRequestData(), "sonarqube-test-webhook-secret", "647f2395d30b1b7efcb58d9338be5b69c2addb54faf6bde6314a57ea28f45467", "test-component")
assert.True(t, actual, "Expected successful webhook signature validation")
}
func TestIsValidWebhookNothingConfiguredOrProvidedSuccess(t *testing.T) {
actual, _ := isValidWebhook(getRequestData(), "", "", "test-component")
assert.True(t, actual, "Webhook signature validation not skipped")
}
func TestIsValidWebhookSignatureDecodingFailure(t *testing.T) {
actual, err := isValidWebhook(getRequestData(), "sonarqube-test-webhook-secret", "invalid-signature", "test-component")
assert.False(t, actual)
assert.EqualError(t, err, "Error decoding signature for test-component webhook.", "Undetected signature encoding error")
}
func TestIsValidWebhookSignatureMismatchFailure(t *testing.T) {
actual, err := isValidWebhook(getRequestData(), "sonarqube-test-webhook-secret", "fde6a666b7a1a46c27efb1961c17b46b6cf7aa13db5560e5ac95e801a18a92f3", "test-component")
assert.False(t, actual)
assert.EqualError(t, err, "Signature header does not match the received test-component webhook content. Request rejected.", "Undetected signature mismatch")
// assert.EqualError(t, err, "Signature header received but no test-component webhook secret configured. Request rejected due to possible configuration mismatch.", "Undetected configuration mismatch (1)")
}
func TestIsValidWebhookEmptySecretConfigurationFailure(t *testing.T) {
actual, err := isValidWebhook(getRequestData(), "", "647f2395d30b1b7efcb58d9338be5b69c2addb54faf6bde6314a57ea28f45467", "test-component")
assert.False(t, actual)
assert.EqualError(t, err, "Signature header received but no test-component webhook secret configured. Request rejected due to possible configuration mismatch.", "Undetected configuration mismatch (1)")
}
func TestIsValidWebhookEmptySignatureConfigurationFailure(t *testing.T) {
actual, err := isValidWebhook(getRequestData(), "sonarqube-test-webhook-secret", "", "test-component")
assert.False(t, actual)
assert.EqualError(t, err, "test-component webhook secret configured but no signature header received. Request rejected due to possible configuration mismatch.", "Undetected configuration mismatch (2)")
}

View file

@ -83,6 +83,14 @@ func (h *SonarQubeWebhookHandler) Handle(rw http.ResponseWriter, r *http.Request
return
}
ok, err := isValidWebhook(raw, settings.SonarQube.Webhook.Secret, r.Header.Get("X-Sonar-Webhook-HMAC-SHA256"), "SonarQube")
if !ok {
log.Print(err.Error())
rw.WriteHeader(http.StatusPreconditionFailed)
io.WriteString(rw, `{"message": "Webhook validation failed. Request rejected."}`)
return
}
w, ok := webhook.New(raw)
if !ok {
rw.WriteHeader(http.StatusUnprocessableEntity)

View file

@ -27,6 +27,11 @@ func withValidSonarQubeRequestData(t *testing.T, jsonBody []byte) (*http.Request
}
func TestHandleSonarQubeWebhookProjectMapped(t *testing.T) {
settings.SonarQube = settings.SonarQubeConfig{
Webhook: &settings.Webhook{
Secret: "",
},
}
settings.Projects = []settings.Project{
{
SonarQube: struct{ Key string }{
@ -72,7 +77,33 @@ func TestHandleSonarQubeWebhookInvalidJSONBody(t *testing.T) {
assert.Equal(t, `{"message": "Error parsing POST body."}`, rr.Body.String())
}
func TestHandleSonarQubeWebhookInvalidWebhookSignature(t *testing.T) {
settings.SonarQube = settings.SonarQubeConfig{
Webhook: &settings.Webhook{
Secret: "sonarqube-test-webhook-secret",
},
}
settings.Projects = []settings.Project{
{
SonarQube: struct{ Key string }{
Key: "pr-bot",
},
},
}
req, rr, handler := withValidSonarQubeRequestData(t, []byte(`{ "serverUrl": "https://example.com/sonarqube", "taskId": "AXouyxDpizdp4B1K", "status": "SUCCESS", "analysedAt": "2021-05-21T12:12:07+0000", "revision": "f84442009c09b1adc278b6aa80a3853419f54007", "changedAt": "2021-05-21T12:12:07+0000", "project": { "key": "pr-bot", "name": "PR Bot", "url": "https://example.com/sonarqube/dashboard?id=pr-bot" }, "branch": { "name": "PR-1337", "type": "PULL_REQUEST", "isMain": false, "url": "https://example.com/sonarqube/dashboard?id=pr-bot&pullRequest=PR-1337" }, "qualityGate": { "name": "PR Bot", "status": "OK", "conditions": [ { "metric": "new_reliability_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_security_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_maintainability_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_security_hotspots_reviewed", "operator": "LESS_THAN", "status": "NO_VALUE", "errorThreshold": "100" } ] }, "properties": {} }`))
req.Header.Set("X-Sonar-Webhook-HMAC-SHA256", "647f2395d30b1b7efcb58d9338be5b69c2addb54faf6bde6314a57ea28f45467")
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusPreconditionFailed, rr.Code)
assert.Equal(t, `{"message": "Webhook validation failed. Request rejected."}`, rr.Body.String())
}
func TestHandleSonarQubeWebhookForPullRequest(t *testing.T) {
settings.SonarQube = settings.SonarQubeConfig{
Webhook: &settings.Webhook{
Secret: "",
},
}
settings.Projects = []settings.Project{
{
SonarQube: struct{ Key string }{
@ -89,6 +120,11 @@ func TestHandleSonarQubeWebhookForPullRequest(t *testing.T) {
}
func TestHandleSonarQubeWebhookForBranch(t *testing.T) {
settings.SonarQube = settings.SonarQubeConfig{
Webhook: &settings.Webhook{
Secret: "",
},
}
settings.Projects = []settings.Project{
{
SonarQube: struct{ Key string }{

View file

@ -5,7 +5,7 @@ type GiteaRepository struct {
Name string
}
type giteaConfig struct {
type GiteaConfig struct {
Url string
Token *Token
Webhook *Webhook

View file

@ -8,8 +8,8 @@ import (
)
var (
Gitea giteaConfig
SonarQube sonarQubeConfig
Gitea GiteaConfig
SonarQube SonarQubeConfig
Projects []Project
)
@ -62,12 +62,12 @@ func Load(configPath string) {
errCallback := func(msg string) { panic(msg) }
Gitea = giteaConfig{
Gitea = GiteaConfig{
Url: r.GetString("gitea.url"),
Token: NewToken(r.GetString, "gitea", errCallback),
Webhook: NewWebhook(r.GetString, "gitea", errCallback),
}
SonarQube = sonarQubeConfig{
SonarQube = SonarQubeConfig{
Url: r.GetString("sonarqube.url"),
Token: NewToken(r.GetString, "sonarqube", errCallback),
Webhook: NewWebhook(r.GetString, "sonarqube", errCallback),

View file

@ -56,7 +56,7 @@ func TestLoadGiteaStructure(t *testing.T) {
WriteConfigFile(t, defaultConfig)
Load(os.TempDir())
expected := giteaConfig{
expected := GiteaConfig{
Url: "https://example.com/gitea",
Token: &Token{
Value: "d0fcdeb5eaa99c506831f9eb4e63fc7cc484a565",
@ -75,7 +75,7 @@ func TestLoadGiteaStructureInjectedEnvs(t *testing.T) {
WriteConfigFile(t, defaultConfig)
Load(os.TempDir())
expected := giteaConfig{
expected := GiteaConfig{
Url: "https://example.com/gitea",
Token: &Token{
Value: "injected-token",
@ -97,7 +97,7 @@ func TestLoadSonarQubeStructure(t *testing.T) {
WriteConfigFile(t, defaultConfig)
Load(os.TempDir())
expected := sonarQubeConfig{
expected := SonarQubeConfig{
Url: "https://example.com/sonarqube",
Token: &Token{
Value: "a09eb5785b25bb2cbacf48808a677a0709f02d8e",
@ -131,7 +131,7 @@ projects:
`))
Load(os.TempDir())
expected := sonarQubeConfig{
expected := SonarQubeConfig{
Url: "https://example.com/sonarqube",
Token: &Token{
Value: "fake-sonarqube-token",
@ -155,7 +155,7 @@ func TestLoadSonarQubeStructureInjectedEnvs(t *testing.T) {
WriteConfigFile(t, defaultConfig)
Load(os.TempDir())
expected := sonarQubeConfig{
expected := SonarQubeConfig{
Url: "https://example.com/sonarqube",
Token: &Token{
Value: "injected-token",
@ -207,7 +207,7 @@ projects:
os.Setenv("PRBOT_SONARQUBE_WEBHOOK_SECRETFILE", sonarqubeWebhookSecretFile)
os.Setenv("PRBOT_SONARQUBE_TOKEN_FILE", sonarqubeTokenFile)
expectedGitea := giteaConfig{
expectedGitea := GiteaConfig{
Url: "https://example.com/gitea",
Token: &Token{
Value: "d0fcdeb5eaa99c506831f9eb4e63fc7cc484a565",
@ -219,7 +219,7 @@ projects:
},
}
expectedSonarQube := sonarQubeConfig{
expectedSonarQube := SonarQubeConfig{
Url: "https://example.com/sonarqube",
Token: &Token{
Value: "a09eb5785b25bb2cbacf48808a677a0709f02d8e",

View file

@ -2,14 +2,14 @@ package settings
import "strings"
type sonarQubeConfig struct {
type SonarQubeConfig struct {
Url string
Token *Token
Webhook *Webhook
AdditionalMetrics []string
}
func (c *sonarQubeConfig) GetMetricsList() string {
func (c *SonarQubeConfig) GetMetricsList() string {
metrics := []string{
"bugs",
"vulnerabilities",