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