Skip to content

Commit

Permalink
feat: hmac 256 verification (#43)
Browse files Browse the repository at this point in the history
* feat: hmac 256 verification

* docs: extra verification readme docs
  • Loading branch information
didil committed Aug 9, 2023
1 parent fea7685 commit ed1906c
Show file tree
Hide file tree
Showing 14 changed files with 459 additions and 5 deletions.
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,26 @@ In case of failures, retries are attempted based on the sink config params.

If the config is modifed, the server must be restarted to load the new config.

### Securing webhooks
If you would like to verify your webhooks with HMAC 256, you can use the following configuration:

``` yaml
flows:
- id: flow-1
source:
id: source-1
slug: source-1-slug
type: http
verification:
verificationType: hmac # only option supported at the moment
hmacAlgorithm: sha256 # only option supported at the moment
signatureHeader: x-my-header # the name of the http header in the incoming webhook that contains the signature
signaturePrefix: "sha256=" # optional signature prefix that is required for some sources, such as github for example that uses the prefix 'sha256='
currentSecretEnvVar: VERIFICATION_FLOW_1_CURRENT_SECRET # the name of the environment variable containing the verification secret
previousSecretEnvVar: VERIFICATION_FLOW_1_PREVIOUS_SECRET # optional env var that allows rotating secrets without service interruption
```


## Development setup
### Tools
Go 1.20+ and Redis 6.2.6+ are required
Expand Down Expand Up @@ -91,7 +111,7 @@ make lint
make run-dev
```

Run Docker Compose
### Run Docker Compose
```shell
docker-compose up
```
Expand Down
2 changes: 2 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,14 @@ func main() {

messageEnqueuer := services.NewMessageEnqueuer(redisStore, timeSvc)
messageFetcher := services.NewMessageFetcher(redisStore, timeSvc)
messageVerifier := services.NewMessageVerifier()

app := handlers.NewApp(
handlers.WithLogger(logger),
handlers.WithInhooksConfigService(inhooksConfigSvc),
handlers.WithMessageBuilder(messageBuilder),
handlers.WithMessageEnqueuer(messageEnqueuer),
handlers.WithMessageVerifier(messageVerifier),
)

r := server.NewRouter(app)
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ services:
ports:
- "6379:6379"

nginx-lb-updater:
inhooks:
build: .
ports:
- "3000:3000"
Expand Down
25 changes: 25 additions & 0 deletions pkg/models/inhooks_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,31 @@ func ValidateInhooksConfig(appConf *lib.AppConfig, c *InhooksConfig) error {
return fmt.Errorf("invalid source type: %s. allowed: %v", source.Type, SourceTypes)
}

if source.Verification != nil {
verification := source.Verification
if verification.VerificationType != "" && !slices.Contains(VerificationTypes, verification.VerificationType) {
return fmt.Errorf("invalid verification type: %s. allowed: %v", verification.VerificationType, VerificationTypes)
}

if verification.VerificationType == VerificationTypeHMAC {
if verification.HMACAlgorithm == nil || *verification.HMACAlgorithm == "" {
return fmt.Errorf("verification hmac algorithm required")
}

if !slices.Contains(HMACAlgorithms, *verification.HMACAlgorithm) {
return fmt.Errorf("invalid hmac algorithm: %s. allowed: %v", *verification.HMACAlgorithm, HMACAlgorithms)
}
}

if verification.SignatureHeader == "" {
return fmt.Errorf("verification signature header required")
}

if verification.CurrentSecretEnvVar == "" {
return fmt.Errorf("verification current secret env var required")
}
}

if len(f.Sinks) == 0 {
return fmt.Errorf("flow sinks cannot be empty")
}
Expand Down
73 changes: 73 additions & 0 deletions pkg/models/inhooks_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ func TestValidateInhooksConfig_OK(t *testing.T) {
assert.NoError(t, err)

delay := 12 * time.Minute
var hmacAlgorithm HMACAlgorithm = HMACAlgorithmSHA256

c := &InhooksConfig{
Flows: []*Flow{
{
Expand All @@ -39,6 +41,12 @@ func TestValidateInhooksConfig_OK(t *testing.T) {
ID: "source-2",
Slug: "source-2-slug",
Type: "http",
Verification: &Verification{
VerificationType: VerificationTypeHMAC,
HMACAlgorithm: &hmacAlgorithm,
SignatureHeader: "x-my-header",
CurrentSecretEnvVar: "FLOW_2_VERIFICATION_SECRET",
},
},
Sinks: []*Sink{
{
Expand Down Expand Up @@ -286,3 +294,68 @@ func TestValidateInhooksConfig_InvalidSinkUrl(t *testing.T) {

assert.ErrorContains(t, ValidateInhooksConfig(appConf, c), "invalid url: ABCD123")
}

func TestValidateInhooksConfig_InvalidVerificationType(t *testing.T) {
ctx := context.Background()
appConf, err := testsupport.InitAppConfig(ctx)
assert.NoError(t, err)

c := &InhooksConfig{
Flows: []*Flow{
{
ID: "flow-1",
Source: &Source{
ID: "source-1",
Slug: "source-1-slug",
Type: "http",
Verification: &Verification{
VerificationType: "random",
},
},
Sinks: []*Sink{
{
ID: "sink-1",
Type: "http",
URL: "https://example.com/sink",
},
},
},
},
}

assert.ErrorContains(t, ValidateInhooksConfig(appConf, c), "invalid verification type: random. allowed: [hmac]")
}

func TestValidateInhooksConfig_InvalidHMACAlgorithm(t *testing.T) {
ctx := context.Background()
appConf, err := testsupport.InitAppConfig(ctx)
assert.NoError(t, err)

hmacAlgorithm := HMACAlgorithm("somealgorithm")

c := &InhooksConfig{
Flows: []*Flow{
{
ID: "flow-1",
Source: &Source{
ID: "source-1",
Slug: "source-1-slug",
Type: "http",
Verification: &Verification{
VerificationType: VerificationTypeHMAC,
HMACAlgorithm: &hmacAlgorithm,
},
},
Sinks: []*Sink{
{
ID: "sink-1",
Type: "http",
URL: "https://example.com/sink",
},
},
},
},
}

assert.ErrorContains(t, ValidateInhooksConfig(appConf, c), "invalid hmac algorithm: somealgorithm. allowed: [sha256]")
}
27 changes: 24 additions & 3 deletions pkg/models/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,29 @@ var SourceTypes = []SourceType{
SourceTypeHttp,
}

type VerificationType string

const (
VerificationTypeHMAC VerificationType = "hmac"
)

var VerificationTypes = []VerificationType{
VerificationTypeHMAC,
}

type HMACAlgorithm string

const (
HMACAlgorithmSHA256 HMACAlgorithm = "sha256"
)

var HMACAlgorithms = []HMACAlgorithm{
HMACAlgorithmSHA256,
}

type Source struct {
ID string `yaml:"id"`
Slug string `yaml:"slug"`
Type SourceType `yaml:"type"`
ID string `yaml:"id"`
Slug string `yaml:"slug"`
Type SourceType `yaml:"type"`
Verification *Verification `yaml:"verification"`
}
10 changes: 10 additions & 0 deletions pkg/models/verification.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package models

type Verification struct {
VerificationType VerificationType `yaml:"verificationType"`
HMACAlgorithm *HMACAlgorithm `yaml:"hmacAlgorithm"`
SignatureHeader string `yaml:"signatureHeader"`
SignaturePrefix string `yaml:"signaturePrefix"`
CurrentSecretEnvVar string `yaml:"currentSecretEnvVar"`
PreviousSecretEnvVar string `yaml:"previousSecretEnvVar"`
}
7 changes: 7 additions & 0 deletions pkg/server/handlers/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type App struct {
inhooksConfigSvc services.InhooksConfigService
messageBuilder services.MessageBuilder
messageEnqueuer services.MessageEnqueuer
messageVerifier services.MessageVerifier
}

type AppOpt func(app *App)
Expand Down Expand Up @@ -51,6 +52,12 @@ func WithMessageEnqueuer(messageEnqueuer services.MessageEnqueuer) AppOpt {
}
}

func WithMessageVerifier(messageVerifier services.MessageVerifier) AppOpt {
return func(app *App) {
app.messageVerifier = messageVerifier
}
}

type JSONErr struct {
Error string `json:"error"`
ReqID string `json:"reqID,omitempty"`
Expand Down
8 changes: 8 additions & 0 deletions pkg/server/handlers/ingest.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ func (app *App) HandleIngest(w http.ResponseWriter, r *http.Request) {
return
}

// verify messages (first message is enough as payloads and signatures are the same)
err = app.messageVerifier.Verify(flow, messages[0])
if err != nil {
logger.Error("ingest request failed: unable to verify messages signature", zap.Error(err))
app.WriteJSONErr(w, http.StatusForbidden, reqID, fmt.Errorf("unable to verify signature"))
return
}

// enqueue messages
queuedInfos, err := app.messageEnqueuer.Enqueue(ctx, messages)
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions pkg/server/handlers/ingest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func TestIngest_OK(t *testing.T) {
inhooksConfigSvc := mocks.NewMockInhooksConfigService(ctrl)
messageBuilder := mocks.NewMockMessageBuilder(ctrl)
messageEnqueuer := mocks.NewMockMessageEnqueuer(ctrl)
messageVerifier := mocks.NewMockMessageVerifier(ctrl)
logger, err := zap.NewDevelopment()
assert.NoError(t, err)

Expand All @@ -31,6 +32,7 @@ func TestIngest_OK(t *testing.T) {
handlers.WithInhooksConfigService(inhooksConfigSvc),
handlers.WithMessageBuilder(messageBuilder),
handlers.WithMessageEnqueuer(messageEnqueuer),
handlers.WithMessageVerifier(messageVerifier),
)
r := server.NewRouter(app)
s := httptest.NewServer(r)
Expand All @@ -53,6 +55,9 @@ func TestIngest_OK(t *testing.T) {
}

messageBuilder.EXPECT().FromHttp(flow, gomock.AssignableToTypeOf(&http.Request{}), gomock.AssignableToTypeOf("")).Return(messages, nil)

messageVerifier.EXPECT().Verify(flow, messages[0]).Return(nil)

queuedInfos := []*models.QueuedInfo{
{MessageID: messages[0].ID, QueueStatus: models.QueueStatusReady},
{MessageID: messages[1].ID, QueueStatus: models.QueueStatusReady},
Expand Down
84 changes: 84 additions & 0 deletions pkg/services/message_verifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package services

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"hash"
"os"

"github.com/didil/inhooks/pkg/models"
"github.com/pkg/errors"
)

type MessageVerifier interface {
Verify(flow *models.Flow, m *models.Message) error
}

type messageVerifier struct {
}

func NewMessageVerifier() MessageVerifier {
return &messageVerifier{}
}

func (v *messageVerifier) Verify(flow *models.Flow, m *models.Message) error {
verification := flow.Source.Verification

if verification == nil {
// no verification required
return nil
}

if verification.VerificationType == models.VerificationTypeHMAC {
signature := []byte(m.HttpHeaders.Get(verification.SignatureHeader))
signaturePrefix := verification.SignaturePrefix
algorithm := verification.HMACAlgorithm
err := v.verifyHMAC(algorithm, signature, signaturePrefix, os.Getenv(verification.CurrentSecretEnvVar), m.Payload)

if err != nil && verification.PreviousSecretEnvVar != "" {
// try again with previous secret
err = v.verifyHMAC(algorithm, signature, signaturePrefix, os.Getenv(verification.PreviousSecretEnvVar), m.Payload)
}

if err != nil {
return errors.Wrapf(err, "failed to verify message")
}
}

return nil
}

func (v *messageVerifier) verifyHMAC(hmacAlgorithm *models.HMACAlgorithm, signature []byte, signaturePrefix string, secret string, msgContent []byte) error {
var hashFunc func() hash.Hash

if hmacAlgorithm == nil {
return errors.New("no hmac algorithm specified")
}

switch *hmacAlgorithm {
case models.HMACAlgorithmSHA256:
hashFunc = sha256.New
default:
return fmt.Errorf("unexpected hmac algorithm: %s", *hmacAlgorithm)
}

mac := hmac.New(hashFunc, []byte(secret))
_, err := mac.Write(msgContent)
if err != nil {
return errors.Wrapf(err, "failed to write hash")
}
calculatedMACHex := hex.EncodeToString(mac.Sum(nil))

if signaturePrefix != "" {
// add prefix if needed (for github for example, the prefix is 'sha256=')
calculatedMACHex = signaturePrefix + string(calculatedMACHex)
}

if !hmac.Equal([]byte(calculatedMACHex), signature) {
return errors.New("invalid signature")
}

return nil
}
Loading

0 comments on commit ed1906c

Please sign in to comment.