Skip to content

Commit

Permalink
Add HMAC Signing Support for Webhook Client Security (#1134)
Browse files Browse the repository at this point in the history
Implements SHA-256 HMAC signing for webhook client requests to enhance
security. When configured with an HMAC key, the client automatically
signs request bodies and includes the signature in the X-Signature-256
header, following patterns similar to GitHub's webhook implementation.
  • Loading branch information
window9u authored Feb 4, 2025
1 parent a1e187a commit 778d34f
Show file tree
Hide file tree
Showing 2 changed files with 198 additions and 6 deletions.
39 changes: 33 additions & 6 deletions pkg/webhook/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ package webhook
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -51,6 +54,8 @@ type Options struct {

MaxRetries uint64
MaxWaitInterval time.Duration

HMACKey string
}

// Client is a client for the webhook.
Expand Down Expand Up @@ -87,12 +92,7 @@ func (c *Client[Req, Res]) Send(ctx context.Context, req Req) (*Res, int, error)

var res Res
status, err := c.withExponentialBackoff(ctx, func() (int, error) {
// TODO(hackerwins, window9u): We should consider using HMAC to sign the request.
resp, err := http.Post(
c.url,
"application/json",
bytes.NewBuffer(body),
)
resp, err := c.post("application/json", body)
if err != nil {
return 0, fmt.Errorf("post to webhook: %w", err)
}
Expand Down Expand Up @@ -126,6 +126,33 @@ func (c *Client[Req, Res]) Send(ctx context.Context, req Req) (*Res, int, error)
return &res, status, nil
}

// post sends an HTTP POST request with HMAC-SHA256 signature headers.
// If key is empty, post sends an HTTP POST without signature.
func (c *Client[Req, Res]) post(contentType string, body []byte) (*http.Response, error) {
req, err := http.NewRequest("POST", c.url, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("create HTTP request: %w", err)
}

req.Header.Set("Content-Type", contentType)
if c.options.HMACKey != "" {
mac := hmac.New(sha256.New, []byte(c.options.HMACKey))
if _, err := mac.Write(body); err != nil {
return nil, fmt.Errorf("write HMAC body: %w", err)
}
signature := mac.Sum(nil)
signatureHex := hex.EncodeToString(signature) // Convert to hex string
req.Header.Set("X-Signature-256", fmt.Sprintf("sha256=%s", signatureHex))
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send to %s: %w", c.url, err) // Wrapped with context
}

return resp, nil
}

func (c *Client[Req, Res]) withExponentialBackoff(ctx context.Context, webhookFn func() (int, error)) (int, error) {
var retries uint64
var statusCode int
Expand Down
165 changes: 165 additions & 0 deletions pkg/webhook/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package webhook_test

import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/assert"

"github.com/yorkie-team/yorkie/pkg/cache"
"github.com/yorkie-team/yorkie/pkg/types"
"github.com/yorkie-team/yorkie/pkg/webhook"
)

// testRequest is a simple request type for demonstration.
type testRequest struct {
Name string `json:"name"`
}

// testResponse is a simple response type for demonstration.
type testResponse struct {
Greeting string `json:"greeting"`
}

func verifySignature(signatureHeader, secret string, body []byte) error {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expectedSig := hex.EncodeToString(mac.Sum(nil))
expectedSigHeader := fmt.Sprintf("sha256=%s", expectedSig)
if !hmac.Equal([]byte(signatureHeader), []byte(expectedSigHeader)) {
return errors.New("signature validation failed")
}

return nil
}

func TestHMAC(t *testing.T) {
const secretKey = "my-secret-key"
const wrongKey = "wrong-key"
reqData := testRequest{Name: "HMAC Tester"}
resData := testResponse{Greeting: "HMAC OK"}

testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
signatureHeader := r.Header.Get("X-Signature-256")
if signatureHeader == "" {
w.WriteHeader(http.StatusUnauthorized)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}

if err := verifySignature(signatureHeader, secretKey, bodyBytes); err != nil {
w.WriteHeader(http.StatusForbidden)
return
}
w.WriteHeader(http.StatusOK)
assert.NoError(t, json.NewEncoder(w).Encode(resData))
}))
defer testServer.Close()

t.Run("webhook client with valid HMAC key test", func(t *testing.T) {
testCache, err := cache.NewLRUExpireCache[string, types.Pair[int, *testResponse]](100)
assert.NoError(t, err)

client := webhook.NewClient[testRequest, testResponse](
testServer.URL,
testCache,
webhook.Options{
CacheKeyPrefix: "testPrefix-hmac",
CacheTTL: 5 * time.Second,
MaxRetries: 0,
MaxWaitInterval: 200 * time.Millisecond,
HMACKey: secretKey,
},
)

ctx := context.Background()
resp, statusCode, err := client.Send(ctx, reqData)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, statusCode)
assert.NotNil(t, resp)
assert.Equal(t, resData.Greeting, resp.Greeting)
})

t.Run("webhook client with invalid HMAC key test", func(t *testing.T) {
testCache, err := cache.NewLRUExpireCache[string, types.Pair[int, *testResponse]](100)
assert.NoError(t, err)

client := webhook.NewClient[testRequest, testResponse](
testServer.URL,
testCache,
webhook.Options{
CacheKeyPrefix: "testPrefix-hmac",
CacheTTL: 5 * time.Second,
MaxRetries: 0,
MaxWaitInterval: 200 * time.Millisecond,
HMACKey: wrongKey,
},
)

ctx := context.Background()
resp, statusCode, err := client.Send(ctx, reqData)
assert.Error(t, err)
assert.Equal(t, http.StatusForbidden, statusCode)
assert.Nil(t, resp)
})

t.Run("webhook client without HMAC key test", func(t *testing.T) {
testCache, err := cache.NewLRUExpireCache[string, types.Pair[int, *testResponse]](100)
assert.NoError(t, err)

client := webhook.NewClient[testRequest, testResponse](
testServer.URL,
testCache,
webhook.Options{
CacheKeyPrefix: "testPrefix-hmac",
CacheTTL: 5 * time.Second,
MaxRetries: 0,
MaxWaitInterval: 200 * time.Millisecond,
},
)

ctx := context.Background()
resp, statusCode, err := client.Send(ctx, reqData)
assert.Error(t, err)
assert.Equal(t, http.StatusUnauthorized, statusCode)
assert.Nil(t, resp)
})

t.Run("webhook client with empty body test", func(t *testing.T) {
testCache, err := cache.NewLRUExpireCache[string, types.Pair[int, *testResponse]](100)
assert.NoError(t, err)

client := webhook.NewClient[testRequest, testResponse](
testServer.URL,
testCache,
webhook.Options{
CacheKeyPrefix: "testPrefix-hmac",
CacheTTL: 5 * time.Second,
MaxRetries: 0,
MaxWaitInterval: 200 * time.Millisecond,
HMACKey: secretKey,
},
)

ctx := context.Background()
resp, statusCode, err := client.Send(ctx, testRequest{})
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, statusCode)
assert.NotNil(t, resp)
assert.Equal(t, resData.Greeting, resp.Greeting)
})
}

0 comments on commit 778d34f

Please sign in to comment.