From 0ea7a081e3ecf631e042f7820174d6df4e1f4333 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 00:29:41 +0800 Subject: [PATCH] feat(webhook): verify tiktok ingress signatures --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 5 +- internal/handler/webhook/tiktok_webhook.go | 57 +++++++++++++++++++ .../handler/webhook/webhook_lookup_test.go | 56 ++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 12b4623a..78c9c776 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -64,6 +64,7 @@ This ledger records the committed parity checkpoints that future slices should b | `66ecabb feat(webhook): persist provider receipt statuses` | Added delivery/read/failed status persistence for Twilio, WhatsApp, Facebook/Instagram, and TikTok receipt events. | Focused webhook/channel tests passed; full `go test ./...` passed. | Continue P6.7 review with async dispatch/events and broader provider fixture assertions. | | `06b999b feat(webhook): dispatch persisted provider events` | Wired webhook incoming persistence and status updates into the existing `channel.Dispatcher` fan-out boundary. | Focused webhook tests passed; full `go test ./...` passed. | Continue P6.7 review with broader provider fixture assertions. | | `55295dd test(webhook): cover provider ingress persistence fixtures` | Added provider-specific webhook persistence fixture assertions for LINE, Twilio SMS, WhatsApp, Instagram, and TikTok, extending the existing Telegram fixture. | Focused webhook tests passed; full `go test ./...` passed. | Continue P6.7 review with signature edge fixtures and final provider Done/Review classification. | +| Working tree | Added Chatwoot-style TikTok `Tiktok-Signature` HMAC verification using `TIKTOK_APP_SECRET`, timestamp freshness, and invalid-signature rejection coverage. | Focused webhook tests passed; full `go test ./...` passed. | Continue P6.7 review with LINE/WhatsApp missing-signature edge fixtures and final provider classification. | ## Next Slice Contract @@ -85,6 +86,7 @@ Current N1/N2 implementation checkpoint: - Added receipt persistence for status-only webhook events. Twilio delivery callbacks, WhatsApp statuses, Facebook/Instagram delivery/read receipts, and TikTok read receipts now update existing message statuses through the same boundary. Async event fan-out remains the next dispatch gap. - Wired the same boundary into `channel.Dispatcher` so incoming webhooks emit `contact.created`, `conversation.created/opened/updated`, `message.created/incoming`, and `message.status_updated` events for automation, CSAT, bot rules, notifications, and future async workers. - Added provider-specific persistence fixtures for Telegram, LINE, Twilio SMS, WhatsApp, Instagram, and TikTok. These tests assert durable `messages` rows by provider source ID instead of only checking webhook `200 OK` acknowledgements. +- Added TikTok webhook signature verification to match `reference/chatwoot/app/controllers/webhooks/tiktok_controller.rb`: `Tiktok-Signature` must include `t=,s=`, the HMAC is `sha256(TIKTOK_APP_SECRET, ".")`, and stale signatures older than five seconds are rejected. ## Immediate Execution Queue @@ -513,7 +515,7 @@ Webhook ingress subtracking: | P6.7d | SMS/Twilio `POST /webhooks/sms/:phone_number` | `webhooks/sms#process_payload` | Go path was `/webhooks/twilio/sms/:phone_number`; handler read an inbox-style param. | Chatwoot path is registered, phone number resolves `ChannelTwilioSMS`, signature verification is applied where configured, and message/status events dispatch. | Review | | P6.7e | WhatsApp `GET/POST /webhooks/whatsapp/:phone_number` | `webhooks/whatsapp#verify`, `#process_payload` | Verify-token lookup scanned only account `0`, and Cloud signature verification used access token as a placeholder secret. | Verify challenge and POST event ingestion match Chatwoot path, token, response, and inbox resolution behavior. | Review | | P6.7f | Instagram `GET/POST /webhooks/instagram` | `webhooks/instagram#verify`, `#events` | Chatwoot no-param route was registered but still returned parity stub responses. | Verify/event routes exist at Chatwoot paths and resolve account/inbox from payload/subscription data. | Review | -| P6.7g | TikTok `POST /webhooks/tiktok` | `webhooks/tiktok#events` | Go route expected `:business_id`; Chatwoot route has no path param and should derive identity from payload. | Handler accepts Chatwoot path, resolves business/inbox from payload, and acks/dispatches provider events. | Review | +| P6.7g | TikTok `POST /webhooks/tiktok` | `webhooks/tiktok#events` | Go route expected `:business_id`; Chatwoot route has no path param and should derive identity from payload. | Handler accepts Chatwoot path, verifies `Tiktok-Signature`, resolves business/inbox from payload, and persists/dispatches provider events. | Done | | P6.7h | Shopify `POST /webhooks/shopify` | `webhooks/shopify#events` | Chatwoot route existed but returned parity stub responses. | Route either has a real verified handler or is explicitly tracked as unsupported without placeholder success. | Review | | P6.7i | Generic fallback and auth middleware | Go `WebhookAuth`, `webhookStub` | Generic middleware reads `:channel_type/:identifier`, which breaks provider-specific routes; fallback returned placeholder success. | Provider routes perform provider-specific verification; fallback no longer masks missing providers with success JSON. | Done | @@ -606,3 +608,4 @@ Verification milestone gates: - 2026-06-04: Continued P6.7 dispatch parity by persisting provider receipt/status events. Existing messages are updated from Twilio delivery callbacks, WhatsApp sent/delivered/read/failed statuses, Facebook/Instagram delivery/read receipts, and TikTok read receipts. Added tests for direct status update and Twilio delivery callback update. Focused webhook/channel tests and full `go test ./...` passed. - 2026-06-05: Wired P6.7 incoming persistence into the existing dispatcher fan-out boundary. Newly persisted webhook contacts, conversations, messages, and message status updates now emit `ChannelEvent`s through `DispatchAsync`'s current sync fallback, keeping automation, bot rule, CSAT, and notification listeners reachable from provider webhooks. Added listener-based regression coverage for incoming and status events. Focused webhook tests and full `go test ./...` passed. - 2026-06-05: Broadened P6.7 provider webhook persistence fixtures. LINE, Twilio SMS, WhatsApp, Instagram, and TikTok webhook tests now assert persisted incoming `messages` by provider source ID, matching the existing Telegram persistence fixture and reducing the remaining provider-review surface to signature edge cases and final unsupported-provider classification. Focused webhook tests and full `go test ./...` passed. +- 2026-06-05: Added TikTok webhook signature parity. `/webhooks/tiktok` now rejects missing, invalid, or stale `Tiktok-Signature` values using the same timestamp-plus-body HMAC shape as the Chatwoot reference controller, while valid signed payloads still resolve the inbox from `biz_id` and persist incoming messages. Focused webhook tests and full `go test ./...` passed. diff --git a/internal/handler/webhook/tiktok_webhook.go b/internal/handler/webhook/tiktok_webhook.go index 632c0e89..6a2a9984 100644 --- a/internal/handler/webhook/tiktok_webhook.go +++ b/internal/handler/webhook/tiktok_webhook.go @@ -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 diff --git a/internal/handler/webhook/webhook_lookup_test.go b/internal/handler/webhook/webhook_lookup_test.go index aeb911d3..9559ca2c 100644 --- a/internal/handler/webhook/webhook_lookup_test.go +++ b/internal/handler/webhook/webhook_lookup_test.go @@ -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)