From eb83d241fede3c0bb6e2fc28f3170caa726cc2e5 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 23 Aug 2026 20:11:04 +0800 Subject: [PATCH] HH-547: allow cross-origin widget requests (#126) * HH-547: allow cross-origin widget requests * fix(HH-547): align production preflight with wildcard CORS --------- Co-authored-by: Rogee --- .env.example | 1 - backend/configs/config.production.yaml | 5 +-- backend/configs/config.yaml | 9 ++-- .../config/security_hardening_test.go | 5 +-- backend/internal/config/validator.go | 13 ------ backend/internal/middleware/cors.go | 17 ++++---- backend/internal/middleware/cors_test.go | 41 +++++++++++++++---- backend/internal/middleware/coverage7_test.go | 2 +- deploy/docker/docker-compose.prod.yml | 1 - deploy/docker/preflight.sh | 2 +- deploy/docker/preflight_test.sh | 2 +- .../store/modules/conversation/actions.js | 3 +- .../specs/conversation/actions.spec.js | 28 +++++++++++++ 13 files changed, 79 insertions(+), 50 deletions(-) diff --git a/.env.example b/.env.example index 8f2e1443..94d21ed1 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,6 @@ GOCHAT_IMAGE_REF=ghcr.io/rogeecn/gochat@sha256:CHANGE_ME GOCHAT_ENV=production GOCHAT_PORT=3000 GOCHAT_SERVER_MODE=release -GOCHAT_SERVER_CORS_ALLOWED_ORIGINS=https://chat.CHANGE_ME.example.com GOCHAT_SERVER_TRUSTED_PROXIES=10.0.0.0/8 GOCHAT_DATABASE_DSN=postgres://gochat:CHANGE_ME@db.CHANGE_ME.example.com:5432/gochat_production?sslmode=verify-full GOCHAT_REDIS_DSN=redis://redis:6379/1 diff --git a/backend/configs/config.production.yaml b/backend/configs/config.production.yaml index a96b1591..02bc8466 100644 --- a/backend/configs/config.production.yaml +++ b/backend/configs/config.production.yaml @@ -1,9 +1,6 @@ -# GoChat production overrides. Secrets and public origins must come from the environment. +# GoChat production overrides. Secrets must come from the environment. server: mode: "release" - cors: - allowed_origins: ["https://CHANGE_ME.example.com"] - allow_credentials: true database: dsn: "postgres://gochat:CHANGE_ME@postgres:5432/gochat_production?sslmode=disable" diff --git a/backend/configs/config.yaml b/backend/configs/config.yaml index 8da23e16..4b96768f 100644 --- a/backend/configs/config.yaml +++ b/backend/configs/config.yaml @@ -10,14 +10,11 @@ server: max_header_bytes: 1048576 trusted_proxies: [] # add explicit reverse-proxy IPs/CIDRs; XFF is ignored otherwise cors: - allowed_origins: [] # empty = Allow-Origin:* in debug mode; production must list exact origins - # Examples: - # - "https://app.example.com" - # - "*.example.com" + allowed_origins: [] # retained for config compatibility; GoChat allows all origins allowed_methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] - allowed_headers: ["Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "access-token", "client", "uid", "token-type", "expiry"] + allowed_headers: ["Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "X-Auth-Token", "X-Widget-Token", "X-Identifier-Hash", "access-token", "client", "uid", "token-type", "expiry"] expose_headers: ["Content-Length", "access-token", "client", "uid", "token-type", "expiry"] - allow_credentials: false # set to true only if you need cookies/auth headers + allow_credentials: false # retained for config compatibility; wildcard CORS does not use credentials max_age: 86400 # preflight cache duration in seconds database: diff --git a/backend/internal/config/security_hardening_test.go b/backend/internal/config/security_hardening_test.go index c5e692f7..f371a873 100644 --- a/backend/internal/config/security_hardening_test.go +++ b/backend/internal/config/security_hardening_test.go @@ -22,7 +22,7 @@ func validReleaseConfig() *Config { } } -func TestReleaseTransportAndCORSValidationFailsClosed(t *testing.T) { +func TestReleaseTransportValidationFailsClosed(t *testing.T) { tests := []struct { name string mutate func(*Config) @@ -30,9 +30,6 @@ func TestReleaseTransportAndCORSValidationFailsClosed(t *testing.T) { }{ {"database TLS without identity verification", func(c *Config) { c.Database.DSN = "postgres://user:pass@db.acme.test/gochat?sslmode=require" }, "sslmode=disable, verify-full or verify-ca"}, {"redis certificate not verified", func(c *Config) { c.Redis.DSN = "rediss://:redis-secret@redis.acme.test:6379?insecure_skip_verify=true" }, "cannot be disabled"}, - {"placeholder origin", func(c *Config) { c.Server.CORS.AllowedOrigins = []string{"https://example.com"} }, "not deployable"}, - {"non HTTPS origin", func(c *Config) { c.Server.CORS.AllowedOrigins = []string{"http://chat.acme.test"} }, "exact HTTPS origin"}, - {"wildcard origin", func(c *Config) { c.Server.CORS.AllowedOrigins = []string{"*.acme.test"} }, "exact HTTPS origin"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/backend/internal/config/validator.go b/backend/internal/config/validator.go index add28da7..4ae0259d 100644 --- a/backend/internal/config/validator.go +++ b/backend/internal/config/validator.go @@ -136,19 +136,6 @@ func Validate(cfg *Config) error { if redisURL.Scheme == "rediss" && strings.EqualFold(redisURL.Query().Get("insecure_skip_verify"), "true") { return fmt.Errorf("production Redis TLS certificate verification cannot be disabled") } - if len(cfg.Server.CORS.AllowedOrigins) == 0 { - return fmt.Errorf("production CORS requires at least one HTTPS origin") - } - for _, origin := range cfg.Server.CORS.AllowedOrigins { - parsed, err := url.Parse(origin) - lower := strings.ToLower(origin) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { - return fmt.Errorf("production CORS origin must be an exact HTTPS origin: %q", origin) - } - if strings.Contains(origin, "*") || strings.Contains(lower, "localhost") || strings.Contains(lower, "example.") || strings.Contains(lower, "yourdomain") || containsPlaceholder(origin) { - return fmt.Errorf("production CORS origin is not deployable: %q", origin) - } - } if cfg.JWT.AccessExpiryMinutes <= 0 || cfg.JWT.AccessExpiryMinutes > 15 { return fmt.Errorf("production JWT access_expiry_minutes must be between 1 and 15") } diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go index 71903103..500d1464 100644 --- a/backend/internal/middleware/cors.go +++ b/backend/internal/middleware/cors.go @@ -24,7 +24,7 @@ type CORSConfig struct { // Default CORS values for production. var defaultCORSMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"} -var defaultCORSHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "access-token", "client", "uid", "token-type", "expiry"} +var defaultCORSHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "X-Auth-Token", "X-Widget-Token", "X-Identifier-Hash", "access-token", "client", "uid", "token-type", "expiry"} var defaultCORSExposeHeaders = []string{"Content-Length", "access-token", "client", "uid", "token-type", "expiry"} var defaultCORSMaxAge = 86400 // 24 hours @@ -170,16 +170,13 @@ func extractHost(origin string) string { return u.Hostname() } -// CORSConfigFromAppConfig creates a middleware CORSConfig from the application config. +// CORSConfigFromAppConfig keeps method/header tuning while allowing every embed origin. func CORSConfigFromAppConfig(cfg *config.Config) CORSConfig { - devMode := cfg.Server.Mode == "debug" return CORSConfig{ - AllowedOrigins: cfg.Server.CORS.AllowedOrigins, - AllowedMethods: cfg.Server.CORS.AllowedMethods, - AllowedHeaders: cfg.Server.CORS.AllowedHeaders, - ExposeHeaders: cfg.Server.CORS.ExposeHeaders, - AllowCredentials: cfg.Server.CORS.AllowCredentials, - MaxAge: cfg.Server.CORS.MaxAge, - DevMode: devMode, + AllowedMethods: cfg.Server.CORS.AllowedMethods, + AllowedHeaders: cfg.Server.CORS.AllowedHeaders, + ExposeHeaders: cfg.Server.CORS.ExposeHeaders, + MaxAge: cfg.Server.CORS.MaxAge, + DevMode: true, } } diff --git a/backend/internal/middleware/cors_test.go b/backend/internal/middleware/cors_test.go index 37e95a69..eba59267 100644 --- a/backend/internal/middleware/cors_test.go +++ b/backend/internal/middleware/cors_test.go @@ -58,11 +58,38 @@ func TestCORS_DefaultAllowedHeadersIncludeChatwootAuthTokens(t *testing.T) { router.ServeHTTP(w, req) allowedHeaders := w.Header().Get("Access-Control-Allow-Headers") - for _, header := range []string{"access-token", "client", "uid", "token-type", "expiry"} { + for _, header := range []string{"access-token", "client", "uid", "token-type", "expiry", "X-Auth-Token", "X-Widget-Token", "X-Identifier-Hash"} { assert.Contains(t, allowedHeaders, header) } } +func TestCORSConfigFromAppConfig_AllowsWidgetPreflightFromAnyProductionOrigin(t *testing.T) { + cfg := &config.Config{Server: config.ServerConfig{ + Mode: "release", + CORS: config.CORSConfig{ + AllowedOrigins: []string{"https://gochat.example.com"}, + AllowCredentials: true, + }, + }} + router := gin.New() + router.Use(CORS(CORSConfigFromAppConfig(cfg))) + router.POST("/api/v1/widget/conversations/toggle_typing", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, "/api/v1/widget/conversations/toggle_typing", nil) + req.Header.Set("Origin", "https://embedded.example.net") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + req.Header.Set("Access-Control-Request-Headers", "X-Auth-Token, X-Widget-Token, X-Identifier-Hash") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + for _, header := range []string{"X-Auth-Token", "X-Widget-Token", "X-Identifier-Hash"} { + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), header) + } + assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials")) +} + func TestCORS_DefaultExposeHeadersIncludeChatwootAuthTokens(t *testing.T) { cfg := CORSConfig{DevMode: true} router := gin.New() @@ -316,8 +343,8 @@ func TestCORSConfigFromAppConfig_DebugMode(t *testing.T) { mwCfg := CORSConfigFromAppConfig(cfg) assert.True(t, mwCfg.DevMode) - assert.Equal(t, []string{"https://app.example.com"}, mwCfg.AllowedOrigins) - assert.True(t, mwCfg.AllowCredentials) + assert.Empty(t, mwCfg.AllowedOrigins) + assert.False(t, mwCfg.AllowCredentials) assert.Equal(t, 3600, mwCfg.MaxAge) } @@ -337,12 +364,12 @@ func TestCORSConfigFromAppConfig_ProductionMode(t *testing.T) { } mwCfg := CORSConfigFromAppConfig(cfg) - assert.False(t, mwCfg.DevMode) - assert.Equal(t, []string{"https://app.example.com", "*.internal.com"}, mwCfg.AllowedOrigins) + assert.True(t, mwCfg.DevMode) + assert.Empty(t, mwCfg.AllowedOrigins) assert.Equal(t, []string{"GET", "POST", "PUT"}, mwCfg.AllowedMethods) assert.Equal(t, []string{"Authorization", "Content-Type"}, mwCfg.AllowedHeaders) assert.Equal(t, []string{"X-Total-Count"}, mwCfg.ExposeHeaders) - assert.True(t, mwCfg.AllowCredentials) + assert.False(t, mwCfg.AllowCredentials) assert.Equal(t, 7200, mwCfg.MaxAge) } @@ -355,7 +382,7 @@ func TestCORSConfigFromAppConfig_EmptyCORS(t *testing.T) { } mwCfg := CORSConfigFromAppConfig(cfg) - assert.False(t, mwCfg.DevMode) + assert.True(t, mwCfg.DevMode) assert.Empty(t, mwCfg.AllowedOrigins) assert.Empty(t, mwCfg.AllowedMethods) // defaults applied in CORS() middleware, not here } diff --git a/backend/internal/middleware/coverage7_test.go b/backend/internal/middleware/coverage7_test.go index cd323f0b..9cf0221e 100644 --- a/backend/internal/middleware/coverage7_test.go +++ b/backend/internal/middleware/coverage7_test.go @@ -2018,7 +2018,7 @@ func TestCORSConfigFromAppConfig_Cov7(t *testing.T) { cfg.Server.CORS.AllowedOrigins = []string{"https://app.com"} result := CORSConfigFromAppConfig(cfg) assert.True(t, result.DevMode) - assert.Equal(t, []string{"https://app.com"}, result.AllowedOrigins) + assert.Empty(t, result.AllowedOrigins) } // =========================== diff --git a/deploy/docker/docker-compose.prod.yml b/deploy/docker/docker-compose.prod.yml index d96c6e1e..c00c7d17 100644 --- a/deploy/docker/docker-compose.prod.yml +++ b/deploy/docker/docker-compose.prod.yml @@ -41,7 +41,6 @@ x-gochat-environment: &gochat-environment GOCHAT_SERVER_HOST: 0.0.0.0 GOCHAT_SERVER_PORT: 3000 GOCHAT_SERVER_MODE: release - GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: ${GOCHAT_SERVER_CORS_ALLOWED_ORIGINS:?set production CORS origins} GOCHAT_DATABASE_DSN: &gochat-database-dsn ${GOCHAT_DATABASE_DSN:?set an external PostgreSQL DSN with an explicit sslmode} GOCHAT_DATABASE_RUN_MIGRATIONS: "false" GOCHAT_DATABASE_MIGRATIONS_PATH: /app/migrations diff --git a/deploy/docker/preflight.sh b/deploy/docker/preflight.sh index 505cfe28..6434a468 100755 --- a/deploy/docker/preflight.sh +++ b/deploy/docker/preflight.sh @@ -12,7 +12,7 @@ if (($#)); then compose_args=(--env-file "$env_file" "${compose_args[@]}") fi -required=(GOCHAT_IMAGE_REF SHANGWUTONG_IMAGE_REF GOCHAT_SERVER_CORS_ALLOWED_ORIGINS GOCHAT_DATABASE_DSN GOCHAT_REDIS_DSN MEILI_MASTER_KEY GOCHAT_JWT_SECRET GOCHAT_BACKUP_OFFSITE_DIR GOCHAT_BACKUP_OFFSITE_SOURCE GOCHAT_BACKUP_OFFSITE_FSTYPE) +required=(GOCHAT_IMAGE_REF SHANGWUTONG_IMAGE_REF GOCHAT_DATABASE_DSN GOCHAT_REDIS_DSN MEILI_MASTER_KEY GOCHAT_JWT_SECRET GOCHAT_BACKUP_OFFSITE_DIR GOCHAT_BACKUP_OFFSITE_SOURCE GOCHAT_BACKUP_OFFSITE_FSTYPE) for name in "${required[@]}"; do value=${!name:-} if [[ -z $value || ${value^^} == *CHANGE_ME* ]]; then diff --git a/deploy/docker/preflight_test.sh b/deploy/docker/preflight_test.sh index d6264469..68b9ae78 100755 --- a/deploy/docker/preflight_test.sh +++ b/deploy/docker/preflight_test.sh @@ -37,7 +37,7 @@ chmod +x "$tmp/bin/findmnt" "$tmp/bin/docker" export PATH="$tmp/bin:$PATH" export GOCHAT_IMAGE_REF='gochat@example.invalid/gochat@sha256:0000000000000000000000000000000000000000000000000000000000000000' export SHANGWUTONG_IMAGE_REF='gochat@example.invalid/connector@sha256:1111111111111111111111111111111111111111111111111111111111111111' -export GOCHAT_SERVER_CORS_ALLOWED_ORIGINS=https://chat.ci.rogeecn.com +unset GOCHAT_SERVER_CORS_ALLOWED_ORIGINS export GOCHAT_DATABASE_DSN='postgres://external_user:external_password@db.example.test:5432/gochat?sslmode=verify-full' export GOCHAT_REDIS_DSN='rediss://:ci-redis-secret@redis.example.test:6379/0' export POSTGRES_PASSWORD=ci-postgres-secret diff --git a/frontend/app/javascript/widget/store/modules/conversation/actions.js b/frontend/app/javascript/widget/store/modules/conversation/actions.js index aa08af61..1e2b06c7 100644 --- a/frontend/app/javascript/widget/store/modules/conversation/actions.js +++ b/frontend/app/javascript/widget/store/modules/conversation/actions.js @@ -186,7 +186,8 @@ export const actions = { commit('toggleAgentTypingStatus', data); }, - toggleUserTyping: async (_, data) => { + toggleUserTyping: async ({ getters }, data) => { + if (!getters.getConversationSize) return; try { await toggleTyping(data); } catch (error) { diff --git a/frontend/app/javascript/widget/store/modules/specs/conversation/actions.spec.js b/frontend/app/javascript/widget/store/modules/specs/conversation/actions.spec.js index 2ab17cb3..811e08d9 100644 --- a/frontend/app/javascript/widget/store/modules/specs/conversation/actions.spec.js +++ b/frontend/app/javascript/widget/store/modules/specs/conversation/actions.spec.js @@ -206,6 +206,34 @@ describe('#actions', () => { }); }); + describe('#toggleUserTyping', () => { + it('does not request typing before a conversation exists', async () => { + API.post.mockClear(); + + await actions.toggleUserTyping( + { getters: { getConversationSize: 0 } }, + { typingStatus: 'on' } + ); + + expect(API.post).not.toHaveBeenCalled(); + }); + + it('requests typing when a conversation exists', async () => { + API.post.mockClear(); + API.post.mockResolvedValue({ data: {} }); + + await actions.toggleUserTyping( + { getters: { getConversationSize: 1 } }, + { typingStatus: 'on' } + ); + + expect(API.post).toHaveBeenCalledWith( + '/api/v1/widget/conversations/toggle_typing', + { typing_status: 'on' } + ); + }); + }); + describe('#setCustomAttributes', () => { it('queues to pending state when no conversation exists', async () => { const rootGetters = {