Skip to content

Commit 58a5d46

Browse files
authored
feat: Add webhook signature verification using Meta App Secret (#62)
- Add AppSecret field to WhatsAppAccount model for storing Meta App Secret - Update accounts API to handle app_secret in create/update operations - Add HasAppSecret indicator in account response - Include AppSecret in WhatsApp account cache for efficient lookups - Verify X-Hub-Signature-256 header using HMAC-SHA256 with App Secret - Signature verification happens inline during message processing (no double loop) - Add app_secret input field and status badge in frontend AccountsView - Add comprehensive unit tests for signature verification function
1 parent 56352fe commit 58a5d46

File tree

6 files changed

+227
-4
lines changed

6 files changed

+227
-4
lines changed

frontend/src/views/settings/AccountsView.vue

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ interface WhatsAppAccount {
7373
auto_read_receipt: boolean
7474
status: string
7575
has_access_token: boolean
76+
has_app_secret: boolean
7677
phone_number?: string
7778
display_name?: string
7879
created_at: string
@@ -106,6 +107,7 @@ const formData = ref({
106107
phone_id: '',
107108
business_id: '',
108109
access_token: '',
110+
app_secret: '',
109111
webhook_verify_token: '',
110112
api_version: 'v21.0',
111113
is_default_incoming: false,
@@ -144,6 +146,7 @@ function openCreateDialog() {
144146
phone_id: '',
145147
business_id: '',
146148
access_token: '',
149+
app_secret: '',
147150
webhook_verify_token: '',
148151
api_version: 'v21.0',
149152
is_default_incoming: false,
@@ -161,6 +164,7 @@ function openEditDialog(account: WhatsAppAccount) {
161164
phone_id: account.phone_id,
162165
business_id: account.business_id,
163166
access_token: '', // Don't show existing token
167+
app_secret: '', // Don't show existing secret
164168
webhook_verify_token: account.webhook_verify_token,
165169
api_version: account.api_version,
166170
is_default_incoming: account.is_default_incoming,
@@ -184,10 +188,13 @@ async function saveAccount() {
184188
isSubmitting.value = true
185189
try {
186190
const payload = { ...formData.value }
187-
// Don't send empty access token when editing
191+
// Don't send empty access token or app secret when editing
188192
if (editingAccount.value && !payload.access_token) {
189193
delete (payload as any).access_token
190194
}
195+
if (editingAccount.value && !payload.app_secret) {
196+
delete (payload as any).app_secret
197+
}
191198
192199
if (editingAccount.value) {
193200
await api.put(`/accounts/${editingAccount.value.id}`, payload)
@@ -418,6 +425,15 @@ const webhookUrl = window.location.origin + basePath + '/api/webhook'
418425
{{ account.has_access_token ? 'Configured' : 'Missing' }}
419426
</Badge>
420427
</div>
428+
<div class="flex items-center gap-2">
429+
<span class="text-white/50 light:text-gray-500">App Secret:</span>
430+
<Badge
431+
variant="outline"
432+
:class="account.has_app_secret ? 'border-green-600 text-green-600' : 'border-yellow-600 text-yellow-600'"
433+
>
434+
{{ account.has_app_secret ? 'Configured' : 'Not Set' }}
435+
</Badge>
436+
</div>
421437
</div>
422438

423439
<!-- Defaults -->
@@ -598,6 +614,22 @@ const webhookUrl = window.location.origin + basePath + '/api/webhook'
598614
</p>
599615
</div>
600616

617+
<div class="space-y-2">
618+
<Label for="app_secret">
619+
App Secret
620+
<span v-if="editingAccount" class="text-muted-foreground">(leave blank to keep existing)</span>
621+
</Label>
622+
<Input
623+
id="app_secret"
624+
v-model="formData.app_secret"
625+
type="password"
626+
placeholder="Meta App Secret for webhook verification"
627+
/>
628+
<p class="text-xs text-muted-foreground">
629+
Found in Meta Developer Console &gt; App Settings &gt; Basic &gt; App Secret. Used to verify webhook signatures.
630+
</p>
631+
</div>
632+
601633
<Separator />
602634

603635
<div class="space-y-2">

internal/handlers/accounts.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type AccountRequest struct {
2121
PhoneID string `json:"phone_id" validate:"required"`
2222
BusinessID string `json:"business_id" validate:"required"`
2323
AccessToken string `json:"access_token" validate:"required"`
24+
AppSecret string `json:"app_secret"` // Meta App Secret for webhook signature verification
2425
WebhookVerifyToken string `json:"webhook_verify_token"`
2526
APIVersion string `json:"api_version"`
2627
IsDefaultIncoming bool `json:"is_default_incoming"`
@@ -42,6 +43,7 @@ type AccountResponse struct {
4243
AutoReadReceipt bool `json:"auto_read_receipt"`
4344
Status string `json:"status"`
4445
HasAccessToken bool `json:"has_access_token"`
46+
HasAppSecret bool `json:"has_app_secret"`
4547
PhoneNumber string `json:"phone_number,omitempty"`
4648
DisplayName string `json:"display_name,omitempty"`
4749
CreatedAt string `json:"created_at"`
@@ -108,6 +110,7 @@ func (a *App) CreateAccount(r *fastglue.Request) error {
108110
PhoneID: req.PhoneID,
109111
BusinessID: req.BusinessID,
110112
AccessToken: req.AccessToken, // TODO: encrypt before storing
113+
AppSecret: req.AppSecret, // Meta App Secret for webhook signature verification
111114
WebhookVerifyToken: webhookVerifyToken,
112115
APIVersion: apiVersion,
113116
IsDefaultIncoming: req.IsDefaultIncoming,
@@ -199,6 +202,9 @@ func (a *App) UpdateAccount(r *fastglue.Request) error {
199202
if req.AccessToken != "" {
200203
account.AccessToken = req.AccessToken // TODO: encrypt
201204
}
205+
if req.AppSecret != "" {
206+
account.AppSecret = req.AppSecret
207+
}
202208
if req.WebhookVerifyToken != "" {
203209
account.WebhookVerifyToken = req.WebhookVerifyToken
204210
}
@@ -336,6 +342,7 @@ func accountToResponse(acc models.WhatsAppAccount) AccountResponse {
336342
AutoReadReceipt: acc.AutoReadReceipt,
337343
Status: acc.Status,
338344
HasAccessToken: acc.AccessToken != "",
345+
HasAppSecret: acc.AppSecret != "",
339346
CreatedAt: acc.CreatedAt.Format("2006-01-02T15:04:05Z"),
340347
UpdatedAt: acc.UpdatedAt.Format("2006-01-02T15:04:05Z"),
341348
}

internal/handlers/cache.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,11 @@ func (a *App) deleteKeysByPattern(ctx context.Context, pattern string) {
204204
}
205205
}
206206

207-
// whatsAppAccountCache is used for caching since AccessToken has json:"-" tag
207+
// whatsAppAccountCache is used for caching since AccessToken and AppSecret have json:"-" tag
208208
type whatsAppAccountCache struct {
209209
models.WhatsAppAccount
210210
AccessToken string `json:"access_token"`
211+
AppSecret string `json:"app_secret"`
211212
}
212213

213214
// getWhatsAppAccountCached retrieves WhatsApp account by phone_id from cache or database
@@ -221,6 +222,7 @@ func (a *App) getWhatsAppAccountCached(phoneID string) (*models.WhatsAppAccount,
221222
var cacheData whatsAppAccountCache
222223
if err := json.Unmarshal([]byte(cached), &cacheData); err == nil {
223224
cacheData.WhatsAppAccount.AccessToken = cacheData.AccessToken
225+
cacheData.WhatsAppAccount.AppSecret = cacheData.AppSecret
224226
return &cacheData.WhatsAppAccount, nil
225227
}
226228
}
@@ -231,10 +233,11 @@ func (a *App) getWhatsAppAccountCached(phoneID string) (*models.WhatsAppAccount,
231233
return nil, err
232234
}
233235

234-
// Cache the result (include AccessToken explicitly)
236+
// Cache the result (include AccessToken and AppSecret explicitly since they have json:"-")
235237
cacheData := whatsAppAccountCache{
236238
WhatsAppAccount: account,
237239
AccessToken: account.AccessToken,
240+
AppSecret: account.AppSecret,
238241
}
239242
if data, err := json.Marshal(cacheData); err == nil {
240243
a.Redis.Set(ctx, cacheKey, data, whatsappAccountCacheTTL)

internal/handlers/webhook.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package handlers
22

33
import (
4+
"bytes"
5+
"crypto/hmac"
6+
"crypto/sha256"
7+
"encoding/hex"
48
"encoding/json"
59
"strings"
610

@@ -183,12 +187,18 @@ type WebhookPayload struct {
183187

184188
// WebhookHandler processes incoming webhook events from Meta
185189
func (a *App) WebhookHandler(r *fastglue.Request) error {
190+
body := r.RequestCtx.PostBody()
191+
signature := r.RequestCtx.Request.Header.Peek("X-Hub-Signature-256")
192+
186193
var payload WebhookPayload
187-
if err := json.Unmarshal(r.RequestCtx.PostBody(), &payload); err != nil {
194+
if err := json.Unmarshal(body, &payload); err != nil {
188195
a.Log.Error("Failed to parse webhook payload", "error", err)
189196
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid payload", nil, "")
190197
}
191198

199+
// Track if signature has been verified (only need to verify once per request)
200+
signatureVerified := false
201+
192202
// Process each entry
193203
for _, entry := range payload.Entry {
194204
for _, change := range entry.Changes {
@@ -210,6 +220,19 @@ func (a *App) WebhookHandler(r *fastglue.Request) error {
210220

211221
phoneNumberID := change.Value.Metadata.PhoneNumberID
212222

223+
// Verify webhook signature on first message processing (uses cached account)
224+
if !signatureVerified && len(signature) > 0 && phoneNumberID != "" {
225+
account, err := a.getWhatsAppAccountCached(phoneNumberID)
226+
if err == nil && account.AppSecret != "" {
227+
if !verifyWebhookSignature(body, signature, []byte(account.AppSecret)) {
228+
a.Log.Warn("Invalid webhook signature", "phone_id", phoneNumberID)
229+
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Invalid signature", nil, "")
230+
}
231+
a.Log.Debug("Webhook signature verified successfully")
232+
}
233+
signatureVerified = true
234+
}
235+
213236
// Process messages
214237
for _, msg := range change.Value.Messages {
215238
a.Log.Info("Received message",
@@ -421,3 +444,24 @@ func (a *App) processTemplateStatusUpdate(wabaID, event, templateName, templateL
421444
}
422445
}
423446
}
447+
448+
// verifyWebhookSignature verifies the X-Hub-Signature-256 header from Meta.
449+
// The signature is HMAC-SHA256 of the request body using the App Secret.
450+
func verifyWebhookSignature(body, signature, appSecret []byte) bool {
451+
// Signature format: "sha256=<hex_signature>"
452+
prefix := []byte("sha256=")
453+
if !bytes.HasPrefix(signature, prefix) {
454+
return false
455+
}
456+
457+
expectedSig := bytes.TrimPrefix(signature, prefix)
458+
459+
// Compute HMAC-SHA256
460+
mac := hmac.New(sha256.New, appSecret)
461+
mac.Write(body)
462+
computedSig := make([]byte, hex.EncodedLen(mac.Size()))
463+
hex.Encode(computedSig, mac.Sum(nil))
464+
465+
// Constant-time comparison to prevent timing attacks
466+
return hmac.Equal(expectedSig, computedSig)
467+
}

internal/handlers/webhook_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package handlers
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestVerifyWebhookSignature(t *testing.T) {
13+
t.Parallel()
14+
15+
// Test data
16+
appSecret := []byte("test_app_secret_12345")
17+
body := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"123","changes":[]}]}`)
18+
19+
// Compute valid signature
20+
mac := hmac.New(sha256.New, appSecret)
21+
mac.Write(body)
22+
validSig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
23+
24+
tests := []struct {
25+
name string
26+
body []byte
27+
signature []byte
28+
appSecret []byte
29+
want bool
30+
}{
31+
{
32+
name: "valid signature",
33+
body: body,
34+
signature: []byte(validSig),
35+
appSecret: appSecret,
36+
want: true,
37+
},
38+
{
39+
name: "invalid signature - wrong hash",
40+
body: body,
41+
signature: []byte("sha256=0000000000000000000000000000000000000000000000000000000000000000"),
42+
appSecret: appSecret,
43+
want: false,
44+
},
45+
{
46+
name: "invalid signature - wrong secret",
47+
body: body,
48+
signature: []byte(validSig),
49+
appSecret: []byte("wrong_secret"),
50+
want: false,
51+
},
52+
{
53+
name: "invalid signature - modified body",
54+
body: []byte(`{"object":"modified"}`),
55+
signature: []byte(validSig),
56+
appSecret: appSecret,
57+
want: false,
58+
},
59+
{
60+
name: "invalid signature - missing sha256 prefix",
61+
body: body,
62+
signature: []byte(hex.EncodeToString(mac.Sum(nil))),
63+
appSecret: appSecret,
64+
want: false,
65+
},
66+
{
67+
name: "invalid signature - wrong prefix",
68+
body: body,
69+
signature: []byte("sha1=" + hex.EncodeToString(mac.Sum(nil))),
70+
appSecret: appSecret,
71+
want: false,
72+
},
73+
{
74+
name: "empty signature",
75+
body: body,
76+
signature: []byte{},
77+
appSecret: appSecret,
78+
want: false,
79+
},
80+
{
81+
name: "empty body with valid signature for empty body",
82+
body: []byte{},
83+
signature: func() []byte {
84+
m := hmac.New(sha256.New, appSecret)
85+
m.Write([]byte{})
86+
return []byte("sha256=" + hex.EncodeToString(m.Sum(nil)))
87+
}(),
88+
appSecret: appSecret,
89+
want: true,
90+
},
91+
}
92+
93+
for _, tt := range tests {
94+
t.Run(tt.name, func(t *testing.T) {
95+
t.Parallel()
96+
got := verifyWebhookSignature(tt.body, tt.signature, tt.appSecret)
97+
assert.Equal(t, tt.want, got, "verifyWebhookSignature() = %v, want %v", got, tt.want)
98+
})
99+
}
100+
}
101+
102+
func TestVerifyWebhookSignature_RealWorldExample(t *testing.T) {
103+
t.Parallel()
104+
105+
// Simulate a real Meta webhook payload
106+
payload := `{"object":"whatsapp_business_account","entry":[{"id":"123456789","changes":[{"value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"15551234567","phone_number_id":"987654321"},"messages":[{"from":"15559876543","id":"wamid.abc123","timestamp":"1234567890","type":"text","text":{"body":"Hello"}}]},"field":"messages"}]}]}`
107+
appSecret := "my_app_secret_from_meta_dashboard"
108+
109+
// Compute signature like Meta would
110+
mac := hmac.New(sha256.New, []byte(appSecret))
111+
mac.Write([]byte(payload))
112+
signature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
113+
114+
// Verify
115+
result := verifyWebhookSignature([]byte(payload), []byte(signature), []byte(appSecret))
116+
assert.True(t, result, "Should verify real-world webhook payload")
117+
}
118+
119+
func TestVerifyWebhookSignature_TimingAttackResistance(t *testing.T) {
120+
t.Parallel()
121+
122+
// This test ensures we use constant-time comparison
123+
// by verifying the function behaves correctly with similar signatures
124+
appSecret := []byte("test_secret")
125+
body := []byte("test body")
126+
127+
mac := hmac.New(sha256.New, appSecret)
128+
mac.Write(body)
129+
validSig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
130+
131+
// Create a signature that differs only in the last character
132+
almostValidSig := validSig[:len(validSig)-1] + "0"
133+
134+
assert.True(t, verifyWebhookSignature(body, []byte(validSig), appSecret))
135+
assert.False(t, verifyWebhookSignature(body, []byte(almostValidSig), appSecret))
136+
}

internal/models/models.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ type WhatsAppAccount struct {
271271
PhoneID string `gorm:"size:100;not null" json:"phone_id"`
272272
BusinessID string `gorm:"size:100;not null" json:"business_id"`
273273
AccessToken string `gorm:"type:text;not null" json:"-"` // encrypted
274+
AppSecret string `gorm:"size:255" json:"-"` // Meta App Secret for webhook signature verification
274275
WebhookVerifyToken string `gorm:"size:255" json:"webhook_verify_token"`
275276
APIVersion string `gorm:"size:20;default:'v21.0'" json:"api_version"`
276277
IsDefaultIncoming bool `gorm:"default:false" json:"is_default_incoming"`

0 commit comments

Comments
 (0)