feat(webhook): verify tiktok ingress signatures

This commit is contained in:
2026-06-05 00:29:41 +08:00
parent 959a56bac1
commit 0ea7a081e3
3 changed files with 117 additions and 1 deletions
@@ -6,10 +6,17 @@ package webhook
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gochat/gochat/internal/channel"
tiktokchannel "github.com/gochat/gochat/internal/channel/tiktok"
@@ -50,6 +57,12 @@ func (h *TikTokWebhookHandler) HandleTikTokWebhook(c *gin.Context) {
c.Request.Body.Close()
c.Request.Body = io.NopCloser(bytes.NewReader(body))
if err := verifyTikTokSignature(c.GetHeader("Tiktok-Signature"), body, time.Now()); err != nil {
applogger.L().Warnf("TikTok webhook: signature verification failed: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "signature verification failed"})
return
}
businessID := c.Param("business_id")
if businessID == "" {
businessID = extractTikTokBusinessID(body)
@@ -176,6 +189,50 @@ func extractTikTokBusinessID(body []byte) string {
return ""
}
func verifyTikTokSignature(signatureHeader string, body []byte, now time.Time) error {
clientSecret := os.Getenv("TIKTOK_APP_SECRET")
timestamp, signature := extractTikTokSignatureParts(signatureHeader)
if clientSecret == "" || timestamp == 0 || signature == "" {
return fmt.Errorf("missing tiktok signature credentials")
}
payload := fmt.Sprintf("%d.%s", timestamp, string(body))
mac := hmac.New(sha256.New, []byte(clientSecret))
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) {
return fmt.Errorf("invalid tiktok signature")
}
if now.Unix()-timestamp > 5 {
return fmt.Errorf("stale tiktok signature")
}
return nil
}
func extractTikTokSignatureParts(signatureHeader string) (int64, string) {
if signatureHeader == "" {
return 0, ""
}
var timestamp int64
var signature string
for _, part := range strings.Split(signatureHeader, ",") {
keyValue := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(keyValue) != 2 {
continue
}
switch keyValue[0] {
case "t":
parsed, err := strconv.ParseInt(keyValue[1], 10, 64)
if err == nil {
timestamp = parsed
}
case "s":
signature = keyValue[1]
}
}
return timestamp, signature
}
func tiktokDataString(data map[string]interface{}, key string) string {
if value, ok := data[key].(string); ok {
return value
@@ -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)