diff --git a/backend/internal/handler/widget/widget_handler.go b/backend/internal/handler/widget/widget_handler.go index 62932106..41bc2685 100644 --- a/backend/internal/handler/widget/widget_handler.go +++ b/backend/internal/handler/widget/widget_handler.go @@ -29,6 +29,70 @@ func NewHandler(widgetService *service.WidgetService) *WidgetHandler { } } +// AllowIframeRequests applies Chatwoot's WidgetsController iframe policy only +// to the widget HTML response. +func (h *WidgetHandler) AllowIframeRequests(c *gin.Context) { + inbox, err := h.widgetService.GetInboxByWebsiteToken(c.Request.Context(), strings.TrimSpace(c.Query("website_token"))) + if err != nil { + c.Status(http.StatusNotFound) + c.Abort() + return + } + config, err := service.ParseWebWidgetConfig(inbox.ChannelConfig) + if err != nil { + c.Status(http.StatusInternalServerError) + c.Abort() + return + } + frameAncestors, ok := widgetFrameAncestors(config.AllowedDomains) + if !ok { + c.Status(http.StatusInternalServerError) + c.Abort() + return + } + + c.Writer.Header().Del("X-Frame-Options") + c.Header("Content-Security-Policy", widgetContentSecurityPolicy(c.Writer.Header().Get("Content-Security-Policy"), frameAncestors)) +} + +func widgetFrameAncestors(allowedDomains string) (string, bool) { + domains := make([]string, 0) + for _, domain := range strings.Split(allowedDomains, ",") { + domain = strings.TrimSpace(domain) + if domain == "" { + continue + } + if strings.ContainsAny(domain, "; \t\r\n") { + return "", false + } + domains = append(domains, domain) + } + return strings.Join(domains, " "), true +} + +func widgetContentSecurityPolicy(policy, frameAncestors string) string { + directives := strings.Split(policy, ";") + result := make([]string, 0, len(directives)) + found := false + for _, directive := range directives { + directive = strings.TrimSpace(directive) + if strings.HasPrefix(directive, "frame-ancestors ") { + found = true + if frameAncestors != "" { + result = append(result, "frame-ancestors "+frameAncestors) + } + continue + } + if directive != "" { + result = append(result, directive) + } + } + if frameAncestors != "" && !found { + result = append(result, "frame-ancestors "+frameAncestors) + } + return strings.Join(result, "; ") +} + // Init handles widget initialization — authenticates/creates a contact // and returns a widget_token (pubsub_token) for subsequent requests. // POST /widget/init diff --git a/backend/internal/handler/widget/widget_handler_test.go b/backend/internal/handler/widget/widget_handler_test.go index 36af8a43..550355d9 100644 --- a/backend/internal/handler/widget/widget_handler_test.go +++ b/backend/internal/handler/widget/widget_handler_test.go @@ -30,6 +30,7 @@ import ( "github.com/gochat/gochat/internal/campaign" "github.com/gochat/gochat/internal/config" apiv1 "github.com/gochat/gochat/internal/handler/api/v1" + "github.com/gochat/gochat/internal/middleware" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" @@ -243,6 +244,67 @@ func seedWidgetHandlerData(t *testing.T, db *gorm.DB) (*model.Account, *model.In return account, inbox } +func TestAllowIframeRequestsScopesEmbeddingToWidgetPage(t *testing.T) { + db, _, handler := setupWidgetHandlerTest(t) + account, _ := seedWidgetHandlerData(t, db) + + configJSON, err := json.Marshal(map[string]any{ + "website_token": "restricted_widget", + "allowed_domains": "https://allowed.example, https://support.example", + }) + require.NoError(t, err) + require.NoError(t, db.Create(&model.Inbox{ + AccountID: account.ID, Name: "Restricted Widget", ChannelType: "web_widget", Enabled: true, ChannelConfig: string(configJSON), + }).Error) + + invalidConfigJSON, err := json.Marshal(map[string]any{ + "website_token": "invalid_widget", + "allowed_domains": "https://allowed.example; frame-ancestors *", + }) + require.NoError(t, err) + require.NoError(t, db.Create(&model.Inbox{ + AccountID: account.ID, Name: "Invalid Widget", ChannelType: "web_widget", Enabled: true, ChannelConfig: string(invalidConfigJSON), + }).Error) + + router := gin.New() + router.Use(middleware.SecurityHeaders(middleware.DefaultSecurityHeadersConfig())) + router.GET("/widget", handler.AllowIframeRequests, func(c *gin.Context) { c.String(http.StatusOK, "widget") }) + router.GET("/app", func(c *gin.Context) { c.String(http.StatusOK, "dashboard") }) + + for _, origin := range []string{"https://go-web-inbox.yqbmb.com", "https://unrelated.example"} { + t.Run("default widget from "+origin, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/widget?website_token=handler_ws_token_123", nil) + request.Header.Set("Referer", origin+"/") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + assert.Equal(t, http.StatusOK, response.Code) + assert.Empty(t, response.Header().Get("X-Frame-Options")) + assert.NotContains(t, response.Header().Get("Content-Security-Policy"), "frame-ancestors") + assert.Contains(t, response.Header().Get("Content-Security-Policy"), "script-src 'self'") + }) + } + + restricted := httptest.NewRecorder() + router.ServeHTTP(restricted, httptest.NewRequest(http.MethodGet, "/widget?website_token=restricted_widget", nil)) + assert.Equal(t, http.StatusOK, restricted.Code) + assert.Empty(t, restricted.Header().Get("X-Frame-Options")) + assert.Contains(t, restricted.Header().Get("Content-Security-Policy"), "frame-ancestors https://allowed.example https://support.example") + assert.Contains(t, restricted.Header().Get("Content-Security-Policy"), "script-src 'self'") + + invalid := httptest.NewRecorder() + router.ServeHTTP(invalid, httptest.NewRequest(http.MethodGet, "/widget?website_token=invalid_widget", nil)) + assert.Equal(t, http.StatusInternalServerError, invalid.Code) + assert.Equal(t, "DENY", invalid.Header().Get("X-Frame-Options")) + assert.Contains(t, invalid.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'") + + dashboard := httptest.NewRecorder() + router.ServeHTTP(dashboard, httptest.NewRequest(http.MethodGet, "/app", nil)) + assert.Equal(t, http.StatusOK, dashboard.Code) + assert.Equal(t, "DENY", dashboard.Header().Get("X-Frame-Options")) + assert.Contains(t, dashboard.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'") +} + func seedPublicAPIInbox(t *testing.T, db *gorm.DB) (*model.Account, *model.Inbox, *channelmodel.ChannelAPI) { t.Helper() diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 42240604..9531180f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -298,6 +298,7 @@ func RegisterRoutes( // Widget API routes — public + CORS for embed (ref: Chatwoot namespace :widget_api) widget := engine.Group("/widget") widget.Use(middleware.CORS(corsCfg)) + widget.GET("", handlers.Widget.AllowIframeRequests, widgetIndex) registerWidgetRoutes(widget, handlers.Widget) // Widget direct file upload — visitor uploads file before conversation starts // Reference: Chatwoot POST /widget/direct_uploads @@ -2241,6 +2242,10 @@ func dashboardIndex(c *gin.Context) { c.File(filepath.Join(frontendDistDir(), "index.html")) } +func widgetIndex(c *gin.Context) { + c.File(filepath.Join(frontendDistDir(), "widget.html")) +} + func dashboardWantsJSON(c *gin.Context) bool { if strings.HasSuffix(c.Request.URL.Path, ".json") { return true diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index aa708692..d80aed4f 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -54,6 +54,7 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { expected := []string{ "GET /", + "GET /widget", "GET /.well-known/assetlinks.json", "GET /.well-known/apple-app-site-association", "GET /.well-known/microsoft-identity-association.json", diff --git a/backend/internal/service/inbox_service.go b/backend/internal/service/inbox_service.go index a2b74bf2..b7d4ecfc 100644 --- a/backend/internal/service/inbox_service.go +++ b/backend/internal/service/inbox_service.go @@ -1192,6 +1192,7 @@ func (s *InboxService) deleteShangwutongInbox(ctx context.Context, inbox *model. type WebWidgetConfig struct { WebsiteToken string `json:"website_token"` HMACToken string `json:"hmac_token"` + AllowedDomains string `json:"allowed_domains,omitempty"` WidgetColor string `json:"widget_color,omitempty"` WelcomeTitle string `json:"welcome_title,omitempty"` WelcomeTagline string `json:"welcome_tagline,omitempty"` diff --git a/backend/internal/service/widget_service.go b/backend/internal/service/widget_service.go index 392e8a57..d4dc7cb9 100644 --- a/backend/internal/service/widget_service.go +++ b/backend/internal/service/widget_service.go @@ -1863,6 +1863,15 @@ func ParseWebWidgetConfig(channelConfig string) (*WebWidgetConfig, error) { if channelConfig == "" { return nil, errors.New("empty channel_config") } + var rawConfig struct { + AllowedDomains json.RawMessage `json:"allowed_domains"` + } + if err := json.Unmarshal([]byte(channelConfig), &rawConfig); err == nil && rawConfig.AllowedDomains != nil { + var allowedDomains *string + if err := json.Unmarshal(rawConfig.AllowedDomains, &allowedDomains); err != nil || allowedDomains == nil { + return nil, errors.New("allowed_domains must be a JSON string") + } + } var config WebWidgetConfig if err := json.Unmarshal([]byte(channelConfig), &config); err != nil { // Tolerate type mismatch errors (e.g., "" for a bool field). diff --git a/backend/internal/service/widget_service_test.go b/backend/internal/service/widget_service_test.go index 3ed192f1..8a5594c5 100644 --- a/backend/internal/service/widget_service_test.go +++ b/backend/internal/service/widget_service_test.go @@ -1860,6 +1860,37 @@ func TestParseWebWidgetConfig_InvalidJSON(t *testing.T) { assert.Error(t, err) } +func TestParseWebWidgetConfig_AllowedDomainsType(t *testing.T) { + tests := []struct { + name string + channelConfig string + want string + wantErr bool + }{ + {name: "missing", channelConfig: `{}`, want: ""}, + {name: "empty string", channelConfig: `{"allowed_domains":""}`, want: ""}, + {name: "string", channelConfig: `{"allowed_domains":"https://allowed.example"}`, want: "https://allowed.example"}, + {name: "array", channelConfig: `{"allowed_domains":["https://allowed.example"]}`, wantErr: true}, + {name: "object", channelConfig: `{"allowed_domains":{"domain":"https://allowed.example"}}`, wantErr: true}, + {name: "number", channelConfig: `{"allowed_domains":1}`, wantErr: true}, + {name: "boolean", channelConfig: `{"allowed_domains":true}`, wantErr: true}, + {name: "null", channelConfig: `{"allowed_domains":null}`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := ParseWebWidgetConfig(tt.channelConfig) + if tt.wantErr { + assert.Nil(t, config) + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, config.AllowedDomains) + }) + } +} + // ========== File Helper Tests ========== func TestDetectMIMEFromFilename(t *testing.T) { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6f287e2f..fb65929b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -57,7 +57,7 @@ const resolveAliases = { // Entrypoints that vite-plugin-ruby used to auto-discover const entrypoints = { dashboard: path.resolve(__dirname, './index.html'), - widget: path.resolve(__dirname, './app/javascript/entrypoints/widget.js'), + widget: path.resolve(__dirname, './widget.html'), portal: path.resolve(__dirname, './app/javascript/entrypoints/portal.js'), superadmin: path.resolve( __dirname, diff --git a/frontend/widget.html b/frontend/widget.html index 8247c42d..bc94da44 100644 --- a/frontend/widget.html +++ b/frontend/widget.html @@ -5,7 +5,7 @@