* fix(security): harden auth and credential handling (HH-444) * fix(security): address HH-444 review blockers * fix(security): close remaining HH-444 review blockers --------- Co-authored-by: Rogee <rogee@ipao.vip>
169 lines
5.5 KiB
Go
169 lines
5.5 KiB
Go
package whatsapp
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gochat/gochat/internal/model"
|
|
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
|
"github.com/gochat/gochat/internal/security"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func newWhatsAppWebhookTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(&model.Inbox{}, &channelmodel.ChannelWhatsApp{}); err != nil {
|
|
t.Fatalf("migrate whatsapp models: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func TestWhatsAppWebhookLookupByVerifyToken(t *testing.T) {
|
|
db := newWhatsAppWebhookTestDB(t)
|
|
inbox := model.Inbox{AccountID: 1, Name: "wa", ChannelType: "whatsapp", ChannelID: 1, Enabled: true}
|
|
if err := db.Create(&inbox).Error; err != nil {
|
|
t.Fatalf("create inbox: %v", err)
|
|
}
|
|
channel := channelmodel.ChannelWhatsApp{
|
|
AccountID: 1,
|
|
InboxID: inbox.ID,
|
|
PhoneNumber: "+15551234567",
|
|
PhoneNumberID: "phone-number-id",
|
|
AccessToken: "access-token",
|
|
Provider: "whatsapp_cloud",
|
|
WebhookVerifyToken: "verify-token",
|
|
ProviderConfig: `{"app_secret":"app-secret"}`,
|
|
BusinessAccountID: "waba-id",
|
|
WhatsAppAccountName: "WA",
|
|
}
|
|
if err := db.Create(&channel).Error; err != nil {
|
|
t.Fatalf("create whatsapp channel: %v", err)
|
|
}
|
|
|
|
repo := NewRepository(db)
|
|
provider := NewWhatsAppProvider(nil, repo, nil)
|
|
h := NewWebhookHandler(provider)
|
|
found, err := h.lookupByVerifyToken("verify-token")
|
|
if err != nil {
|
|
t.Fatalf("lookup verify token: %v", err)
|
|
}
|
|
if found.ID != channel.ID {
|
|
t.Fatalf("unexpected channel id: %d", found.ID)
|
|
}
|
|
if secret := resolveCloudAppSecret(found); secret != "app-secret" {
|
|
t.Fatalf("unexpected app secret: %q", secret)
|
|
}
|
|
}
|
|
|
|
func TestWhatsAppWebhookLookupUsesDigestForEncryptedToken(t *testing.T) {
|
|
db, err := gorm.Open(sqlite.Open("file:encrypted-whatsapp?mode=memory&cache=shared"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&model.Inbox{}, &channelmodel.ChannelWhatsApp{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key := base64.StdEncoding.EncodeToString([]byte("11111111111111111111111111111111"))
|
|
encryptor, err := security.NewEncryptor(security.EncryptionConfig{Enabled: true, AESKey: key, KeyVersion: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := security.RegisterGORMEncryption(db, encryptor); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inbox := model.Inbox{AccountID: 1, Name: "encrypted-wa", ChannelType: "whatsapp", ChannelID: 1, Enabled: true}
|
|
if err := db.Create(&inbox).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record := channelmodel.ChannelWhatsApp{AccountID: 1, InboxID: inbox.ID, PhoneNumber: "+15551234568", AccessToken: "access-token", WebhookVerifyToken: "verify-token"}
|
|
if err := db.Create(&record).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
handler := NewWebhookHandler(NewWhatsAppProvider(nil, NewRepository(db), nil))
|
|
found, err := handler.lookupByVerifyToken("verify-token")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if found.ID != record.ID || found.WebhookVerifyToken != "verify-token" {
|
|
t.Fatalf("unexpected encrypted lookup result: %#v", found)
|
|
}
|
|
}
|
|
|
|
func TestWhatsAppCloudSignatureUsesAppSecret(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
body := []byte(`{"object":"whatsapp_business_account"}`)
|
|
mac := hmac.New(sha256.New, []byte("app-secret"))
|
|
mac.Write(body)
|
|
signature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/phone", nil)
|
|
c.Request.Header.Set("X-Hub-Signature-256", signature)
|
|
|
|
h := NewWebhookHandler(nil)
|
|
if err := h.verifyCloudSignature(c, body, "app-secret"); err != nil {
|
|
t.Fatalf("verify signature: %v", err)
|
|
}
|
|
if err := h.verifyCloudSignature(c, body, "wrong-secret"); err == nil {
|
|
t.Fatal("expected wrong app secret to fail")
|
|
}
|
|
}
|
|
|
|
func TestWhatsAppWebhookRetriesWhenChannelConfigLookupFails(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWhatsAppWebhookTestDB(t)
|
|
inbox := model.Inbox{AccountID: 1, Name: "wa-retry", ChannelType: "whatsapp", ChannelID: 1, Enabled: true}
|
|
if err := db.Create(&inbox).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
channel := channelmodel.ChannelWhatsApp{
|
|
AccountID: 1,
|
|
InboxID: inbox.ID,
|
|
PhoneNumber: "+15550000000",
|
|
PhoneNumberID: "retry-phone-id",
|
|
AccessToken: "access-token",
|
|
Provider: "whatsapp_cloud",
|
|
}
|
|
if err := db.Create(&channel).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
channelQueries := 0
|
|
temporaryErr := errors.New("temporary channel lookup failure")
|
|
if err := db.Callback().Query().Before("gorm:query").Register("fail_second_channel_query", func(tx *gorm.DB) {
|
|
if tx.Statement.Table == channel.TableName() {
|
|
channelQueries++
|
|
if channelQueries == 2 {
|
|
_ = tx.AddError(temporaryErr)
|
|
}
|
|
}
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body := []byte(`{"object":"whatsapp_business_account","entry":[{"changes":[{"value":{"metadata":{"phone_number_id":"retry-phone-id"}}}]}]}`)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp", bytes.NewReader(body))
|
|
h := NewWebhookHandler(NewWhatsAppProvider(nil, NewRepository(db), nil))
|
|
h.HandleWebhookEvent(c)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want %d; body=%s", w.Code, http.StatusServiceUnavailable, w.Body.String())
|
|
}
|
|
}
|