HH-540: scope iframe policy to Widget page (#123)

* fix(HH-540): scope iframe policy to widget page

* fix(HH-540): reject non-string allowed domains

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-23 18:37:08 +08:00
committed by GitHub
co-authored by rogee
parent 867092fd99
commit a2e4f9a1e8
9 changed files with 175 additions and 2 deletions
@@ -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
@@ -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()
+5
View File
@@ -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
+1
View File
@@ -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",
@@ -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"`
@@ -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).
@@ -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) {
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=0" />
<title>GoChat Widget</title>
<script vite-ignore src="/runtime-config.js"></script>
<script>
<script type="module">
(function () {
var cfg = window.__GOCHAT_CONFIG__ || {};
window.chatwootConfig = Object.assign(