Add webhook secret validation
Resolves: #4 Signed-off-by: Steven Kriegler <sk.bunsenbrenner@gmail.com>
This commit is contained in:
parent
e203034228
commit
5cb3daab60
|
@ -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
41
internal/api/request_validation.go
Normal file
41
internal/api/request_validation.go
Normal 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
|
||||
}
|
46
internal/api/request_validation_test.go
Normal file
46
internal/api/request_validation_test.go
Normal 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)")
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 }{
|
||||
|
|
|
@ -5,7 +5,7 @@ type GiteaRepository struct {
|
|||
Name string
|
||||
}
|
||||
|
||||
type giteaConfig struct {
|
||||
type GiteaConfig struct {
|
||||
Url string
|
||||
Token *Token
|
||||
Webhook *Webhook
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue