feat(webhook): verify tiktok ingress signatures
This commit is contained in:
@@ -11,8 +11,10 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
@@ -196,6 +198,12 @@ func metaSignature(secret string, body []byte) string {
|
||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func tiktokSignature(secret string, timestamp int64, body []byte) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "." + string(body)))
|
||||
return "t=" + strconv.FormatInt(timestamp, 10) + ",s=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func seedWebhookInbox(t *testing.T, db *gorm.DB, channelType string) model.Inbox {
|
||||
t.Helper()
|
||||
|
||||
@@ -485,6 +493,7 @@ func TestTikTokWebhookLookupInboxByBusinessIDAndPayloadExtractor(t *testing.T) {
|
||||
|
||||
func TestTikTokWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "tiktok")
|
||||
channelRecord := channelmodel.ChannelTikTok{AccountID: 1, InboxID: inbox.ID, TikTokBusinessID: "biz-123", WebhookVerifyToken: "verify-token"}
|
||||
@@ -502,6 +511,7 @@ func TestTikTokWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
body := []byte(`{"type":"message.received","timestamp":1710000000,"biz_id":"biz-123","data":{"message_id":"tt-msg-1","from_user_id":"tt-user-1","to_user_id":"biz-123","content_type":"text","content":"hello tiktok","timestamp":1710000000,"conversation_id":"tt-conv-1"}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/tiktok", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Tiktok-Signature", tiktokSignature("tiktok-secret", time.Now().Unix(), body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
@@ -512,6 +522,52 @@ func TestTikTokWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
assertPersistedMessage(t, db, inbox.ID, "tt-msg-1", "hello tiktok")
|
||||
}
|
||||
|
||||
func TestTikTokWebhookRejectsInvalidSignature(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "tiktok")
|
||||
channelRecord := channelmodel.ChannelTikTok{AccountID: 1, InboxID: inbox.ID, TikTokBusinessID: "biz-123", WebhookVerifyToken: "verify-token"}
|
||||
if err := db.Create(&channelRecord).Error; err != nil {
|
||||
t.Fatalf("create tiktok channel: %v", err)
|
||||
}
|
||||
|
||||
ttRepo := tiktokchannel.NewRepository(db)
|
||||
ttService := tiktokchannel.NewTikTokService(ttRepo)
|
||||
ttPipeline := tiktokchannel.NewIncomingProcessor(ttService, ttRepo)
|
||||
ttWebhook := tiktokchannel.NewWebhookHandler(ttService, ttPipeline)
|
||||
h := NewTikTokWebhookHandler(ttWebhook, ttPipeline, db)
|
||||
r := gin.New()
|
||||
r.POST("/webhooks/tiktok", h.HandleTikTokWebhook)
|
||||
body := []byte(`{"type":"message.received","timestamp":1710000000,"biz_id":"biz-123","data":{"message_id":"tt-msg-invalid","from_user_id":"tt-user-1","to_user_id":"biz-123","content_type":"text","content":"hello tiktok","timestamp":1710000000,"conversation_id":"tt-conv-1"}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/tiktok", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Tiktok-Signature", "t="+strconv.FormatInt(time.Now().Unix(), 10)+",s=bad")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.Message{}).Where("inbox_id = ? AND source_id = ?", inbox.ID, "tt-msg-invalid").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count message: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected no persisted message, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTikTokWebhookRejectsStaleSignature(t *testing.T) {
|
||||
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
||||
body := []byte(`{"type":"message.received","biz_id":"biz-123"}`)
|
||||
timestamp := time.Now().Add(-10 * time.Second).Unix()
|
||||
if err := verifyTikTokSignature(tiktokSignature("tiktok-secret", timestamp, body), body, time.Now()); err == nil {
|
||||
t.Fatal("expected stale signature rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShopifyWebhookShopRedactDeletesMatchingHook(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
|
||||
Reference in New Issue
Block a user