fix: harden fake channel — production guard, token validation, typing events, URL validation, capability narrowing

- 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
This commit is contained in:
2026-07-10 10:55:54 +08:00
parent 9c852cd99b
commit 05af5ebcbc
6 changed files with 151 additions and 27 deletions
+26 -4
View File
@@ -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
}
}
+38 -9
View File
@@ -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 != ""
}
+52 -8
View File
@@ -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)
}
}
@@ -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":"<id>"}'
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)
+13 -1
View File
@@ -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
}
}
@@ -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({