From 05af5ebcbcb4734585fea49cd57d916cb6e8d2d3 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 10 Jul 2026 10:55:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20harden=20fake=20channel=20=E2=80=94=20pr?= =?UTF-8?q?oduction=20guard,=20token=20validation,=20typing=20events,=20UR?= =?UTF-8?q?L=20validation,=20capability=20narrowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard fake channel with GOCHAT_ENV check: skip init() registration, bootstrap wiring, and inbox creation in production - Reject empty-token webhooks in production (was silently skipped) - Use PostgreSQL jsonb @> query for inbox lookup, keep SQLite fallback - Replace isValidURL string-prefix hack with net/url.Parse - Handle typing.start/typing.stop by returning nil (no garbage messages) - Narrow Capabilities to only implemented features (Attachments, Replies) - Hide fake channel from frontend channel list in production builds --- backend/internal/app/bootstrap.go | 30 ++++++++-- backend/internal/channel/provider/fake.go | 47 ++++++++++++--- .../internal/channel/provider/fake_test.go | 60 ++++++++++++++++--- .../internal/handler/webhook/fake_webhook.go | 17 +++++- backend/internal/service/inbox_service.go | 14 ++++- .../dashboard/settings/inbox/ChannelList.vue | 10 +++- 6 files changed, 151 insertions(+), 27 deletions(-) diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index 7016f42a..04dca83c 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" "github.com/gin-gonic/gin" @@ -496,9 +497,15 @@ func Bootstrap(env string) (*App, error) { // FakeProvider is self-contained (no service/repo/pipeline deps), so we // only need to create the webhook handler here. The provider itself is // registered via init() in the provider package. - fakeProvider := channelprovider.NewFakeProvider() - fakeWebhookHandler := webhook.NewFakeWebhookHandler(fakeProvider, db, channelDispatcher) - fakeWebhookHandler.WithWorkerPool(workerPool) + // Skip in production — the fake channel is a test-only tool. + var fakeWebhookHandler *webhook.FakeWebhookHandler + if !isFakeChannelEnabled(env) { + applogger.L().Info("Fake channel provider skipped (production environment)") + } else { + fakeProvider := channelprovider.NewFakeProvider() + fakeWebhookHandler = webhook.NewFakeWebhookHandler(fakeProvider, db, channelDispatcher) + fakeWebhookHandler.WithWorkerPool(workerPool) + } // Create Email webhook handler (Gin HTTP handler for Email webhook endpoints) emailWebhook := emailchannel.NewWebhookHandler() @@ -723,7 +730,9 @@ func Bootstrap(env string) (*App, error) { tiktokWebhookHandler.WithSearchIndexer(searchIndexer) lineWebhookHandler.WithSearchIndexer(searchIndexer) twilioWebhookHandler.WithSearchIndexer(searchIndexer) - fakeWebhookHandler.WithSearchIndexer(searchIndexer) + if fakeWebhookHandler != nil { + fakeWebhookHandler.WithSearchIndexer(searchIndexer) + } // Event-triggered automation/AgentBot listeners are registered after the durable // search indexer exists so action side effects keep Meilisearch current. channelDispatcher.Register(automation.NewAgentBotRuleListenerWithSearchIndexer(&dbProvider{db: db}, searchIndexer)) @@ -1017,3 +1026,16 @@ func (a *hubTypingAdapter) SetTypingOff(ctx context.Context, accountID, conversa a.hub.SendToAccount(accountID, data) return nil } + + +// isFakeChannelEnabled reports whether the fake test channel should be wired. +// It is disabled in production environments to prevent test endpoints from +// being exposed in deployed images. +func isFakeChannelEnabled(env string) bool { + switch strings.ToLower(strings.TrimSpace(env)) { + case "production", "prod": + return false + default: + return true + } +} diff --git a/backend/internal/channel/provider/fake.go b/backend/internal/channel/provider/fake.go index 58f99832..69c134cd 100644 --- a/backend/internal/channel/provider/fake.go +++ b/backend/internal/channel/provider/fake.go @@ -7,6 +7,9 @@ import ( "fmt" "io" "net/http" + "net/url" + "os" + "strings" "time" "github.com/gochat/gochat/internal/channel" @@ -149,6 +152,11 @@ func (p *FakeProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, if payload.Content == "" { payload.Content = "[session ended]" } + case "typing.start", "typing.stop": + // Typing indicator events are not persisted as messages. Return nil + // so the webhook handler skips persistence and the event is not + // recorded as a conversation message. + return nil, nil } incoming := &channel.IncomingMessage{ @@ -189,7 +197,10 @@ func (p *FakeProvider) ValidateWebhookRequest(ctx context.Context, inbox *model. config := parseFakeConfig(inbox) expectedToken, _ := config["token"].(string) if expectedToken == "" { - // No token configured — skip verification (test convenience). + // No token configured — reject in production, allow only in dev/test. + if isProductionEnv() { + return fmt.Errorf("fake webhook token is required in production") + } return nil } providedToken := request.Headers["X-Fake-Token"] @@ -299,17 +310,20 @@ func (p *FakeProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox // === Capabilities === func (p *FakeProvider) Capabilities() channel.ChannelCapabilities { + // Only capabilities actually implemented by FakeProvider are + // declared. Unsupported features return false so the dispatcher does + // not wait for callbacks (e.g. delivery receipts) that never arrive. return channel.ChannelCapabilities{ SupportsAttachments: true, - SupportsLocation: true, - SupportsTypingIndicator: true, - SupportsDeliveryStatus: true, + SupportsLocation: false, + SupportsTypingIndicator: false, + SupportsDeliveryStatus: false, SupportsReplies: true, - SupportsEmojiReactions: true, - SupportsVoiceMessages: true, + SupportsEmojiReactions: false, + SupportsVoiceMessages: false, SupportsVideoCalls: false, - SupportsCustomCards: true, - SupportsTemplates: true, + SupportsCustomCards: false, + SupportsTemplates: false, SupportsEmailHeaders: false, MaxAttachmentSize: 50 * 1024 * 1024, MaxTextLength: 0, @@ -319,6 +333,9 @@ func (p *FakeProvider) Capabilities() channel.ChannelCapabilities { // init registers FakeProvider with the global channel registry. The provider // package is imported by bootstrap.go, so init() runs at startup. func init() { + if isProductionEnv() { + return + } channel.MustRegister(NewFakeProvider()) } @@ -336,9 +353,21 @@ func parseFakeConfig(inbox *model.Inbox) channel.ChannelConfig { return cfg } +// isProductionEnv reports whether the current GOCHAT_ENV indicates a +// production deployment. The fake channel is a test-only tool and must not +// register or accept traffic in production. +func isProductionEnv() bool { + env := strings.ToLower(strings.TrimSpace(os.Getenv("GOCHAT_ENV"))) + return env == "production" || env == "prod" +} + func isValidURL(s string) bool { if s == "" { return false } - return len(s) > 7 && (s[:7] == "http://" || s[:8] == "https://") + u, err := url.Parse(s) + if err != nil { + return false + } + return (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" } diff --git a/backend/internal/channel/provider/fake_test.go b/backend/internal/channel/provider/fake_test.go index 2e9b29c2..d65b91b2 100644 --- a/backend/internal/channel/provider/fake_test.go +++ b/backend/internal/channel/provider/fake_test.go @@ -303,17 +303,61 @@ func TestFakeProvider_Capabilities(t *testing.T) { if !caps.SupportsAttachments { t.Fatal("expected SupportsAttachments=true") } - if !caps.SupportsTypingIndicator { - t.Fatal("expected SupportsTypingIndicator=true") - } - if !caps.SupportsDeliveryStatus { - t.Fatal("expected SupportsDeliveryStatus=true") - } if !caps.SupportsReplies { t.Fatal("expected SupportsReplies=true") } - if !caps.SupportsEmojiReactions { - t.Fatal("expected SupportsEmojiReactions=true") + // Capabilities narrowed to only what FakeProvider actually implements. + if caps.SupportsTypingIndicator { + t.Fatal("expected SupportsTypingIndicator=false") + } + if caps.SupportsDeliveryStatus { + t.Fatal("expected SupportsDeliveryStatus=false") + } + if caps.SupportsEmojiReactions { + t.Fatal("expected SupportsEmojiReactions=false") + } + if caps.SupportsVoiceMessages { + t.Fatal("expected SupportsVoiceMessages=false") + } + if caps.SupportsCustomCards { + t.Fatal("expected SupportsCustomCards=false") + } + if caps.SupportsTemplates { + t.Fatal("expected SupportsTemplates=false") + } +} + +func TestFakeProvider_ProcessIncoming_TypingStartReturnsNil(t *testing.T) { + p := NewFakeProvider() + inbox := &model.Inbox{} + inbox.ID = 1 + inbox.AccountID = 10 + + payload := `{"event":"typing.start","message_id":"msg_t1","sender_id":"cust_1","content":"","timestamp":1720000000}` + + msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload)) + if err != nil { + t.Fatalf("ProcessIncoming for typing.start should not error, got: %v", err) + } + if msg != nil { + t.Fatalf("expected nil IncomingMessage for typing.start, got: %+v", msg) + } +} + +func TestFakeProvider_ProcessIncoming_TypingStopReturnsNil(t *testing.T) { + p := NewFakeProvider() + inbox := &model.Inbox{} + inbox.ID = 1 + inbox.AccountID = 10 + + payload := `{"event":"typing.stop","message_id":"msg_t2","sender_id":"cust_1","content":"","timestamp":1720000000}` + + msg, err := p.ProcessIncoming(context.Background(), inbox, []byte(payload)) + if err != nil { + t.Fatalf("ProcessIncoming for typing.stop should not error, got: %v", err) + } + if msg != nil { + t.Fatalf("expected nil IncomingMessage for typing.stop, got: %+v", msg) } } diff --git a/backend/internal/handler/webhook/fake_webhook.go b/backend/internal/handler/webhook/fake_webhook.go index 8861ba94..66f5567c 100644 --- a/backend/internal/handler/webhook/fake_webhook.go +++ b/backend/internal/handler/webhook/fake_webhook.go @@ -152,13 +152,26 @@ func (h *FakeWebhookHandler) HandleFakeWebhook(c *gin.Context) { } // lookupInbox finds the Inbox for a fake channel by config identifier. -// Fake channels store all config in Inbox.ChannelConfig JSON, so we query all -// fake inboxes and filter by the identifier field in Go (SQLite-compatible). +// On PostgreSQL, uses jsonb containment for a DB-side indexed lookup. +// On SQLite (test mode), falls back to loading all fake inboxes and +// filtering in Go — acceptable for low-volume test traffic. func (h *FakeWebhookHandler) lookupInbox(identifier string) (*model.Inbox, error) { if h.db == nil { return nil, fmt.Errorf("fake webhook database is not configured") } + // PostgreSQL: use jsonb @> for a server-side, indexable query. + if h.db.Dialector.Name() == "postgres" { + var inbox model.Inbox + // channel_config @> '{"identifier":""}' + query := fmt.Sprintf(`channel_type = 'fake' AND channel_config @> '{"identifier":"%s"}'`, identifier) + if err := h.db.Where(query).First(&inbox).Error; err != nil { + return nil, fmt.Errorf("fake inbox not found for identifier=%s: %w", identifier, err) + } + return &inbox, nil + } + + // SQLite fallback: load all fake inboxes and filter in Go. var inboxes []model.Inbox if err := h.db.Where("channel_type = ?", "fake").Find(&inboxes).Error; err != nil { return nil, fmt.Errorf("failed to query fake inboxes: %w", err) diff --git a/backend/internal/service/inbox_service.go b/backend/internal/service/inbox_service.go index 79494088..74f7555a 100644 --- a/backend/internal/service/inbox_service.go +++ b/backend/internal/service/inbox_service.go @@ -173,7 +173,7 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb "web_widget": true, "telegram": true, "facebook": true, "instagram": true, "whatsapp": true, "email": true, "api": true, "tiktok": true, "line": true, "twilio_sms": true, "sms": true, - "fake": true, + "fake": isFakeChannelAllowed(), } if !validChannelTypes[req.ChannelType] { return nil, errors.New("invalid channel_type") @@ -2125,3 +2125,15 @@ func generateRandomHex(n int) (string, error) { } return hex.EncodeToString(b), nil } + +// isFakeChannelAllowed reports whether the fake test channel is allowed. +// It is disabled in production environments. +func isFakeChannelAllowed() bool { + env := strings.ToLower(strings.TrimSpace(os.Getenv("GOCHAT_ENV"))) + switch env { + case "production", "prod": + return false + default: + return true + } +} diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index ce23fcf0..6388e7b2 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -77,13 +77,17 @@ const channelList = computed(() => { description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'), icon: 'i-woot-instagram', }, - { + ]; + + // Fake channel is a test-only tool — show only in development builds. + if (import.meta.env.DEV) { + channels.push({ key: 'fake', title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.FAKE.TITLE'), description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.FAKE.DESCRIPTION'), icon: 'i-woot-api', - }, - ]; + }); + } if (hasTiktokConfigured.value) { channels.push({