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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 != ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user