Skip to content

Commit ed1906c

Browse files
authored
feat: hmac 256 verification (#43)
* feat: hmac 256 verification * docs: extra verification readme docs
1 parent fea7685 commit ed1906c

File tree

14 files changed

+459
-5
lines changed

14 files changed

+459
-5
lines changed

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,26 @@ In case of failures, retries are attempted based on the sink config params.
6060

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

63+
### Securing webhooks
64+
If you would like to verify your webhooks with HMAC 256, you can use the following configuration:
65+
66+
``` yaml
67+
flows:
68+
- id: flow-1
69+
source:
70+
id: source-1
71+
slug: source-1-slug
72+
type: http
73+
verification:
74+
verificationType: hmac # only option supported at the moment
75+
hmacAlgorithm: sha256 # only option supported at the moment
76+
signatureHeader: x-my-header # the name of the http header in the incoming webhook that contains the signature
77+
signaturePrefix: "sha256=" # optional signature prefix that is required for some sources, such as github for example that uses the prefix 'sha256='
78+
currentSecretEnvVar: VERIFICATION_FLOW_1_CURRENT_SECRET # the name of the environment variable containing the verification secret
79+
previousSecretEnvVar: VERIFICATION_FLOW_1_PREVIOUS_SECRET # optional env var that allows rotating secrets without service interruption
80+
```
81+
82+
6383
## Development setup
6484
### Tools
6585
Go 1.20+ and Redis 6.2.6+ are required
@@ -91,7 +111,7 @@ make lint
91111
make run-dev
92112
```
93113

94-
Run Docker Compose
114+
### Run Docker Compose
95115
```shell
96116
docker-compose up
97117
```

cmd/api/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,14 @@ func main() {
6464

6565
messageEnqueuer := services.NewMessageEnqueuer(redisStore, timeSvc)
6666
messageFetcher := services.NewMessageFetcher(redisStore, timeSvc)
67+
messageVerifier := services.NewMessageVerifier()
6768

6869
app := handlers.NewApp(
6970
handlers.WithLogger(logger),
7071
handlers.WithInhooksConfigService(inhooksConfigSvc),
7172
handlers.WithMessageBuilder(messageBuilder),
7273
handlers.WithMessageEnqueuer(messageEnqueuer),
74+
handlers.WithMessageVerifier(messageVerifier),
7375
)
7476

7577
r := server.NewRouter(app)

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ services:
88
ports:
99
- "6379:6379"
1010

11-
nginx-lb-updater:
11+
inhooks:
1212
build: .
1313
ports:
1414
- "3000:3000"

pkg/models/inhooks_config.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,31 @@ func ValidateInhooksConfig(appConf *lib.AppConfig, c *InhooksConfig) error {
6060
return fmt.Errorf("invalid source type: %s. allowed: %v", source.Type, SourceTypes)
6161
}
6262

63+
if source.Verification != nil {
64+
verification := source.Verification
65+
if verification.VerificationType != "" && !slices.Contains(VerificationTypes, verification.VerificationType) {
66+
return fmt.Errorf("invalid verification type: %s. allowed: %v", verification.VerificationType, VerificationTypes)
67+
}
68+
69+
if verification.VerificationType == VerificationTypeHMAC {
70+
if verification.HMACAlgorithm == nil || *verification.HMACAlgorithm == "" {
71+
return fmt.Errorf("verification hmac algorithm required")
72+
}
73+
74+
if !slices.Contains(HMACAlgorithms, *verification.HMACAlgorithm) {
75+
return fmt.Errorf("invalid hmac algorithm: %s. allowed: %v", *verification.HMACAlgorithm, HMACAlgorithms)
76+
}
77+
}
78+
79+
if verification.SignatureHeader == "" {
80+
return fmt.Errorf("verification signature header required")
81+
}
82+
83+
if verification.CurrentSecretEnvVar == "" {
84+
return fmt.Errorf("verification current secret env var required")
85+
}
86+
}
87+
6388
if len(f.Sinks) == 0 {
6489
return fmt.Errorf("flow sinks cannot be empty")
6590
}

pkg/models/inhooks_config_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ func TestValidateInhooksConfig_OK(t *testing.T) {
1515
assert.NoError(t, err)
1616

1717
delay := 12 * time.Minute
18+
var hmacAlgorithm HMACAlgorithm = HMACAlgorithmSHA256
19+
1820
c := &InhooksConfig{
1921
Flows: []*Flow{
2022
{
@@ -39,6 +41,12 @@ func TestValidateInhooksConfig_OK(t *testing.T) {
3941
ID: "source-2",
4042
Slug: "source-2-slug",
4143
Type: "http",
44+
Verification: &Verification{
45+
VerificationType: VerificationTypeHMAC,
46+
HMACAlgorithm: &hmacAlgorithm,
47+
SignatureHeader: "x-my-header",
48+
CurrentSecretEnvVar: "FLOW_2_VERIFICATION_SECRET",
49+
},
4250
},
4351
Sinks: []*Sink{
4452
{
@@ -286,3 +294,68 @@ func TestValidateInhooksConfig_InvalidSinkUrl(t *testing.T) {
286294

287295
assert.ErrorContains(t, ValidateInhooksConfig(appConf, c), "invalid url: ABCD123")
288296
}
297+
298+
func TestValidateInhooksConfig_InvalidVerificationType(t *testing.T) {
299+
ctx := context.Background()
300+
appConf, err := testsupport.InitAppConfig(ctx)
301+
assert.NoError(t, err)
302+
303+
c := &InhooksConfig{
304+
Flows: []*Flow{
305+
{
306+
ID: "flow-1",
307+
Source: &Source{
308+
ID: "source-1",
309+
Slug: "source-1-slug",
310+
Type: "http",
311+
Verification: &Verification{
312+
VerificationType: "random",
313+
},
314+
},
315+
Sinks: []*Sink{
316+
{
317+
ID: "sink-1",
318+
Type: "http",
319+
URL: "https://example.com/sink",
320+
},
321+
},
322+
},
323+
},
324+
}
325+
326+
assert.ErrorContains(t, ValidateInhooksConfig(appConf, c), "invalid verification type: random. allowed: [hmac]")
327+
}
328+
329+
func TestValidateInhooksConfig_InvalidHMACAlgorithm(t *testing.T) {
330+
ctx := context.Background()
331+
appConf, err := testsupport.InitAppConfig(ctx)
332+
assert.NoError(t, err)
333+
334+
hmacAlgorithm := HMACAlgorithm("somealgorithm")
335+
336+
c := &InhooksConfig{
337+
Flows: []*Flow{
338+
{
339+
ID: "flow-1",
340+
Source: &Source{
341+
ID: "source-1",
342+
Slug: "source-1-slug",
343+
Type: "http",
344+
Verification: &Verification{
345+
VerificationType: VerificationTypeHMAC,
346+
HMACAlgorithm: &hmacAlgorithm,
347+
},
348+
},
349+
Sinks: []*Sink{
350+
{
351+
ID: "sink-1",
352+
Type: "http",
353+
URL: "https://example.com/sink",
354+
},
355+
},
356+
},
357+
},
358+
}
359+
360+
assert.ErrorContains(t, ValidateInhooksConfig(appConf, c), "invalid hmac algorithm: somealgorithm. allowed: [sha256]")
361+
}

pkg/models/source.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,29 @@ var SourceTypes = []SourceType{
1010
SourceTypeHttp,
1111
}
1212

13+
type VerificationType string
14+
15+
const (
16+
VerificationTypeHMAC VerificationType = "hmac"
17+
)
18+
19+
var VerificationTypes = []VerificationType{
20+
VerificationTypeHMAC,
21+
}
22+
23+
type HMACAlgorithm string
24+
25+
const (
26+
HMACAlgorithmSHA256 HMACAlgorithm = "sha256"
27+
)
28+
29+
var HMACAlgorithms = []HMACAlgorithm{
30+
HMACAlgorithmSHA256,
31+
}
32+
1333
type Source struct {
14-
ID string `yaml:"id"`
15-
Slug string `yaml:"slug"`
16-
Type SourceType `yaml:"type"`
34+
ID string `yaml:"id"`
35+
Slug string `yaml:"slug"`
36+
Type SourceType `yaml:"type"`
37+
Verification *Verification `yaml:"verification"`
1738
}

pkg/models/verification.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package models
2+
3+
type Verification struct {
4+
VerificationType VerificationType `yaml:"verificationType"`
5+
HMACAlgorithm *HMACAlgorithm `yaml:"hmacAlgorithm"`
6+
SignatureHeader string `yaml:"signatureHeader"`
7+
SignaturePrefix string `yaml:"signaturePrefix"`
8+
CurrentSecretEnvVar string `yaml:"currentSecretEnvVar"`
9+
PreviousSecretEnvVar string `yaml:"previousSecretEnvVar"`
10+
}

pkg/server/handlers/app.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type App struct {
1313
inhooksConfigSvc services.InhooksConfigService
1414
messageBuilder services.MessageBuilder
1515
messageEnqueuer services.MessageEnqueuer
16+
messageVerifier services.MessageVerifier
1617
}
1718

1819
type AppOpt func(app *App)
@@ -51,6 +52,12 @@ func WithMessageEnqueuer(messageEnqueuer services.MessageEnqueuer) AppOpt {
5152
}
5253
}
5354

55+
func WithMessageVerifier(messageVerifier services.MessageVerifier) AppOpt {
56+
return func(app *App) {
57+
app.messageVerifier = messageVerifier
58+
}
59+
}
60+
5461
type JSONErr struct {
5562
Error string `json:"error"`
5663
ReqID string `json:"reqID,omitempty"`

pkg/server/handlers/ingest.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ func (app *App) HandleIngest(w http.ResponseWriter, r *http.Request) {
3737
return
3838
}
3939

40+
// verify messages (first message is enough as payloads and signatures are the same)
41+
err = app.messageVerifier.Verify(flow, messages[0])
42+
if err != nil {
43+
logger.Error("ingest request failed: unable to verify messages signature", zap.Error(err))
44+
app.WriteJSONErr(w, http.StatusForbidden, reqID, fmt.Errorf("unable to verify signature"))
45+
return
46+
}
47+
4048
// enqueue messages
4149
queuedInfos, err := app.messageEnqueuer.Enqueue(ctx, messages)
4250
if err != nil {

pkg/server/handlers/ingest_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func TestIngest_OK(t *testing.T) {
2323
inhooksConfigSvc := mocks.NewMockInhooksConfigService(ctrl)
2424
messageBuilder := mocks.NewMockMessageBuilder(ctrl)
2525
messageEnqueuer := mocks.NewMockMessageEnqueuer(ctrl)
26+
messageVerifier := mocks.NewMockMessageVerifier(ctrl)
2627
logger, err := zap.NewDevelopment()
2728
assert.NoError(t, err)
2829

@@ -31,6 +32,7 @@ func TestIngest_OK(t *testing.T) {
3132
handlers.WithInhooksConfigService(inhooksConfigSvc),
3233
handlers.WithMessageBuilder(messageBuilder),
3334
handlers.WithMessageEnqueuer(messageEnqueuer),
35+
handlers.WithMessageVerifier(messageVerifier),
3436
)
3537
r := server.NewRouter(app)
3638
s := httptest.NewServer(r)
@@ -53,6 +55,9 @@ func TestIngest_OK(t *testing.T) {
5355
}
5456

5557
messageBuilder.EXPECT().FromHttp(flow, gomock.AssignableToTypeOf(&http.Request{}), gomock.AssignableToTypeOf("")).Return(messages, nil)
58+
59+
messageVerifier.EXPECT().Verify(flow, messages[0]).Return(nil)
60+
5661
queuedInfos := []*models.QueuedInfo{
5762
{MessageID: messages[0].ID, QueueStatus: models.QueueStatusReady},
5863
{MessageID: messages[1].ID, QueueStatus: models.QueueStatusReady},

0 commit comments

Comments
 (0)