From f719529d66801413098a11516af517ad9b00f747 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 22 Aug 2026 15:45:06 +0800 Subject: [PATCH] fix(security): harden auth and secret handling (HH-444) (#101) * fix(security): harden auth and credential handling (HH-444) * fix(security): address HH-444 review blockers * fix(security): close remaining HH-444 review blockers --------- Co-authored-by: Rogee --- .env.example | 14 +- .github/workflows/ci.yml | 37 +++- backend/cmd/rotate_secrets/main.go | 72 ++++++++ backend/configs/config.development.yaml | 2 +- backend/configs/config.yaml | 23 ++- backend/internal/app/bootstrap.go | 26 ++- backend/internal/auth/jwt.go | 44 ++++- backend/internal/auth/oidc.go | 2 +- backend/internal/auth/refresh_store.go | 2 +- .../internal/auth/security_hardening_test.go | 59 +++++++ backend/internal/auth/ws_ticket.go | 103 +++++++++++ backend/internal/channel/config.go | 32 +++- .../channel/telegram/webhook_handler.go | 1 - .../channel/tiktok/webhook_handler.go | 7 +- .../channel/whatsapp/webhook_handler.go | 12 +- .../channel/whatsapp/webhook_handler_test.go | 37 ++++ backend/internal/config/config.go | 145 +++++++++++----- backend/internal/config/config_test.go | 60 ++++--- .../config/security_hardening_test.go | 66 ++++++++ backend/internal/config/validator.go | 60 ++++++- .../encrypted_secret_migration_test.go | 25 +++ .../database/upload_migration_test.go | 2 +- .../handler/api/v1/agent_bot_handler.go | 13 +- .../internal/handler/api/v1/auth_handler.go | 160 +++++++++++++++--- .../handler/api/v1/auth_handler_test.go | 58 ++++++- .../api/v1/inbox_handler_parity_test.go | 27 ++- .../handler/api/v1/inbox_serializer.go | 43 ++++- .../api/v1/integration_hook_handler.go | 3 + .../handler/api/v1/security_hardening_test.go | 31 ++++ .../api/v1/webhook_subscription_handler.go | 9 +- .../handler/webhook/telegram_webhook.go | 2 +- .../handler/widget/widget_theme_handler.go | 2 +- backend/internal/middleware/cors.go | 20 ++- backend/internal/middleware/csrf.go | 3 +- backend/internal/middleware/logger.go | 7 +- .../middleware/security_hardening_test.go | 86 ++++++++++ .../middleware/security_rate_limit.go | 94 ++++++++++ backend/internal/model/access_token.go | 22 +-- backend/internal/model/agent_bot.go | 8 +- backend/internal/model/channel/api.go | 4 +- backend/internal/model/channel/email.go | 6 +- backend/internal/model/channel/facebook.go | 6 +- backend/internal/model/channel/instagram.go | 2 +- backend/internal/model/channel/tiktok.go | 6 +- backend/internal/model/channel/web_widget.go | 16 +- backend/internal/model/channel/whatsapp.go | 23 ++- backend/internal/model/inbox.go | 6 +- backend/internal/model/integration_hook.go | 8 +- backend/internal/model/sensitive_json_test.go | 32 ++++ backend/internal/model/user.go | 58 +++---- .../internal/model/webhook_subscription.go | 4 +- backend/internal/security/encryption.go | 82 ++++++--- backend/internal/security/gorm_encryption.go | 111 ++++++++++++ .../internal/security/gorm_encryption_test.go | 71 ++++++++ .../internal/service/auth_security_test.go | 35 ++++ backend/internal/service/auth_service.go | 23 ++- .../internal/service/push_delivery_service.go | 6 +- backend/internal/service/widget_service.go | 2 +- backend/internal/ws/auth.go | 79 ++++++--- .../internal/ws/security_hardening_test.go | 66 ++++++++ ...84_expand_encrypted_secret_fields.down.sql | 5 + ...0084_expand_encrypted_secret_fields.up.sql | 38 +++++ backend/tests/helpers/pg_helper.go | 1 + deploy/docker/docker-compose.prod-smoke.yml | 48 ++++++ deploy/docker/docker-compose.prod.yml | 12 +- deploy/docker/preflight_test.sh | 2 +- .../2026-07-15-cdp-user-function-test-plan.md | 2 +- docs/security-key-rotation.md | 10 ++ frontend/app/javascript/dashboard/api/auth.js | 24 +-- .../app/javascript/dashboard/api/auth.spec.js | 17 ++ .../javascript/dashboard/helper/APIHelper.js | 59 ++++--- .../dashboard/helper/APIHelper.spec.js | 43 +++++ .../store/modules/specs/auth/actions.spec.js | 1 - .../javascript/dashboard/store/utils/api.js | 15 +- .../dashboard/store/utils/api.spec.js | 32 ++++ .../javascript/entrypoints/dashboardConfig.js | 23 +-- .../helpers/BaseActionCableConnector.js | 78 +++++---- .../specs/BaseActionCableConnector.spec.js | 121 +++++++++---- frontend/app/javascript/v3/api/apiClient.js | 2 +- .../app/javascript/v3/helpers/AuthHelper.js | 2 +- frontend/super_admin.html | 23 --- 81 files changed, 2149 insertions(+), 474 deletions(-) create mode 100644 backend/cmd/rotate_secrets/main.go create mode 100644 backend/internal/auth/security_hardening_test.go create mode 100644 backend/internal/auth/ws_ticket.go create mode 100644 backend/internal/config/security_hardening_test.go create mode 100644 backend/internal/database/encrypted_secret_migration_test.go create mode 100644 backend/internal/handler/api/v1/security_hardening_test.go create mode 100644 backend/internal/middleware/security_hardening_test.go create mode 100644 backend/internal/middleware/security_rate_limit.go create mode 100644 backend/internal/model/sensitive_json_test.go create mode 100644 backend/internal/security/gorm_encryption.go create mode 100644 backend/internal/security/gorm_encryption_test.go create mode 100644 backend/internal/service/auth_security_test.go create mode 100644 backend/internal/ws/security_hardening_test.go create mode 100644 backend/migrations/000084_expand_encrypted_secret_fields.down.sql create mode 100644 backend/migrations/000084_expand_encrypted_secret_fields.up.sql create mode 100644 deploy/docker/docker-compose.prod-smoke.yml create mode 100644 docs/security-key-rotation.md create mode 100644 frontend/app/javascript/dashboard/api/auth.spec.js create mode 100644 frontend/app/javascript/dashboard/helper/APIHelper.spec.js create mode 100644 frontend/app/javascript/dashboard/store/utils/api.spec.js diff --git a/.env.example b/.env.example index 2552562c..5bf6f4f5 100644 --- a/.env.example +++ b/.env.example @@ -5,12 +5,14 @@ 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=rediss://:CHANGE_ME@redis.CHANGE_ME.example.com:6380/0 POSTGRES_DB=gochat_production POSTGRES_USER=gochat POSTGRES_PASSWORD=CHANGE_ME -# The built-in PostgreSQL service is non-TLS. For an external database, set a -# complete GOCHAT_DATABASE_DSN with sslmode=require, verify-ca, or verify-full. +# Release mode requires external PostgreSQL/Redis endpoints with verified TLS. POSTGRES_IMAGE_REF=pgvector/pgvector:pg16@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b REDIS_IMAGE_REF=redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf @@ -22,6 +24,14 @@ GOCHAT_JWT_SECRET=CHANGE_ME_WITH_AT_LEAST_32_RANDOM_CHARACTERS # Optional during a bounded rotation window; comma-separated old 32+ byte secrets. GOCHAT_JWT_PREVIOUS_SECRETS= GOCHAT_JWT_ALLOW_INSECURE_HEADER_AUTH=false +GOCHAT_JWT_ACCESS_EXPIRY_MINUTES=15 +GOCHAT_JWT_REFRESH_EXPIRY_HOURS=168 +GOCHAT_JWT_WS_TICKET_TTL_SECONDS=30 + +# Generate with: openssl rand -base64 32 +GOCHAT_ENCRYPTION_ENABLED=true +GOCHAT_ENCRYPTION_CURRENT_KEY_VERSION=1 +GOCHAT_ENCRYPTION_AES_KEY=CHANGE_ME # Optional connector. Supply the digest published by its release pipeline. SHANGWUTONG_IMAGE_REF=ghcr.io/rogeecn/shangwutong@sha256:CHANGE_ME diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 764ca3ed..226a9110 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,7 +225,11 @@ jobs: env: GOCHAT_IMAGE_REF: gochat:production-smoke GOCHAT_PORT: "38080" - GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: https://chat.example.test + GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: https://chat.ci.rogeecn.com + GOCHAT_DATABASE_DSN: "postgres://gochat:ci-postgres-secret@postgres:5432/gochat_production?sslmode=verify-full&sslrootcert=/run/tls/ca.crt" + GOCHAT_REDIS_DSN: "rediss://:ci-redis-secret@redis:6379/0" + GOCHAT_ENCRYPTION_AES_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + GOCHAT_TLS_DIR: ${{ github.workspace }}/.tmp/gochat-tls POSTGRES_PASSWORD: ci-postgres-secret REDIS_PASSWORD: ci-redis-secret MEILI_MASTER_KEY: ci-meili-secret-16 @@ -271,20 +275,37 @@ jobs: 'test -s /app/configs/config.production.yaml && find /app/migrations -maxdepth 1 -type f -name "*.sql" -print -quit | grep -q . && test -s /app/frontend/dist/index.html && test -s /app/frontend/dist/favicon-32x32.png' docker run --rm --entrypoint sh "$SHANGWUTONG_IMAGE_REF" -c \ 'test -x /usr/local/bin/shangwutong && test -d /data && test -d /backup && test "$(id -u)" -eq 10001' + - name: Create trusted TLS fixtures + run: | + mkdir -p "$GOCHAT_TLS_DIR" + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=GoChat CI CA' \ + -keyout "$GOCHAT_TLS_DIR/ca.key" -out "$GOCHAT_TLS_DIR/ca.crt" + for service in postgres redis; do + openssl req -newkey rsa:2048 -nodes -subj "/CN=$service" \ + -addext "subjectAltName=DNS:$service" \ + -keyout "$GOCHAT_TLS_DIR/$service.key" -out "$GOCHAT_TLS_DIR/$service.csr" + printf 'subjectAltName=DNS:%s\n' "$service" > "$GOCHAT_TLS_DIR/$service.ext" + openssl x509 -req -days 1 -CA "$GOCHAT_TLS_DIR/ca.crt" -CAkey "$GOCHAT_TLS_DIR/ca.key" \ + -CAcreateserial -extfile "$GOCHAT_TLS_DIR/$service.ext" \ + -in "$GOCHAT_TLS_DIR/$service.csr" -out "$GOCHAT_TLS_DIR/$service.crt" + done + chmod 600 "$GOCHAT_TLS_DIR"/*.key + chmod 644 "$GOCHAT_TLS_DIR"/*.crt - name: Start production Compose and smoke core pages run: | deploy/docker/preflight_test.sh docker compose -f deploy/docker/docker-compose.prod.yml config --format json | python3 -c 'import json, os, sys; config = json.load(sys.stdin); assert all(config["services"][service]["environment"]["GOCHAT_JWT_PREVIOUS_SECRETS"] == os.environ["GOCHAT_JWT_PREVIOUS_SECRETS"] for service in ("gochat", "worker"))' - docker compose -f deploy/docker/docker-compose.prod.yml --profile ops run --rm migrate - docker compose -f deploy/docker/docker-compose.prod.yml up -d --wait gochat shangwutong - docker compose -f deploy/docker/docker-compose.prod.yml up -d worker + compose=(docker compose -f deploy/docker/docker-compose.prod.yml -f deploy/docker/docker-compose.prod-smoke.yml) + "${compose[@]}" --profile ops run --rm migrate + "${compose[@]}" up -d --wait gochat shangwutong + "${compose[@]}" up -d worker for service in gochat worker shangwutong; do - container_id="$(docker compose -f deploy/docker/docker-compose.prod.yml ps -q "$service")" + container_id="$("${compose[@]}" ps -q "$service")" test -n "$container_id" test "$(docker inspect --format '{{.State.Status}}' "$container_id")" = running done for service in gochat shangwutong; do - container_id="$(docker compose -f deploy/docker/docker-compose.prod.yml ps -q "$service")" + container_id="$("${compose[@]}" ps -q "$service")" test "$(docker inspect --format '{{.State.Health.Status}}' "$container_id")" = healthy done curl -fsS "http://127.0.0.1:$GOCHAT_PORT/health" | grep -q '"status":"healthy"' @@ -292,12 +313,12 @@ jobs: curl -fsS "http://127.0.0.1:$GOCHAT_PORT/runtime-config.js" | grep -q 'window.__GOCHAT_CONFIG__' curl -fsS -o "$RUNNER_TEMP/favicon-32x32.png" "http://127.0.0.1:$GOCHAT_PORT/favicon-32x32.png" test -s "$RUNNER_TEMP/favicon-32x32.png" - docker compose -f deploy/docker/docker-compose.prod.yml exec -T shangwutong \ + "${compose[@]}" exec -T shangwutong \ wget -q -T 3 -O - http://127.0.0.1:9100/readyz | grep -q '"status":"ready"' - name: Stop production Compose if: always() run: | - docker compose -f deploy/docker/docker-compose.prod.yml down -v + docker compose -f deploy/docker/docker-compose.prod.yml -f deploy/docker/docker-compose.prod-smoke.yml down -v docker rm -f gochat-ci-registry || true release: diff --git a/backend/cmd/rotate_secrets/main.go b/backend/cmd/rotate_secrets/main.go new file mode 100644 index 00000000..31cda997 --- /dev/null +++ b/backend/cmd/rotate_secrets/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "os" + "strconv" + + "github.com/gochat/gochat/internal/app" + "github.com/gochat/gochat/internal/config" + "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/gochat/gochat/internal/security" + "gorm.io/gorm" +) + +func main() { + env := os.Getenv("GOCHAT_ENV") + if env == "" { + env = "prod" + } + cfg, err := config.LoadWithEnv(env) + if err != nil { + panic(err) + } + db, err := app.NewDatabase(&cfg.Database, "silent") + if err != nil { + panic(err) + } + previous := make(map[int]string, len(cfg.Encryption.PreviousKeys)) + for version, key := range cfg.Encryption.PreviousKeys { + parsed, err := strconv.Atoi(version) + if err != nil { + panic(err) + } + previous[parsed] = key + } + encryptor, err := security.NewEncryptorWithPreviousKeys(security.EncryptionConfig{ + Enabled: cfg.Encryption.Enabled, AESKey: cfg.Encryption.AESKey, KeyVersion: cfg.Encryption.CurrentKeyVersion, + }, previous) + if err != nil { + panic(err) + } + if err := security.RegisterGORMEncryption(db, encryptor); err != nil { + panic(err) + } + for _, rotate := range []func(*gorm.DB) error{ + rotateRows[model.AgentBot], rotateRows[model.Inbox], rotateRows[model.IntegrationHook], + rotateRows[model.WebhookSubscription], rotateRows[model.User], rotateRows[channelmodel.ChannelAPI], + rotateRows[channelmodel.ChannelFacebook], rotateRows[channelmodel.ChannelInstagram], rotateRows[channelmodel.ChannelTikTok], + rotateRows[channelmodel.ChannelWhatsApp], rotateRows[channelmodel.ChannelEmail], rotateRows[channelmodel.ChannelWebWidget], + } { + if err := rotate(db); err != nil { + panic(err) + } + } + fmt.Printf("sensitive fields rotated to encryption key v%d\n", cfg.Encryption.CurrentKeyVersion) +} + +func rotateRows[T any](db *gorm.DB) error { + var rows []T + if err := db.Find(&rows).Error; err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + for i := range rows { + if err := tx.Save(&rows[i]).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/backend/configs/config.development.yaml b/backend/configs/config.development.yaml index 22c038fa..2ab3f470 100644 --- a/backend/configs/config.development.yaml +++ b/backend/configs/config.development.yaml @@ -8,7 +8,7 @@ jwt: allow_insecure_header_auth: true database: - dsn: "postgres://postgres:xiha02@localhost:5444/gochat_dev?sslmode=disable" + dsn: "postgres://postgres@localhost:5432/gochat_dev?sslmode=disable" log_level: "info" log: diff --git a/backend/configs/config.yaml b/backend/configs/config.yaml index f94e2912..8da23e16 100644 --- a/backend/configs/config.yaml +++ b/backend/configs/config.yaml @@ -8,6 +8,7 @@ server: idle_timeout_seconds: 120 shutdown_timeout_seconds: 30 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: @@ -20,7 +21,7 @@ server: max_age: 86400 # preflight cache duration in seconds database: - dsn: "postgres://postgres:xiha02@localhost:5444/gochat_dev?sslmode=disable" + dsn: "postgres://postgres@localhost:5432/gochat_dev?sslmode=disable" max_idle_conns: 10 max_open_conns: 100 conn_max_lifetime: 3600 # seconds @@ -28,14 +29,24 @@ database: migrations_path: "migrations" redis: - dsn: "redis://:xiha02@localhost:6397/0" + dsn: "redis://localhost:6379/0" pool_size: 50 jwt: secret: "gochat_dev_secret_change_in_production" previous_secrets: [] allow_insecure_header_auth: false - expiry_hours: 72 + access_expiry_minutes: 15 + refresh_expiry_hours: 168 + ws_ticket_ttl_seconds: 30 + issuer: "gochat" + audience: "gochat-api" + +encryption: + enabled: false + current_key_version: 1 + aes_key: "" + previous_keys: {} log: level: "debug" # debug, info, warn, error @@ -43,8 +54,10 @@ log: rate_limit: enabled: true - requests_per_minute: 100 # max requests per client IP per window - window_seconds: 60 # sliding window duration in seconds + login: { requests: 10, window_seconds: 60 } + password_reset: { requests: 5, window_seconds: 300 } + public_upload: { requests: 20, window_seconds: 60 } + webhook: { requests: 120, window_seconds: 60 } search: # Chatwoot parity target. Use "db" only for explicit local fallback. diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index f090223d..f276853b 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" "strings" "sync/atomic" "time" @@ -101,6 +102,20 @@ func Bootstrap(env string) (*App, error) { if err != nil { return nil, fmt.Errorf("database init failed: %w", err) } + previousKeys := make(map[int]string, len(cfg.Encryption.PreviousKeys)) + for version, key := range cfg.Encryption.PreviousKeys { + parsed, _ := strconv.Atoi(version) + previousKeys[parsed] = key + } + encryptor, err := security.NewEncryptorWithPreviousKeys(security.EncryptionConfig{ + Enabled: cfg.Encryption.Enabled, AESKey: cfg.Encryption.AESKey, KeyVersion: cfg.Encryption.CurrentKeyVersion, + }, previousKeys) + if err != nil { + return nil, fmt.Errorf("encryption init failed: %w", err) + } + if err := security.RegisterGORMEncryption(db, encryptor); err != nil { + return nil, fmt.Errorf("encryption callbacks failed: %w", err) + } // Step 4b: Run database migrations if enabled (ref: Chatwoot ActiveRecord migration pattern) if cfg.Database.RunMigrations { @@ -143,6 +158,7 @@ func Bootstrap(env string) (*App, error) { // Step 6: Wire auth infrastructure (P2E §1) jwtService := auth.NewJWTService(&cfg.JWT) refreshStore := auth.NewRefreshTokenStore(rdb, &cfg.JWT) + wsTickets := auth.NewWSTicketStore(rdb, time.Duration(cfg.JWT.WSTicketTTLSeconds)*time.Second) sessionStore := auth.NewSessionStore(&cfg.Session) // session management (ref: Chatwoot Devise sessions) webhookRegistry := auth.NewWebhookTokenRegistry() @@ -804,7 +820,7 @@ func Bootstrap(env string) (*App, error) { contactMergeService := service.NewContactMergeService(contactMergeRepo, db) handlers := &router.Handlers{ RBAC: service.NewRBACService(db), - Auth: v1.NewAuthHandler(authService, profileService), + Auth: v1.NewAuthHandler(authService, profileService).WithWSTicketStore(wsTickets).WithSecureCookies(cfg.Server.Mode == gin.ReleaseMode), Account: v1.NewAccountHandler(accountService), EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService), Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher), @@ -946,6 +962,9 @@ func Bootstrap(env string) (*App, error) { // (ref: Chatwoot Rails middleware stack in config/application.rb) gin.SetMode(cfg.Server.Mode) engine := gin.New() + if err := engine.SetTrustedProxies(cfg.Server.TrustedProxies); err != nil { + return nil, fmt.Errorf("trusted proxy configuration failed: %w", err) + } startedAt := time.Now() httpMetrics := basehandler.NewHTTPMetrics(startedAt) ready := &atomic.Bool{} @@ -956,7 +975,8 @@ func Bootstrap(env string) (*App, error) { corsMiddleware := middleware.CORS(middleware.CORSConfigFromAppConfig(cfg)) engine.Use(middleware.Recovery()) // panic recovery engine.Use(httpMetrics.Middleware()) - engine.Use(middleware.RequestLogger()) // structured request logging + engine.Use(middleware.RequestLogger()) // structured request logging + engine.Use(middleware.SecurityRateLimit(rdb, cfg.RateLimit)) engine.Use(middleware.RateLimit(rdb)) // rate limiting (ref: Chatwoot rack-attack) engine.Use(corsMiddleware) // CORS with configurable whitelist engine.Use(middleware.SecurityHeaders(middleware.DefaultSecurityHeadersConfig())) // security headers (ref: P14 deliverable #11) @@ -984,7 +1004,7 @@ func Bootstrap(env string) (*App, error) { // so handlers can reference it via the hubTypingAdapter. // Create WS authenticator for dual JWT + pubsub_token auth - wsAuthenticator := wspkg.NewWSAuthenticator(jwtService, contactInboxRepo, db) + wsAuthenticator := wspkg.NewWSAuthenticator(jwtService, contactInboxRepo, db, wsTickets) // Register all routes with handlers + WS hub + authenticator router.RegisterRoutes(engine, jwtService, refreshStore, webhookRegistry, handlers, wsHub, wsAuthenticator, &cfg.JWT, middleware.CORSConfigFromAppConfig(cfg), db) diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index 48bc4b0a..7329baaf 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -1,6 +1,8 @@ package auth import ( + "crypto/rand" + "encoding/hex" "errors" "fmt" "strings" @@ -65,7 +67,12 @@ func (s *JWTService) GenerateTokenPairForClient(user *model.User, accountID uint } // Access Token - accessExpiry := time.Now().Add(time.Duration(s.cfg.ExpiryHours) * time.Hour) + now := time.Now() + accessExpiry := now.Add(s.cfg.ExpiryDuration()) + issuer := s.cfg.Issuer + if issuer == "" { + issuer = "gochat" + } accessClaims := &Claims{ UserID: user.ID, AccountID: accountID, @@ -81,9 +88,10 @@ func (s *JWTService) GenerateTokenPairForClient(user *model.User, accountID uint ClientID: clientID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(accessExpiry), - IssuedAt: jwt.NewNumericDate(time.Now()), + IssuedAt: jwt.NewNumericDate(now), Subject: fmt.Sprintf("user_%d", user.ID), - Issuer: "gochat", + Issuer: issuer, + Audience: jwt.ClaimStrings{s.cfg.Audience}, }, } @@ -94,16 +102,22 @@ func (s *JWTService) GenerateTokenPairForClient(user *model.User, accountID uint } // Refresh Token - refreshExpiry := time.Now().Add(time.Duration(s.cfg.RefreshExpiryHours) * time.Hour) + refreshExpiry := now.Add(s.cfg.RefreshExpiryDuration()) + refreshID := make([]byte, 16) + if _, err := rand.Read(refreshID); err != nil { + return nil, fmt.Errorf("failed to generate refresh token id: %w", err) + } refreshClaims := &Claims{ UserID: user.ID, Provider: user.Provider, ClientID: clientID, RegisteredClaims: jwt.RegisteredClaims{ + ID: hex.EncodeToString(refreshID), ExpiresAt: jwt.NewNumericDate(refreshExpiry), - IssuedAt: jwt.NewNumericDate(time.Now()), + IssuedAt: jwt.NewNumericDate(now), Subject: fmt.Sprintf("refresh_%d", user.ID), - Issuer: "gochat", + Issuer: issuer, + Audience: jwt.ClaimStrings{s.cfg.Audience}, }, } @@ -137,11 +151,11 @@ func (s *JWTService) validateToken(tokenString, subjectPrefix, kind string) (*Cl secrets := append([]string{s.cfg.Secret}, s.cfg.PreviousSecrets...) var lastErr error - for _, secret := range secrets { + for index, secret := range secrets { claims := &Claims{} token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { return []byte(secret), nil - }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) + }, s.validationOptions(index == 0)...) if err != nil { lastErr = err continue @@ -158,6 +172,20 @@ func (s *JWTService) validateToken(tokenString, subjectPrefix, kind string) (*Cl return nil, fmt.Errorf("failed to parse %s token: %w", kind, lastErr) } +func (s *JWTService) validationOptions(validateClaims bool) []jwt.ParserOption { + options := []jwt.ParserOption{jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})} + if !validateClaims { + return options + } + if s.cfg.Issuer != "" { + options = append(options, jwt.WithIssuer(s.cfg.Issuer)) + } + if s.cfg.Audience != "" { + options = append(options, jwt.WithAudience(s.cfg.Audience)) + } + return options +} + // RefreshAccessToken generates a new access token from a valid refresh token. func (s *JWTService) RefreshAccessToken(refreshTokenString string, accountID uint, role string) (*TokenPair, error) { claims, err := s.ValidateRefreshToken(refreshTokenString) diff --git a/backend/internal/auth/oidc.go b/backend/internal/auth/oidc.go index f3e38626..12a772ce 100644 --- a/backend/internal/auth/oidc.go +++ b/backend/internal/auth/oidc.go @@ -197,7 +197,7 @@ func (s *OIDCService) GetAuthorizationURL(ctx context.Context, accountID uint, r oauth2.SetAuthURLParam("code_challenge_method", "S256"), ) - applogger.L().Infof("OIDC authorization URL generated (account=%d, state=%s)", accountID, state) + applogger.L().Infof("OIDC authorization URL generated (account=%d)", accountID) return authURL, state, nil } diff --git a/backend/internal/auth/refresh_store.go b/backend/internal/auth/refresh_store.go index df285230..e50653d1 100644 --- a/backend/internal/auth/refresh_store.go +++ b/backend/internal/auth/refresh_store.go @@ -50,7 +50,7 @@ func (s *RefreshTokenStore) Store(ctx context.Context, userID uint, refreshToken func (s *RefreshTokenStore) StoreForClient(ctx context.Context, userID uint, clientID, refreshToken string) error { key := s.key(userID, clientID) - ttl := time.Duration(s.cfg.RefreshExpiryHours) * time.Hour + ttl := s.cfg.RefreshExpiryDuration() if s.rdb == nil { s.mu.Lock() defer s.mu.Unlock() diff --git a/backend/internal/auth/security_hardening_test.go b/backend/internal/auth/security_hardening_test.go new file mode 100644 index 00000000..1e359488 --- /dev/null +++ b/backend/internal/auth/security_hardening_test.go @@ -0,0 +1,59 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "github.com/gochat/gochat/internal/config" + "github.com/gochat/gochat/internal/model" +) + +func TestJWTAccessTokenUsesConfiguredMinuteExpiry(t *testing.T) { + cfg := &config.JWTConfig{Secret: "test-secret", AccessExpiryMinutes: 15, RefreshExpiryHours: 168} + service := NewJWTService(cfg) + + pair, err := service.GenerateTokenPair(&model.User{Base: model.Base{ID: 7}, Provider: "email"}, 3, "agent") + require.NoError(t, err) + require.WithinDuration(t, time.Now().Add(15*time.Minute), pair.ExpiresAt, 2*time.Second) +} + +func TestRefreshRotationRejectsReusedToken(t *testing.T) { + cfg := &config.JWTConfig{Secret: "test-secret", AccessExpiryMinutes: 15, RefreshExpiryHours: 168} + store := NewRefreshTokenStore(nil, cfg) + ctx := context.Background() + + require.NoError(t, store.StoreForClient(ctx, 7, "browser", "old-token")) + require.NoError(t, store.RotateForClient(ctx, 7, "browser", "new-token")) + valid, err := store.ValidateForClient(ctx, 7, "browser", "old-token") + require.NoError(t, err) + require.False(t, valid) +} + +func TestWSTicketIsOneTimeAndExpires(t *testing.T) { + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + store := NewWSTicketStore(rdb, 5*time.Second) + ctx := context.Background() + want := WSTicketClaims{UserID: 7, AccountID: 3, Role: "agent", Provider: "email"} + + ticket, err := store.Issue(ctx, want) + require.NoError(t, err) + got, err := store.Consume(ctx, ticket) + require.NoError(t, err) + require.Equal(t, want, *got) + _, err = store.Consume(ctx, ticket) + require.ErrorIs(t, err, ErrInvalidWSTicket) + + expiring, err := store.Issue(ctx, want) + require.NoError(t, err) + mr.FastForward(6 * time.Second) + _, err = store.Consume(ctx, expiring) + require.True(t, errors.Is(err, ErrInvalidWSTicket)) +} diff --git a/backend/internal/auth/ws_ticket.go b/backend/internal/auth/ws_ticket.go new file mode 100644 index 00000000..e72e2283 --- /dev/null +++ b/backend/internal/auth/ws_ticket.go @@ -0,0 +1,103 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +var ErrInvalidWSTicket = errors.New("invalid or expired websocket ticket") + +type WSTicketClaims struct { + UserID uint `json:"user_id"` + AccountID uint `json:"account_id"` + Role string `json:"role"` + Provider string `json:"provider"` + ClientID string `json:"client_id,omitempty"` +} + +type wsTicketEntry struct { + claims WSTicketClaims + expiresAt time.Time +} + +// WSTicketStore exchanges a short-lived opaque ticket for user claims exactly once. +type WSTicketStore struct { + rdb *redis.Client + ttl time.Duration + mu sync.Mutex + mem map[string]wsTicketEntry +} + +func NewWSTicketStore(rdb *redis.Client, ttl time.Duration) *WSTicketStore { + if ttl <= 0 { + ttl = 30 * time.Second + } + return &WSTicketStore{rdb: rdb, ttl: ttl, mem: make(map[string]wsTicketEntry)} +} + +func (s *WSTicketStore) Issue(ctx context.Context, claims WSTicketClaims) (string, error) { + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + return "", fmt.Errorf("generate websocket ticket: %w", err) + } + ticket := base64.RawURLEncoding.EncodeToString(random) + key := wsTicketKey(ticket) + if s.rdb == nil { + s.mu.Lock() + s.mem[key] = wsTicketEntry{claims: claims, expiresAt: time.Now().Add(s.ttl)} + s.mu.Unlock() + return ticket, nil + } + payload, err := json.Marshal(claims) + if err != nil { + return "", err + } + if err := s.rdb.Set(ctx, key, payload, s.ttl).Err(); err != nil { + return "", fmt.Errorf("store websocket ticket: %w", err) + } + return ticket, nil +} + +func (s *WSTicketStore) Consume(ctx context.Context, ticket string) (*WSTicketClaims, error) { + if ticket == "" { + return nil, ErrInvalidWSTicket + } + key := wsTicketKey(ticket) + if s.rdb == nil { + s.mu.Lock() + entry, ok := s.mem[key] + delete(s.mem, key) + s.mu.Unlock() + if !ok || time.Now().After(entry.expiresAt) { + return nil, ErrInvalidWSTicket + } + return &entry.claims, nil + } + payload, err := s.rdb.GetDel(ctx, key).Bytes() + if errors.Is(err, redis.Nil) { + return nil, ErrInvalidWSTicket + } + if err != nil { + return nil, fmt.Errorf("consume websocket ticket: %w", err) + } + var claims WSTicketClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, ErrInvalidWSTicket + } + return &claims, nil +} + +func wsTicketKey(ticket string) string { + digest := sha256.Sum256([]byte(ticket)) + return "gochat:ws_ticket:" + hex.EncodeToString(digest[:]) +} diff --git a/backend/internal/channel/config.go b/backend/internal/channel/config.go index 2ffc5169..be19d38a 100644 --- a/backend/internal/channel/config.go +++ b/backend/internal/channel/config.go @@ -6,6 +6,7 @@ import ( "fmt" "regexp" "strconv" + "strings" applogger "github.com/gochat/gochat/pkg/logger" ) @@ -290,11 +291,11 @@ func MergeDefaults(ctx context.Context, provider ChannelProvider, config Channel // SanitizeConfig removes secret fields from the config for API response rendering. // Returns a new ChannelConfig without fields marked as Secret in the provider's schema. func SanitizeConfig(ctx context.Context, provider ChannelProvider, config ChannelConfig) ChannelConfig { - if provider == nil || config == nil { - if config == nil { - return ChannelConfig{} - } - return config + if config == nil { + return ChannelConfig{} + } + if provider == nil { + return filterKnownSecrets(config) } schema := provider.ConfigSchema() @@ -325,7 +326,7 @@ func filterKnownSecrets(config ChannelConfig) ChannelConfig { for k, v := range config { isSecret := false - lowerKey := k + lowerKey := strings.ToLower(k) for _, pattern := range secretPatterns { if lowerKey == pattern || containsSubstring(lowerKey, pattern) { isSecret = true @@ -335,12 +336,27 @@ func filterKnownSecrets(config ChannelConfig) ChannelConfig { if isSecret { result[k] = "***" } else { - result[k] = v + result[k] = sanitizeNestedValue(v) } } return result } +func sanitizeNestedValue(value interface{}) interface{} { + switch nested := value.(type) { + case map[string]interface{}: + return filterKnownSecrets(ChannelConfig(nested)) + case []interface{}: + result := make([]interface{}, len(nested)) + for i := range nested { + result[i] = sanitizeNestedValue(nested[i]) + } + return result + default: + return value + } +} + // containsSubstring checks if substr is contained in s (simple check). func containsSubstring(s string, substr string) bool { return len(s) >= len(substr) && s[len(s)-len(substr):] == substr || len(s) >= len(substr) && containsAnySubstring(s, substr) @@ -480,4 +496,4 @@ func GetConfigStringSlice(ctx context.Context, config ChannelConfig, key string) default: return nil, fmt.Errorf("config key \"%s\" is not an array (got %T)", key, val) } -} \ No newline at end of file +} diff --git a/backend/internal/channel/telegram/webhook_handler.go b/backend/internal/channel/telegram/webhook_handler.go index 47e01da2..bb8460e6 100644 --- a/backend/internal/channel/telegram/webhook_handler.go +++ b/backend/internal/channel/telegram/webhook_handler.go @@ -87,7 +87,6 @@ func (h *WebhookHandler) HandleWebhookRequest(w http.ResponseWriter, r *http.Req } applogger.L().Info("Telegram webhook received", - "bot_token_prefix", maskBotToken(botToken), "update_id", update.UpdateID, ) diff --git a/backend/internal/channel/tiktok/webhook_handler.go b/backend/internal/channel/tiktok/webhook_handler.go index 67d336f9..8bfb0344 100644 --- a/backend/internal/channel/tiktok/webhook_handler.go +++ b/backend/internal/channel/tiktok/webhook_handler.go @@ -68,10 +68,9 @@ func (h *WebhookHandler) HandleVerifyRequest(r *http.Request, expectedVerifyToke challenge := r.URL.Query().Get("challenge") if verifyToken != expectedVerifyToken { - return "", fmt.Errorf("tiktok webhook verify: token mismatch (expected=%s, got=%s)", - expectedVerifyToken, verifyToken) + return "", fmt.Errorf("tiktok webhook verify: token mismatch") } - applogger.L().Infof("TikTok webhook verify: challenge accepted, token=%s", verifyToken) + applogger.L().Info("TikTok webhook verify: challenge accepted") return challenge, nil -} \ No newline at end of file +} diff --git a/backend/internal/channel/whatsapp/webhook_handler.go b/backend/internal/channel/whatsapp/webhook_handler.go index f79f7573..39f80017 100644 --- a/backend/internal/channel/whatsapp/webhook_handler.go +++ b/backend/internal/channel/whatsapp/webhook_handler.go @@ -78,7 +78,6 @@ func (h *WebhookHandler) HandleVerification(c *gin.Context) { waChannel, err := h.lookupByVerifyToken(token) if err != nil { applogger.L().Warn("WhatsApp webhook verification: token lookup failed", - "token", token, "error", err, ) c.JSON(http.StatusForbidden, gin.H{"error": "Invalid verify token"}) @@ -290,9 +289,14 @@ func (h *WebhookHandler) lookupByVerifyToken(token string) (*channelmodel.Channe } var channel channelmodel.ChannelWhatsApp - if err := h.provider.repository.db. - Where("webhook_verify_token = ?", token). - First(&channel).Error; err != nil { + digest := sha256.Sum256([]byte(token)) + digestHex := hex.EncodeToString(digest[:]) + if err := h.provider.repository.db.Where("webhook_verify_token_digest = ?", digestHex).First(&channel).Error; err == nil { + return &channel, nil + } + // Plaintext fallback supports records written before the encryption migration; + // rotate_secrets backfills their digest and removes this path from normal use. + if err := h.provider.repository.db.Where("webhook_verify_token = ?", token).First(&channel).Error; err != nil { return nil, fmt.Errorf("no WhatsApp channel found with verify token: %w", err) } return &channel, nil diff --git a/backend/internal/channel/whatsapp/webhook_handler_test.go b/backend/internal/channel/whatsapp/webhook_handler_test.go index 01945733..57104739 100644 --- a/backend/internal/channel/whatsapp/webhook_handler_test.go +++ b/backend/internal/channel/whatsapp/webhook_handler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/hmac" "crypto/sha256" + "encoding/base64" "encoding/hex" "errors" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/gochat/gochat/internal/security" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -66,6 +68,41 @@ func TestWhatsAppWebhookLookupByVerifyToken(t *testing.T) { } } +func TestWhatsAppWebhookLookupUsesDigestForEncryptedToken(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:encrypted-whatsapp?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Inbox{}, &channelmodel.ChannelWhatsApp{}); err != nil { + t.Fatal(err) + } + key := base64.StdEncoding.EncodeToString([]byte("11111111111111111111111111111111")) + encryptor, err := security.NewEncryptor(security.EncryptionConfig{Enabled: true, AESKey: key, KeyVersion: 1}) + if err != nil { + t.Fatal(err) + } + if err := security.RegisterGORMEncryption(db, encryptor); err != nil { + t.Fatal(err) + } + inbox := model.Inbox{AccountID: 1, Name: "encrypted-wa", ChannelType: "whatsapp", ChannelID: 1, Enabled: true} + if err := db.Create(&inbox).Error; err != nil { + t.Fatal(err) + } + record := channelmodel.ChannelWhatsApp{AccountID: 1, InboxID: inbox.ID, PhoneNumber: "+15551234568", AccessToken: "access-token", WebhookVerifyToken: "verify-token"} + if err := db.Create(&record).Error; err != nil { + t.Fatal(err) + } + + handler := NewWebhookHandler(NewWhatsAppProvider(nil, NewRepository(db), nil)) + found, err := handler.lookupByVerifyToken("verify-token") + if err != nil { + t.Fatal(err) + } + if found.ID != record.ID || found.WebhookVerifyToken != "verify-token" { + t.Fatalf("unexpected encrypted lookup result: %#v", found) + } +} + func TestWhatsAppCloudSignatureUsesAppSecret(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"object":"whatsapp_business_account"}`) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 121bfd68..3af5f90f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -40,6 +40,8 @@ type Config struct { Session SessionConfig `mapstructure:"session"` Storage StorageConfig `mapstructure:"storage"` Copilot CopilotConfig `mapstructure:"copilot"` + RateLimit RateLimitConfig `mapstructure:"rate_limit"` + Encryption EncryptionConfig `mapstructure:"encryption"` } // CopilotConfig is an optional runtime-only fallback for local and Quickstart @@ -93,9 +95,30 @@ type ServerConfig struct { IdleTimeoutS int `mapstructure:"idle_timeout_seconds"` ShutdownTimeoutS int `mapstructure:"shutdown_timeout_seconds"` MaxHeaderBytes int `mapstructure:"max_header_bytes"` + TrustedProxies []string `mapstructure:"trusted_proxies"` CORS CORSConfig `mapstructure:"cors"` } +type RouteLimitConfig struct { + Requests int `mapstructure:"requests"` + WindowSeconds int `mapstructure:"window_seconds"` +} + +type RateLimitConfig struct { + Enabled bool `mapstructure:"enabled"` + Login RouteLimitConfig `mapstructure:"login"` + PasswordReset RouteLimitConfig `mapstructure:"password_reset"` + PublicUpload RouteLimitConfig `mapstructure:"public_upload"` + Webhook RouteLimitConfig `mapstructure:"webhook"` +} + +type EncryptionConfig struct { + Enabled bool `mapstructure:"enabled"` + CurrentKeyVersion int `mapstructure:"current_key_version"` + AESKey string `mapstructure:"aes_key"` + PreviousKeys map[string]string `mapstructure:"previous_keys"` +} + // CORSConfig holds CORS middleware configuration. // AllowedOrigins supports exact matches (e.g. "https://app.example.com") // and wildcard subdomains (e.g. "*.example.com"). @@ -152,12 +175,23 @@ type JWTConfig struct { AccessExpiryMinutes int `mapstructure:"access_expiry_minutes"` Audience string `mapstructure:"audience"` Issuer string `mapstructure:"issuer"` + WSTicketTTLSeconds int `mapstructure:"ws_ticket_ttl_seconds"` } func (j JWTConfig) ExpiryDuration() time.Duration { + if j.AccessExpiryMinutes != 0 { + return time.Duration(j.AccessExpiryMinutes) * time.Minute + } return time.Duration(j.ExpiryHours) * time.Hour } +func (j JWTConfig) RefreshExpiryDuration() time.Duration { + if j.RefreshExpiryHours == 0 { + return 7 * 24 * time.Hour + } + return time.Duration(j.RefreshExpiryHours) * time.Hour +} + type LogConfig struct { Level string `mapstructure:"level"` Format string `mapstructure:"format"` // json, text @@ -261,6 +295,21 @@ func Load() (*Config, error) { // Set defaults for OIDC (M13) viper.SetDefault("oidc.enabled", false) viper.SetDefault("oidc.default_scopes", []string{"openid", "profile", "email"}) + viper.SetDefault("jwt.access_expiry_minutes", 15) + viper.SetDefault("jwt.refresh_expiry_hours", 168) + viper.SetDefault("jwt.issuer", "gochat") + viper.SetDefault("jwt.audience", "gochat-api") + viper.SetDefault("jwt.ws_ticket_ttl_seconds", 30) + viper.SetDefault("rate_limit.login.requests", 10) + viper.SetDefault("rate_limit.login.window_seconds", 60) + viper.SetDefault("rate_limit.password_reset.requests", 5) + viper.SetDefault("rate_limit.password_reset.window_seconds", 300) + viper.SetDefault("rate_limit.public_upload.requests", 20) + viper.SetDefault("rate_limit.public_upload.window_seconds", 60) + viper.SetDefault("rate_limit.webhook.requests", 120) + viper.SetDefault("rate_limit.webhook.window_seconds", 60) + viper.SetDefault("encryption.enabled", false) + viper.SetDefault("encryption.current_key_version", 1) // Set defaults for push notifications viper.SetDefault("push.enabled", false) @@ -480,46 +529,62 @@ func LoadWithEnv(env string) (*Config, error) { // Bind specific env keys that viper can't auto-infer for nested structs // These are common overrides that users set via environment variables envBindings := map[string]string{ - "GOCHAT_SERVER_HOST": "server.host", - "GOCHAT_SERVER_PORT": "server.port", - "GOCHAT_SERVER_MODE": "server.mode", - "GOCHAT_SERVER_READ_HEADER_TIMEOUT_SECONDS": "server.read_header_timeout_seconds", - "GOCHAT_SERVER_READ_TIMEOUT_SECONDS": "server.read_timeout_seconds", - "GOCHAT_SERVER_WRITE_TIMEOUT_SECONDS": "server.write_timeout_seconds", - "GOCHAT_SERVER_IDLE_TIMEOUT_SECONDS": "server.idle_timeout_seconds", - "GOCHAT_SERVER_SHUTDOWN_TIMEOUT_SECONDS": "server.shutdown_timeout_seconds", - "GOCHAT_SERVER_MAX_HEADER_BYTES": "server.max_header_bytes", - "GOCHAT_SERVER_CORS_ALLOWED_ORIGINS": "server.cors.allowed_origins", - "GOCHAT_DATABASE_DSN": "database.dsn", - "GOCHAT_DATABASE_MAX_IDLE_CONNS": "database.max_idle_conns", - "GOCHAT_DATABASE_MAX_OPEN_CONNS": "database.max_open_conns", - "GOCHAT_DATABASE_CONN_MAX_LIFETIME": "database.conn_max_lifetime", - "GOCHAT_DATABASE_RUN_MIGRATIONS": "database.run_migrations", - "GOCHAT_DATABASE_MIGRATIONS_PATH": "database.migrations_path", - "GOCHAT_REDIS_DSN": "redis.dsn", - "GOCHAT_REDIS_POOL_SIZE": "redis.pool_size", - "GOCHAT_JWT_SECRET": "jwt.secret", - "JWT_SECRET": "jwt.secret", // Alias for compatibility (no prefix) - "GOCHAT_JWT_PREVIOUS_SECRETS": "jwt.previous_secrets", - "GOCHAT_JWT_ALLOW_INSECURE_HEADER_AUTH": "jwt.allow_insecure_header_auth", - "GOCHAT_JWT_EXPIRY_HOURS": "jwt.expiry_hours", - "GOCHAT_JWT_ACCESS_EXPIRY_MINUTES": "jwt.access_expiry_minutes", - "GOCHAT_JWT_REFRESH_EXPIRY_HOURS": "jwt.refresh_expiry_hours", - "GOCHAT_LOG_LEVEL": "log.level", - "GOCHAT_LOG_FORMAT": "log.format", - "GOCHAT_WORKER_CONCURRENCY": "worker.concurrency", - "GOCHAT_WORKER_REDIS_STREAM_PREFIX": "worker.redis_stream_prefix", - "GOCHAT_WORKER_REDIS_CONSUMER_GROUP": "worker.redis_consumer_group", - "GOCHAT_WORKER_REDIS_BLOCK_TIMEOUT_S": "worker.redis_block_timeout_s", - "GOCHAT_WORKER_REDIS_SWEEP_INTERVAL_S": "worker.redis_sweep_interval_s", - "GOCHAT_SEARCH_ENGINE": "search.engine", - "GOCHAT_SEARCH_HOST": "search.host", - "GOCHAT_SEARCH_API_KEY": "search.api_key", - "GOCHAT_SEARCH_INDEX_PREFIX": "search.index_prefix", - "GOCHAT_SEARCH_TIMEOUT_SECONDS": "search.timeout_seconds", - "GOCHAT_STORAGE_PROVIDER": "storage.provider", - "GOCHAT_STORAGE_LOCAL_PATH": "storage.local_path", - "GOCHAT_STORAGE_MAX_FILE_SIZE": "storage.max_file_size", + "GOCHAT_SERVER_HOST": "server.host", + "GOCHAT_SERVER_PORT": "server.port", + "GOCHAT_SERVER_MODE": "server.mode", + "GOCHAT_SERVER_READ_HEADER_TIMEOUT_SECONDS": "server.read_header_timeout_seconds", + "GOCHAT_SERVER_READ_TIMEOUT_SECONDS": "server.read_timeout_seconds", + "GOCHAT_SERVER_WRITE_TIMEOUT_SECONDS": "server.write_timeout_seconds", + "GOCHAT_SERVER_IDLE_TIMEOUT_SECONDS": "server.idle_timeout_seconds", + "GOCHAT_SERVER_SHUTDOWN_TIMEOUT_SECONDS": "server.shutdown_timeout_seconds", + "GOCHAT_SERVER_MAX_HEADER_BYTES": "server.max_header_bytes", + "GOCHAT_SERVER_TRUSTED_PROXIES": "server.trusted_proxies", + "GOCHAT_SERVER_CORS_ALLOWED_ORIGINS": "server.cors.allowed_origins", + "GOCHAT_DATABASE_DSN": "database.dsn", + "GOCHAT_DATABASE_MAX_IDLE_CONNS": "database.max_idle_conns", + "GOCHAT_DATABASE_MAX_OPEN_CONNS": "database.max_open_conns", + "GOCHAT_DATABASE_CONN_MAX_LIFETIME": "database.conn_max_lifetime", + "GOCHAT_DATABASE_RUN_MIGRATIONS": "database.run_migrations", + "GOCHAT_DATABASE_MIGRATIONS_PATH": "database.migrations_path", + "GOCHAT_REDIS_DSN": "redis.dsn", + "GOCHAT_REDIS_POOL_SIZE": "redis.pool_size", + "GOCHAT_JWT_SECRET": "jwt.secret", + "JWT_SECRET": "jwt.secret", // Alias for compatibility (no prefix) + "GOCHAT_JWT_PREVIOUS_SECRETS": "jwt.previous_secrets", + "GOCHAT_JWT_ALLOW_INSECURE_HEADER_AUTH": "jwt.allow_insecure_header_auth", + "GOCHAT_JWT_EXPIRY_HOURS": "jwt.expiry_hours", + "GOCHAT_JWT_ACCESS_EXPIRY_MINUTES": "jwt.access_expiry_minutes", + "GOCHAT_JWT_REFRESH_EXPIRY_HOURS": "jwt.refresh_expiry_hours", + "GOCHAT_JWT_WS_TICKET_TTL_SECONDS": "jwt.ws_ticket_ttl_seconds", + "GOCHAT_JWT_ISSUER": "jwt.issuer", + "GOCHAT_JWT_AUDIENCE": "jwt.audience", + "GOCHAT_ENCRYPTION_ENABLED": "encryption.enabled", + "GOCHAT_ENCRYPTION_CURRENT_KEY_VERSION": "encryption.current_key_version", + "GOCHAT_ENCRYPTION_AES_KEY": "encryption.aes_key", + "GOCHAT_RATE_LIMIT_ENABLED": "rate_limit.enabled", + "GOCHAT_RATE_LIMIT_LOGIN_REQUESTS": "rate_limit.login.requests", + "GOCHAT_RATE_LIMIT_LOGIN_WINDOW_SECONDS": "rate_limit.login.window_seconds", + "GOCHAT_RATE_LIMIT_PASSWORD_RESET_REQUESTS": "rate_limit.password_reset.requests", + "GOCHAT_RATE_LIMIT_PASSWORD_RESET_WINDOW_SECONDS": "rate_limit.password_reset.window_seconds", + "GOCHAT_RATE_LIMIT_PUBLIC_UPLOAD_REQUESTS": "rate_limit.public_upload.requests", + "GOCHAT_RATE_LIMIT_PUBLIC_UPLOAD_WINDOW_SECONDS": "rate_limit.public_upload.window_seconds", + "GOCHAT_RATE_LIMIT_WEBHOOK_REQUESTS": "rate_limit.webhook.requests", + "GOCHAT_RATE_LIMIT_WEBHOOK_WINDOW_SECONDS": "rate_limit.webhook.window_seconds", + "GOCHAT_LOG_LEVEL": "log.level", + "GOCHAT_LOG_FORMAT": "log.format", + "GOCHAT_WORKER_CONCURRENCY": "worker.concurrency", + "GOCHAT_WORKER_REDIS_STREAM_PREFIX": "worker.redis_stream_prefix", + "GOCHAT_WORKER_REDIS_CONSUMER_GROUP": "worker.redis_consumer_group", + "GOCHAT_WORKER_REDIS_BLOCK_TIMEOUT_S": "worker.redis_block_timeout_s", + "GOCHAT_WORKER_REDIS_SWEEP_INTERVAL_S": "worker.redis_sweep_interval_s", + "GOCHAT_SEARCH_ENGINE": "search.engine", + "GOCHAT_SEARCH_HOST": "search.host", + "GOCHAT_SEARCH_API_KEY": "search.api_key", + "GOCHAT_SEARCH_INDEX_PREFIX": "search.index_prefix", + "GOCHAT_SEARCH_TIMEOUT_SECONDS": "search.timeout_seconds", + "GOCHAT_STORAGE_PROVIDER": "storage.provider", + "GOCHAT_STORAGE_LOCAL_PATH": "storage.local_path", + "GOCHAT_STORAGE_MAX_FILE_SIZE": "storage.max_file_size", // G10: OAuth config for new channel integrations (Twitter, Microsoft, Google) "GOCHAT_OAUTH_TWITTER_CLIENT_ID": "oauth.twitter.client_id", "GOCHAT_OAUTH_TWITTER_CLIENT_SECRET": "oauth.twitter.client_secret", diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 3e3cd035..5921ee75 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -129,13 +129,14 @@ func TestValidate_ReleaseJWTSecurity(t *testing.T) { validSecret := "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE" base := func() *Config { return &Config{ - Server: ServerConfig{Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}}, - Database: DatabaseConfig{DSN: "postgres://user:database-secret@postgres:5432/db?sslmode=disable"}, - Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"}, - JWT: JWTConfig{Secret: validSecret}, - Log: LogConfig{Level: "info"}, - Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 1, SweepIntervalS: 1}, - Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700", APIKey: "search-secret-123"}, + Server: ServerConfig{Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.acme.test"}}}, + Database: DatabaseConfig{DSN: "postgres://user:database-secret@db.acme.test:5432/db?sslmode=verify-full"}, + Redis: RedisConfig{DSN: "rediss://:redis-secret@redis.acme.test:6379"}, + JWT: JWTConfig{Secret: validSecret, AccessExpiryMinutes: 15}, + Encryption: EncryptionConfig{Enabled: true, CurrentKeyVersion: 1, AESKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}, + Log: LogConfig{Level: "info"}, + Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 1, SweepIntervalS: 1}, + Search: SearchConfig{Engine: "meilisearch", Host: "https://search.acme.test", APIKey: "search-secret-123"}, } } @@ -260,13 +261,14 @@ func TestValidate_ReleaseRejectsPlaceholders(t *testing.T) { func TestValidate_ReleaseRejectsShortSearchKey(t *testing.T) { cfg := &Config{ - Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}}, - Database: DatabaseConfig{DSN: "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable"}, - Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"}, - JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters"}, - Log: LogConfig{Level: "info"}, - Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, - Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "123456789012345", TimeoutSeconds: 5}, + Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.acme.test"}}}, + Database: DatabaseConfig{DSN: "postgres://gochat:database-secret@db.acme.test:5432/gochat?sslmode=verify-full"}, + Redis: RedisConfig{DSN: "rediss://:redis-secret@redis.acme.test:6379"}, + JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters", AccessExpiryMinutes: 15}, + Encryption: EncryptionConfig{Enabled: true, CurrentKeyVersion: 1, AESKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}, + Log: LogConfig{Level: "info"}, + Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, + Search: SearchConfig{Engine: "meilisearch", Host: "https://search.acme.test", APIKey: "123456789012345", TimeoutSeconds: 5}, } assert.ErrorContains(t, Validate(cfg), "search API key must be at least 16 bytes") @@ -274,12 +276,13 @@ func TestValidate_ReleaseRejectsShortSearchKey(t *testing.T) { func TestValidate_ReleaseDatabaseTLS(t *testing.T) { cfg := &Config{ - Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}}, - Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"}, - JWT: JWTConfig{Secret: "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"}, - Log: LogConfig{Level: "info"}, - Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, - Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "search-secret-123", TimeoutSeconds: 5}, + Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.acme.test"}}}, + Redis: RedisConfig{DSN: "rediss://:redis-secret@redis.acme.test:6379"}, + JWT: JWTConfig{Secret: "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE", AccessExpiryMinutes: 15}, + Encryption: EncryptionConfig{Enabled: true, CurrentKeyVersion: 1, AESKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}, + Log: LogConfig{Level: "info"}, + Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, + Search: SearchConfig{Engine: "meilisearch", Host: "https://search.acme.test", APIKey: "search-secret-123", TimeoutSeconds: 5}, } for _, tt := range []struct { @@ -289,8 +292,10 @@ func TestValidate_ReleaseDatabaseTLS(t *testing.T) { }{ {"external disable", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=disable", true}, {"external missing sslmode", "postgres://gochat:database-secret@db.example.test:5432/gochat", true}, - {"external require", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require", false}, - {"built-in compose disable", "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable", false}, + {"external require", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require", true}, + {"external verify ca", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-ca", false}, + {"external verify full", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-full", false}, + {"built-in compose disable", "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable", true}, } { t.Run(tt.name, func(t *testing.T) { cfg.Database.DSN = tt.dsn @@ -314,17 +319,20 @@ func TestLoadWithEnv_ProductionRequiresOverlay(t *testing.T) { } func TestLoadWithEnv_ProductionOverlay(t *testing.T) { - t.Setenv("GOCHAT_DATABASE_DSN", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require") - t.Setenv("GOCHAT_REDIS_DSN", "redis://:redis-secret@redis:6379") + t.Setenv("GOCHAT_DATABASE_DSN", "postgres://gochat:database-secret@db.acme.test:5432/gochat?sslmode=verify-full") + t.Setenv("GOCHAT_REDIS_DSN", "rediss://:redis-secret@redis.acme.test:6379") t.Setenv("GOCHAT_JWT_SECRET", "production-jwt-secret-at-least-32-characters") t.Setenv("GOCHAT_SEARCH_API_KEY", "search-secret-123") - t.Setenv("GOCHAT_SERVER_CORS_ALLOWED_ORIGINS", "https://chat.example.test") + t.Setenv("GOCHAT_SERVER_CORS_ALLOWED_ORIGINS", "https://chat.acme.test") + t.Setenv("GOCHAT_ENCRYPTION_ENABLED", "true") + t.Setenv("GOCHAT_ENCRYPTION_CURRENT_KEY_VERSION", "1") + t.Setenv("GOCHAT_ENCRYPTION_AES_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") t.Chdir("../..") cfg, err := LoadWithEnv("production") require.NoError(t, err) assert.Equal(t, "release", cfg.Server.Mode) - assert.Equal(t, []string{"https://chat.example.test"}, cfg.Server.CORS.AllowedOrigins) + assert.Equal(t, []string{"https://chat.acme.test"}, cfg.Server.CORS.AllowedOrigins) assert.NoError(t, Validate(cfg)) } diff --git a/backend/internal/config/security_hardening_test.go b/backend/internal/config/security_hardening_test.go new file mode 100644 index 00000000..b53581e3 --- /dev/null +++ b/backend/internal/config/security_hardening_test.go @@ -0,0 +1,66 @@ +package config + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func validReleaseConfig() *Config { + return &Config{ + Server: ServerConfig{Port: 3000, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.acme.test"}, AllowCredentials: true}}, + Database: DatabaseConfig{DSN: "postgres://user:pass@db.acme.test/gochat?sslmode=verify-full"}, + Redis: RedisConfig{DSN: "rediss://:redis-secret@redis.acme.test:6379"}, + JWT: JWTConfig{Secret: "production-secret-at-least-32-bytes", AccessExpiryMinutes: 15, RefreshExpiryHours: 168}, + Encryption: EncryptionConfig{Enabled: true, CurrentKeyVersion: 1, AESKey: base64.StdEncoding.EncodeToString(make([]byte, 32))}, + Log: LogConfig{Level: "info"}, + Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, + Search: SearchConfig{Engine: "meilisearch", Host: "https://search.acme.test", APIKey: "search-secret-123", TimeoutSeconds: 5}, + } +} + +func TestReleaseTransportAndCORSValidationFailsClosed(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + message string + }{ + {"database certificate not verified", func(c *Config) { c.Database.DSN = "postgres://user:pass@db.acme.test/gochat?sslmode=require" }, "verify-full or verify-ca"}, + {"plain redis", func(c *Config) { c.Redis.DSN = "redis://:redis-secret@redis.acme.test:6379" }, "must use rediss"}, + {"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) { + cfg := validReleaseConfig() + tt.mutate(cfg) + err := Validate(cfg) + require.ErrorContains(t, err, tt.message) + }) + } +} + +func TestSecuritySettingsLoadFromEnvironment(t *testing.T) { + workingDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(filepath.Join(workingDir, "..", ".."))) + t.Cleanup(func() { _ = os.Chdir(workingDir) }) + t.Setenv("GOCHAT_SERVER_TRUSTED_PROXIES", "10.0.0.0/8,192.0.2.10") + t.Setenv("GOCHAT_SERVER_CORS_ALLOWED_ORIGINS", "https://chat.acme.test,https://admin.acme.test") + t.Setenv("GOCHAT_JWT_WS_TICKET_TTL_SECONDS", "20") + t.Setenv("GOCHAT_ENCRYPTION_ENABLED", "true") + t.Setenv("GOCHAT_RATE_LIMIT_LOGIN_REQUESTS", "7") + + cfg, err := LoadWithEnv("default") + require.NoError(t, err) + require.Equal(t, []string{"10.0.0.0/8", "192.0.2.10"}, cfg.Server.TrustedProxies) + require.Equal(t, []string{"https://chat.acme.test", "https://admin.acme.test"}, cfg.Server.CORS.AllowedOrigins) + require.Equal(t, 20, cfg.JWT.WSTicketTTLSeconds) + require.True(t, cfg.Encryption.Enabled) + require.Equal(t, 7, cfg.RateLimit.Login.Requests) +} diff --git a/backend/internal/config/validator.go b/backend/internal/config/validator.go index 6ac54b0a..5ee72941 100644 --- a/backend/internal/config/validator.go +++ b/backend/internal/config/validator.go @@ -1,7 +1,9 @@ package config import ( + "encoding/base64" "fmt" + "net" "net/url" "strconv" "strings" @@ -33,7 +35,6 @@ func Validate(cfg *Config) error { if dbURL.Host == "" { return fmt.Errorf("invalid database DSN: host is required") } - // Redis validation (ref: Chatwoot config/cable.yml requires Redis connection) if cfg.Redis.DSN == "" { return fmt.Errorf("redis DSN is required") @@ -48,6 +49,13 @@ func Validate(cfg *Config) error { if redisURL.Host == "" { return fmt.Errorf("invalid redis DSN: host is required") } + for _, proxy := range cfg.Server.TrustedProxies { + if net.ParseIP(proxy) == nil { + if _, _, err := net.ParseCIDR(proxy); err != nil { + return fmt.Errorf("invalid trusted proxy %q", proxy) + } + } + } // JWT validation if cfg.Server.Mode == "release" { @@ -68,7 +76,6 @@ func Validate(cfg *Config) error { seen[secret] = true } } - // Log validation validLogLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} if !validLogLevels[cfg.Log.Level] { @@ -112,9 +119,6 @@ func Validate(cfg *Config) error { } if cfg.Server.Mode == "release" { - if len(cfg.Server.CORS.AllowedOrigins) == 0 || containsPlaceholder(strings.Join(cfg.Server.CORS.AllowedOrigins, ",")) { - return fmt.Errorf("production CORS origins are required and must not contain placeholders") - } if dbURL.User == nil || dbURL.User.Username() == "" { return fmt.Errorf("production database credentials are required") } @@ -122,8 +126,8 @@ func Validate(cfg *Config) error { return fmt.Errorf("production database password is required and must not contain placeholders") } sslMode := dbURL.Query().Get("sslmode") - if !(dbURL.Hostname() == "postgres" && sslMode == "disable") && sslMode != "require" && sslMode != "verify-ca" && sslMode != "verify-full" { - return fmt.Errorf("production database DSN must use sslmode=require, verify-ca, or verify-full (sslmode=disable is only allowed for the built-in postgres service)") + if sslMode != "verify-full" && sslMode != "verify-ca" { + return fmt.Errorf("production database DSN must use sslmode=verify-full or verify-ca") } if redisURL.User == nil { return fmt.Errorf("production Redis credentials are required") @@ -131,11 +135,51 @@ func Validate(cfg *Config) error { if password, ok := redisURL.User.Password(); !ok || password == "" || containsPlaceholder(password) { return fmt.Errorf("production Redis password is required and must not contain placeholders") } + if redisURL.Scheme != "rediss" { + return fmt.Errorf("production Redis DSN must use rediss with certificate verification") + } + if 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") + } + if !cfg.Encryption.Enabled { + return fmt.Errorf("production sensitive-field encryption must be enabled") + } if len(cfg.Search.APIKey) < 16 || containsPlaceholder(cfg.Search.APIKey) { return fmt.Errorf("production search API key must be at least 16 bytes and must not contain placeholders") } } - + if cfg.Encryption.Enabled { + key, err := base64.StdEncoding.DecodeString(cfg.Encryption.AESKey) + if err != nil || len(key) != 32 || cfg.Encryption.CurrentKeyVersion < 1 { + return fmt.Errorf("encryption requires a base64-encoded 32-byte AES key and positive key version") + } + for version, encoded := range cfg.Encryption.PreviousKeys { + parsedVersion, err := strconv.Atoi(version) + if err != nil || parsedVersion < 1 || parsedVersion == cfg.Encryption.CurrentKeyVersion { + return fmt.Errorf("invalid previous encryption key version %q", version) + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || len(decoded) != 32 { + return fmt.Errorf("previous encryption key v%s must be base64-encoded 32 bytes", version) + } + } + } return nil } diff --git a/backend/internal/database/encrypted_secret_migration_test.go b/backend/internal/database/encrypted_secret_migration_test.go new file mode 100644 index 00000000..3dd4d144 --- /dev/null +++ b/backend/internal/database/encrypted_secret_migration_test.go @@ -0,0 +1,25 @@ +package database + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEncryptedSecretMigrationUpgradeDownUp(t *testing.T) { + db, dbURL := openUploadMigrationPostgres(t) + migrations := productionMigrationsPath(t) + require.NoError(t, MigrateSteps(dbURL, migrations, 83)) + require.False(t, db.Migrator().HasColumn("channel_whatsapps", "provider_config")) + + require.NoError(t, MigrateSteps(dbURL, migrations, 1)) + require.True(t, db.Migrator().HasColumn("channel_whatsapps", "provider_config")) + require.True(t, db.Migrator().HasColumn("channel_whatsapps", "webhook_verify_token_digest")) + + require.NoError(t, MigrateSteps(dbURL, migrations, -1)) + require.True(t, db.Migrator().HasColumn("channel_whatsapps", "provider_config")) + require.False(t, db.Migrator().HasColumn("channel_whatsapps", "webhook_verify_token_digest")) + + require.NoError(t, MigrateSteps(dbURL, migrations, 1)) + require.True(t, db.Migrator().HasColumn("channel_whatsapps", "webhook_verify_token_digest")) +} diff --git a/backend/internal/database/upload_migration_test.go b/backend/internal/database/upload_migration_test.go index eb9575ca..a28594c5 100644 --- a/backend/internal/database/upload_migration_test.go +++ b/backend/internal/database/upload_migration_test.go @@ -72,7 +72,7 @@ func TestUploadPostgresProductionMigrationsFromEmptySchema(t *testing.T) { require.NoError(t, RunMigrations(dbURL, productionMigrationsPath(t))) version, dirty, err := CurrentVersion(dbURL, productionMigrationsPath(t)) require.NoError(t, err) - assert.Equal(t, uint(83), version) + assert.Equal(t, uint(84), version) assert.False(t, dirty) exerciseUploadProductionSchema(t, db) } diff --git a/backend/internal/handler/api/v1/agent_bot_handler.go b/backend/internal/handler/api/v1/agent_bot_handler.go index 4d662702..7b5807b7 100644 --- a/backend/internal/handler/api/v1/agent_bot_handler.go +++ b/backend/internal/handler/api/v1/agent_bot_handler.go @@ -95,7 +95,7 @@ func (h *AgentBotHandler) Create(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID)) + c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID, true)) } // Update modifies an existing agent bot. @@ -173,7 +173,7 @@ func (h *AgentBotHandler) ResetToken(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID)) + c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID, true)) } // ResetSecret generates a new webhook signing secret for the bot. @@ -198,7 +198,7 @@ func (h *AgentBotHandler) ResetSecret(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID)) + c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID, true)) } // DeleteAvatar removes the bot's avatar URL. @@ -226,7 +226,7 @@ func (h *AgentBotHandler) DeleteAvatar(c *gin.Context) { c.JSON(http.StatusOK, serializeAccountAgentBot(bot, accountID)) } -func serializeAccountAgentBot(bot *model.AgentBot, accountID uint) gin.H { +func serializeAccountAgentBot(bot *model.AgentBot, accountID uint, reveal ...bool) gin.H { if bot == nil { return gin.H{} } @@ -244,10 +244,11 @@ func serializeAccountAgentBot(bot *model.AgentBot, accountID uint) gin.H { if !systemBot { payload["outgoing_url"] = bot.OutgoingURL } - if bot.AccountID != nil && *bot.AccountID == accountID && bot.AccessToken != "" { + showSecrets := len(reveal) > 0 && reveal[0] + if showSecrets && bot.AccountID != nil && *bot.AccountID == accountID && bot.AccessToken != "" { payload["access_token"] = bot.AccessToken } - if bot.AccountID != nil && *bot.AccountID == accountID && bot.Secret != "" { + if showSecrets && bot.AccountID != nil && *bot.AccountID == accountID && bot.Secret != "" { payload["secret"] = bot.Secret } return payload diff --git a/backend/internal/handler/api/v1/auth_handler.go b/backend/internal/handler/api/v1/auth_handler.go index 9bdea2f0..d3577d2c 100644 --- a/backend/internal/handler/api/v1/auth_handler.go +++ b/backend/internal/handler/api/v1/auth_handler.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" @@ -30,6 +31,23 @@ import ( type AuthHandler struct { authService *service.AuthService profileService *service.ProfileService + wsTickets *auth.WSTicketStore + secureCookies bool +} + +const ( + browserRefreshCookie = "_gochat_refresh" + browserSessionMarker = "cw_d_session_state" +) + +func (h *AuthHandler) WithWSTicketStore(store *auth.WSTicketStore) *AuthHandler { + h.wsTickets = store + return h +} + +func (h *AuthHandler) WithSecureCookies(secure bool) *AuthHandler { + h.secureCookies = secure + return h } // NewAuthHandler creates an auth handler with service dependencies. @@ -96,6 +114,10 @@ func (h *AuthHandler) Login(c *gin.Context) { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, err.Error()) return } + if err := h.trackChatwootSession(c, output); err != nil { + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session") + return + } response.OK(c, gin.H{ "user": output.User, @@ -130,6 +152,7 @@ func (h *AuthHandler) ChatwootSignIn(c *gin.Context) { return } + h.setBrowserSession(c, output) h.setChatwootAuthHeaders(c, output) profile, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID) if err != nil { @@ -143,18 +166,36 @@ func (h *AuthHandler) ChatwootSignIn(c *gin.Context) { // GET /auth/validate_token func (h *AuthHandler) ChatwootValidateToken(c *gin.Context) { accessToken := extractChatwootAccessToken(c) - if accessToken == "" { - response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "access token required") - return + var output *service.LoginOutput + var err error + if accessToken != "" { + output, err = h.authService.ValidateAccessToken(c.Request.Context(), accessToken) } - - output, err := h.authService.ValidateAccessToken(c.Request.Context(), accessToken) if err != nil { - response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid token") - return + accessToken = "" + } + if accessToken == "" { + refreshToken, cookieErr := c.Cookie(browserRefreshCookie) + if cookieErr != nil { + h.clearBrowserSession(c) + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid session") + return + } + refreshed, refreshErr := h.authService.Refresh(c.Request.Context(), &service.RefreshInput{RefreshToken: refreshToken}) + if refreshErr != nil { + h.clearBrowserSession(c) + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid session") + return + } + output = &service.LoginOutput{ + User: refreshed.User, TokenPair: refreshed.TokenPair, AccountID: refreshed.AccountID, + Role: refreshed.Role, ClientID: refreshed.ClientID, + } + accessToken = refreshed.TokenPair.AccessToken + h.setBrowserSession(c, output) } - h.setChatwootAuthHeaders(c, &service.LoginOutput{User: output.User, AccountID: output.AccountID}) + h.setChatwootAuthHeaders(c, output) c.Header("access-token", accessToken) profile, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID) if err != nil { @@ -172,18 +213,29 @@ func (h *AuthHandler) ChatwootValidateToken(c *gin.Context) { // ChatwootSignOut revokes the current session for the DeviseTokenAuth route. // DELETE /auth/sign_out func (h *AuthHandler) ChatwootSignOut(c *gin.Context) { + h.clearBrowserSession(c) accessToken := extractChatwootAccessToken(c) - if accessToken == "" { - response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "access token required") + authenticated := false + var revokeErr error + if accessToken != "" { + if output, validateErr := h.authService.ValidateAccessToken(c.Request.Context(), accessToken); validateErr == nil { + authenticated = true + revokeErr = h.authService.RevokeChatwootSession(c.Request.Context(), output.User.ID, output.ClientID) + } + } + if refreshToken, cookieErr := c.Cookie(browserRefreshCookie); cookieErr == nil { + if err := h.authService.RevokeBrowserSession(c.Request.Context(), refreshToken); err == nil { + authenticated = true + } else if !authenticated { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid session") + return + } + } + if !authenticated { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "authentication required") return } - - output, err := h.authService.ValidateAccessToken(c.Request.Context(), accessToken) - if err != nil { - response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid token") - return - } - if err := h.authService.RevokeChatwootSession(c.Request.Context(), output.User.ID, c.GetHeader("client")); err != nil { + if revokeErr != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Logout failed") return } @@ -220,13 +272,18 @@ func (h *AuthHandler) Refresh(c *gin.Context) { // DELETE /api/v1/auth/logout // Requires authentication — uses user_id from JWT context. func (h *AuthHandler) Logout(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { + token := extractChatwootAccessToken(c) + if token == "" || h.authService == nil { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") + return + } + output, err := h.authService.ValidateAccessToken(c.Request.Context(), token) + if err != nil { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") return } - if err := h.authService.Logout(c.Request.Context(), userID); err != nil { + if err := h.authService.RevokeChatwootSession(c.Request.Context(), output.User.ID, output.ClientID); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Logout failed") return } @@ -234,12 +291,38 @@ func (h *AuthHandler) Logout(c *gin.Context) { response.NoContent(c) } +// IssueWSTicket returns a short-lived, one-time credential for WebSocket upgrade. +func (h *AuthHandler) IssueWSTicket(c *gin.Context) { + if h.wsTickets == nil || h.authService == nil { + response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrInternal, "websocket authentication unavailable") + return + } + token := extractChatwootAccessToken(c) + if token == "" { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid token") + return + } + output, err := h.authService.ValidateAccessToken(c.Request.Context(), token) + if err != nil { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid token") + return + } + ticket, err := h.wsTickets.Issue(c.Request.Context(), auth.WSTicketClaims{ + UserID: output.User.ID, AccountID: output.AccountID, Role: output.Role, Provider: output.User.Provider, ClientID: output.ClientID, + }) + if err != nil { + response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrInternal, "websocket authentication unavailable") + return + } + response.OK(c, gin.H{"ticket": ticket}) +} + // SwitchAccount generates new tokens with a different account scope. // POST /api/v1/auth/switch_account // Requires authentication — uses user_id from JWT context. func (h *AuthHandler) SwitchAccount(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { + token := extractChatwootAccessToken(c) + if _, hasLegacyUserContext := c.Get("user_id"); token == "" && !hasLegacyUserContext { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") return } @@ -250,9 +333,20 @@ func (h *AuthHandler) SwitchAccount(c *gin.Context) { return } + if token == "" || h.authService == nil { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") + return + } + login, err := h.authService.ValidateAccessToken(c.Request.Context(), token) + if err != nil { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") + return + } + output, err := h.authService.SwitchAccount(c.Request.Context(), &service.SwitchAccountInput{ - UserID: userID, + UserID: login.User.ID, AccountID: req.AccountID, + ClientID: login.ClientID, }) if err != nil { response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, err.Error()) @@ -310,6 +404,7 @@ func (h *AuthHandler) ConfirmResetPassword(c *gin.Context) { return } + h.setBrowserSession(c, output) h.setChatwootAuthHeaders(c, output) data, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID) if err != nil { @@ -361,6 +456,7 @@ func (h *AuthHandler) ChatwootConfirmEmail(c *gin.Context) { return } + h.setBrowserSession(c, output) h.setChatwootAuthHeaders(c, output) data, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID) if err != nil { @@ -378,6 +474,7 @@ func RegisterAuthRoutes(rg *gin.RouterGroup, handler *AuthHandler) { // Core auth endpoints authGroup.POST("/login", handler.Login) authGroup.POST("/refresh", handler.Refresh) + authGroup.POST("/ws_ticket", handler.IssueWSTicket) authGroup.DELETE("/logout", handler.Logout) // Account management @@ -429,6 +526,23 @@ func (h *AuthHandler) trackChatwootSession(c *gin.Context, output *service.Login return h.authService.TrackChatwootSession(c.Request.Context(), output, c.GetHeader("client"), c.ClientIP(), c.GetHeader("User-Agent")) } +func (h *AuthHandler) setBrowserSession(c *gin.Context, output *service.LoginOutput) { + if output == nil || output.TokenPair == nil || output.TokenPair.RefreshToken == "" { + return + } + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(browserRefreshCookie, output.TokenPair.RefreshToken, 0, "/", "", h.secureCookies, true) + c.SetCookie(browserSessionMarker, "1", 0, "/", "", h.secureCookies, false) + c.Header("Cache-Control", "no-store") +} + +func (h *AuthHandler) clearBrowserSession(c *gin.Context) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(browserRefreshCookie, "", -1, "/", "", h.secureCookies, true) + c.SetCookie(browserSessionMarker, "", -1, "/", "", h.secureCookies, false) + c.Header("Cache-Control", "no-store") +} + func extractChatwootAccessToken(c *gin.Context) string { if token := strings.TrimSpace(c.GetHeader("access-token")); token != "" { return token diff --git a/backend/internal/handler/api/v1/auth_handler_test.go b/backend/internal/handler/api/v1/auth_handler_test.go index 3e2ef26c..ccb67460 100644 --- a/backend/internal/handler/api/v1/auth_handler_test.go +++ b/backend/internal/handler/api/v1/auth_handler_test.go @@ -58,7 +58,7 @@ func setupChatwootAuthTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.User) { refreshStore := auth.NewRefreshTokenStore(nil, jwtCfg) authSvc := service.NewAuthService(db, jwtSvc, refreshStore) profileSvc := service.NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db), repository.NewAccessTokenRepo(db)) - handler := NewAuthHandler(authSvc, profileSvc) + handler := NewAuthHandler(authSvc, profileSvc).WithSecureCookies(true) router := gin.New() RegisterChatwootAuthRoutes(router.Group("/auth"), handler) @@ -151,6 +151,62 @@ func TestChatwootAuthValidateTokenReturnsPayloadData(t *testing.T) { assertChatwootAuthUserFixture(t, data) } +func TestChatwootBrowserSessionReloadAndLogout(t *testing.T) { + router, db, user := setupChatwootAuthTest(t) + body, _ := json.Marshal(map[string]string{"email": "auth@example.com", "password": "password123"}) + signIn := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/auth/sign_in", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(signIn, req) + require.Equal(t, http.StatusOK, signIn.Code, signIn.Body.String()) + + refreshCookie := responseCookie(t, signIn, browserRefreshCookie) + markerCookie := responseCookie(t, signIn, browserSessionMarker) + require.True(t, refreshCookie.HttpOnly) + require.True(t, refreshCookie.Secure) + require.Equal(t, http.SameSiteLaxMode, refreshCookie.SameSite) + require.False(t, markerCookie.HttpOnly) + require.NotContains(t, signIn.Header().Get("Set-Cookie"), "cw_d_session_info") + + reload := httptest.NewRecorder() + reloadReq := httptest.NewRequest(http.MethodGet, "/auth/validate_token", nil) + reloadReq.AddCookie(refreshCookie) + reloadReq.AddCookie(markerCookie) + router.ServeHTTP(reload, reloadReq) + require.Equal(t, http.StatusOK, reload.Code, reload.Body.String()) + require.NotEmpty(t, reload.Header().Get("access-token")) + rotatedRefresh := responseCookie(t, reload, browserRefreshCookie) + require.NotEqual(t, refreshCookie.Value, rotatedRefresh.Value) + + logout := httptest.NewRecorder() + logoutReq := httptest.NewRequest(http.MethodDelete, "/auth/sign_out", nil) + logoutReq.Header.Set("access-token", reload.Header().Get("access-token")) + logoutReq.AddCookie(rotatedRefresh) + router.ServeHTTP(logout, logoutReq) + require.Equal(t, http.StatusOK, logout.Code, logout.Body.String()) + require.Less(t, responseCookie(t, logout, browserRefreshCookie).MaxAge, 0) + + var sessions int64 + require.NoError(t, db.Model(&model.UserSession{}).Where("user_id = ?", user.ID).Count(&sessions).Error) + require.Zero(t, sessions) + replay := httptest.NewRecorder() + replayReq := httptest.NewRequest(http.MethodGet, "/auth/validate_token", nil) + replayReq.AddCookie(rotatedRefresh) + router.ServeHTTP(replay, replayReq) + require.Equal(t, http.StatusUnauthorized, replay.Code) +} + +func responseCookie(t *testing.T, recorder *httptest.ResponseRecorder, name string) *http.Cookie { + t.Helper() + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name == name { + return cookie + } + } + t.Fatalf("missing response cookie %s", name) + return nil +} + func TestChatwootAuthValidateTokenSerializesPlatformAdminType(t *testing.T) { router, db, user := setupChatwootAuthTest(t) require.NoError(t, db.Model(user).Updates(map[string]any{ diff --git a/backend/internal/handler/api/v1/inbox_handler_parity_test.go b/backend/internal/handler/api/v1/inbox_handler_parity_test.go index 6783dc73..9555e956 100644 --- a/backend/internal/handler/api/v1/inbox_handler_parity_test.go +++ b/backend/internal/handler/api/v1/inbox_handler_parity_test.go @@ -360,6 +360,12 @@ func TestInboxHandler_SensitiveFieldsRequireAdministratorRole(t *testing.T) { require.Equal(t, http.StatusOK, adminEmailShow.Code, adminEmailShow.Body.String()) adminEmailData := inboxParityObject(t, adminEmailShow) require.Equal(t, true, adminEmailData["reauthorization_required"]) + require.Equal(t, "***", adminEmailData["imap_password"]) + require.Equal(t, true, adminEmailData["imap_password_configured"]) + require.Equal(t, "***", adminEmailData["smtp_password"]) + require.Equal(t, true, adminEmailData["smtp_password_configured"]) + require.NotContains(t, adminEmailShow.Body.String(), "imap-secret") + require.NotContains(t, adminEmailShow.Body.String(), "smtp-secret") agentWhatsappShow := inboxParityRequestWithRole(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, whatsappInbox.ID), nil, "agent") require.Equal(t, http.StatusOK, agentWhatsappShow.Code, agentWhatsappShow.Body.String()) @@ -619,8 +625,12 @@ func TestInboxHandler_ChatwootChannelSpecificConfigDepth(t *testing.T) { "provider": "whatsapp_cloud", "provider_config": map[string]any{ "api_key": "wa-key", + "app_secret": "whatsapp-app-value", + "app_secret_key": "whatsapp-app-key-value", + "client_secret": "whatsapp-client-value", "phone_number_id": "phone-id", "business_account_id": "waba-id", + "verification_pin": 123456, }, }, }) @@ -629,9 +639,22 @@ func TestInboxHandler_ChatwootChannelSpecificConfigDepth(t *testing.T) { require.Equal(t, "Channel::Whatsapp", whatsappData["channel_type"]) require.Equal(t, "+1555010000", whatsappData["phone_number"]) providerConfig := whatsappData["provider_config"].(map[string]any) - require.Equal(t, "wa-key", providerConfig["api_key"]) + require.Equal(t, "***", providerConfig["api_key"]) + require.Equal(t, true, providerConfig["api_key_configured"]) require.Equal(t, "phone-id", providerConfig["phone_number_id"]) - require.NotEmpty(t, providerConfig["webhook_verify_token"]) + require.Equal(t, "***", providerConfig["webhook_verify_token"]) + require.Equal(t, true, providerConfig["webhook_verify_token_configured"]) + require.Equal(t, "***", providerConfig["verification_pin"]) + require.NotContains(t, whatsappCreate.Body.String(), "wa-key") + for key, secret := range map[string]string{ + "app_secret": "whatsapp-app-value", + "app_secret_key": "whatsapp-app-key-value", + "client_secret": "whatsapp-client-value", + } { + require.Equal(t, "***", providerConfig[key]) + require.Equal(t, true, providerConfig[key+"_configured"]) + require.NotContains(t, whatsappCreate.Body.String(), secret) + } lineCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{ "name": "LINE", diff --git a/backend/internal/handler/api/v1/inbox_serializer.go b/backend/internal/handler/api/v1/inbox_serializer.go index 8c4b4359..8839b2ec 100644 --- a/backend/internal/handler/api/v1/inbox_serializer.go +++ b/backend/internal/handler/api/v1/inbox_serializer.go @@ -129,14 +129,16 @@ func serializeInbox(inbox *model.Inbox, db *gorm.DB, isAdmin bool) map[string]an } if isAdmin { payload["imap_login"] = configValue(config, "imap_login") - payload["imap_password"] = configValue(config, "imap_password") + payload["imap_password"] = maskedSecret(configValue(config, "imap_password")) + payload["imap_password_configured"] = configStringPresent(config, "imap_password") payload["imap_address"] = configValue(config, "imap_address") payload["imap_port"] = configValue(config, "imap_port") payload["imap_enabled"] = configValue(config, "imap_enabled") payload["imap_enable_ssl"] = configValue(config, "imap_enable_ssl") payload["imap_authentication"] = configValue(config, "imap_authentication") payload["smtp_login"] = configValue(config, "smtp_login") - payload["smtp_password"] = configValue(config, "smtp_password") + payload["smtp_password"] = maskedSecret(configValue(config, "smtp_password")) + payload["smtp_password_configured"] = configStringPresent(config, "smtp_password") payload["smtp_address"] = configValue(config, "smtp_address") payload["smtp_port"] = configValue(config, "smtp_port") payload["smtp_enabled"] = configValue(config, "smtp_enabled") @@ -153,7 +155,7 @@ func serializeInbox(inbox *model.Inbox, db *gorm.DB, isAdmin bool) map[string]an payload["phone_number"] = configValue(config, "phone_number") payload["message_templates"] = configValue(config, "message_templates") if isAdmin { - payload["provider_config"] = configValue(config, "provider_config") + payload["provider_config"] = maskedProviderConfig(configValue(config, "provider_config")) } payload["reauthorization_required"] = configValue(config, "reauthorization_required") payload["voice_enabled"] = configValue(config, "voice_enabled") @@ -180,6 +182,41 @@ func serializeInbox(inbox *model.Inbox, db *gorm.DB, isAdmin bool) map[string]an return payload } +func maskedSecret(value any) any { + if !secretConfigured(value) { + return value + } + return "***" +} + +func secretConfigured(value any) bool { + if value == nil { + return false + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) != "" + } + return true +} + +func maskedProviderConfig(value any) any { + config, ok := value.(map[string]any) + if !ok { + return value + } + masked := make(map[string]any, len(config)) + for key, item := range config { + switch strings.ToLower(key) { + case "api_key", "api_secret", "access_token", "refresh_token", "webhook_verify_token", "verification_pin", "app_secret", "app_secret_key", "client_secret": + masked[key] = maskedSecret(item) + masked[key+"_configured"] = secretConfigured(item) + default: + masked[key] = item + } + } + return masked +} + func serializeInboxWorkingHours(inbox *model.Inbox, config map[string]any) any { if len(inbox.WorkingHours) == 0 { return configArray(config, "working_hours") diff --git a/backend/internal/handler/api/v1/integration_hook_handler.go b/backend/internal/handler/api/v1/integration_hook_handler.go index 109d5493..e19f4518 100644 --- a/backend/internal/handler/api/v1/integration_hook_handler.go +++ b/backend/internal/handler/api/v1/integration_hook_handler.go @@ -1,12 +1,14 @@ package v1 import ( + "context" "encoding/json" "net/http" "strings" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/pagination" @@ -216,6 +218,7 @@ func serializeIntegrationHook(hook model.IntegrationHook) gin.H { if len(hook.Settings) > 0 { _ = json.Unmarshal(hook.Settings, &settings) } + settings = gin.H(channel.SanitizeConfig(context.Background(), nil, channel.ChannelConfig(settings))) payload := gin.H{ "id": hook.ID, "app_id": integrationHookAppID(hook), diff --git a/backend/internal/handler/api/v1/security_hardening_test.go b/backend/internal/handler/api/v1/security_hardening_test.go new file mode 100644 index 00000000..35a9e441 --- /dev/null +++ b/backend/internal/handler/api/v1/security_hardening_test.go @@ -0,0 +1,31 @@ +package v1 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + + "github.com/gochat/gochat/internal/model" +) + +func TestSensitiveSerializersMaskStoredCredentials(t *testing.T) { + accountID := uint(3) + bot := &model.AgentBot{AccountID: &accountID, AccessToken: "bot-token", Secret: "bot-secret"} + maskedBot := serializeAccountAgentBot(bot, accountID) + require.NotContains(t, maskedBot, "access_token") + require.NotContains(t, maskedBot, "secret") + require.Equal(t, "bot-token", serializeAccountAgentBot(bot, accountID, true)["access_token"]) + + webhook := model.WebhookSubscription{Secret: "webhook-secret", Events: json.RawMessage(`[]`)} + require.Equal(t, "***", serializeWebhookSubscription(webhook)["secret"]) + require.Equal(t, "webhook-secret", serializeWebhookSubscription(webhook, true)["secret"]) + + hook := model.IntegrationHook{Settings: datatypes.JSON(`{"shop_domain":"shop.test","access_token":"hook-secret","nested":{"api_key":"nested-secret"}}`)} + encoded, err := json.Marshal(serializeIntegrationHook(hook)) + require.NoError(t, err) + require.NotContains(t, string(encoded), "hook-secret") + require.NotContains(t, string(encoded), "nested-secret") + require.Contains(t, string(encoded), `"access_token":"***"`) +} diff --git a/backend/internal/handler/api/v1/webhook_subscription_handler.go b/backend/internal/handler/api/v1/webhook_subscription_handler.go index da72adf4..48b9a98b 100644 --- a/backend/internal/handler/api/v1/webhook_subscription_handler.go +++ b/backend/internal/handler/api/v1/webhook_subscription_handler.go @@ -86,7 +86,7 @@ func (h *WebhookSubscriptionHandler) Create(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"payload": gin.H{"webhook": serializeWebhookSubscription(*subscription)}}) + c.JSON(http.StatusOK, gin.H{"payload": gin.H{"webhook": serializeWebhookSubscription(*subscription, true)}}) } // Update modifies a webhook subscription. @@ -178,7 +178,7 @@ func serializeWebhookSubscriptions(subscriptions []model.WebhookSubscription) [] return items } -func serializeWebhookSubscription(subscription model.WebhookSubscription) gin.H { +func serializeWebhookSubscription(subscription model.WebhookSubscription, reveal ...bool) gin.H { var subscriptions []string _ = json.Unmarshal(subscription.Events, &subscriptions) payload := gin.H{ @@ -187,7 +187,10 @@ func serializeWebhookSubscription(subscription model.WebhookSubscription) gin.H "url": subscription.URL, "account_id": subscription.AccountID, "subscriptions": subscriptions, - "secret": subscription.Secret, + "secret": "***", + } + if len(reveal) > 0 && reveal[0] { + payload["secret"] = subscription.Secret } if subscription.InboxID != nil && *subscription.InboxID != 0 { inbox := gin.H{"id": *subscription.InboxID} diff --git a/backend/internal/handler/webhook/telegram_webhook.go b/backend/internal/handler/webhook/telegram_webhook.go index ab2c8f9f..6e271d43 100644 --- a/backend/internal/handler/webhook/telegram_webhook.go +++ b/backend/internal/handler/webhook/telegram_webhook.go @@ -101,7 +101,7 @@ func (h *TelegramWebhookHandler) HandleTelegramWebhook(c *gin.Context) { } defer c.Request.Body.Close() - applogger.L().Infof("Telegram webhook received for bot_token prefix: %s", maskBotToken(botToken)) + applogger.L().Info("Telegram webhook received") // Parse the update to extract update_id for logging var update telegramchannel.TelegramUpdate diff --git a/backend/internal/handler/widget/widget_theme_handler.go b/backend/internal/handler/widget/widget_theme_handler.go index 1d14f509..f3814a51 100644 --- a/backend/internal/handler/widget/widget_theme_handler.go +++ b/backend/internal/handler/widget/widget_theme_handler.go @@ -140,7 +140,7 @@ func (h *WidgetHandler) StageFileUpload(c *gin.Context) { resp, err := h.widgetService.StageFileUpload(c.Request.Context(), req, fileReader) if err != nil { - applogger.L().Errorf("Failed to stage file upload for website_token=%s: %v", websiteToken, err) + applogger.L().Errorf("Failed to stage widget file upload: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go index ab2622df..71903103 100644 --- a/backend/internal/middleware/cors.go +++ b/backend/internal/middleware/cors.go @@ -58,7 +58,7 @@ func CORS(cfg CORSConfig) gin.HandlerFunc { if cfg.DevMode && len(cfg.AllowedOrigins) == 0 { c.Header("Access-Control-Allow-Origin", "*") - } else if origin != "" && isOriginAllowed(origin, cfg.AllowedOrigins) { + } else if origin != "" && isOriginAllowed(origin, cfg.AllowedOrigins) && productionOriginAllowed(origin, cfg) { c.Header("Access-Control-Allow-Origin", origin) c.Header("Vary", "Origin") if cfg.AllowCredentials { @@ -80,6 +80,24 @@ func CORS(cfg CORSConfig) gin.HandlerFunc { } } +func productionOriginAllowed(origin string, cfg CORSConfig) bool { + if cfg.DevMode { + return true + } + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return false + } + if cfg.AllowCredentials { + for _, allowed := range cfg.AllowedOrigins { + if allowed == "*" { + return false + } + } + } + return true +} + // isOriginAllowed checks whether the request origin matches any entry in the // AllowedOrigins whitelist. Supports: // - Exact match: "https://app.example.com" matches exactly diff --git a/backend/internal/middleware/csrf.go b/backend/internal/middleware/csrf.go index 792ea625..759e18ed 100644 --- a/backend/internal/middleware/csrf.go +++ b/backend/internal/middleware/csrf.go @@ -160,8 +160,7 @@ func CSRF(cfg CSRFConfig) gin.HandlerFunc { } if !strings.EqualFold(cookieToken, headerToken) { - applogger.L().Warnf("CSRF token mismatch: cookie=%s header=%s path=%s", - cookieToken, headerToken, c.Request.URL.Path) + applogger.L().Warnf("CSRF token mismatch: path=%s", c.Request.URL.Path) c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "CSRF token mismatch", }) diff --git a/backend/internal/middleware/logger.go b/backend/internal/middleware/logger.go index 5f116090..aa08355d 100644 --- a/backend/internal/middleware/logger.go +++ b/backend/internal/middleware/logger.go @@ -19,10 +19,11 @@ func RequestLogger() gin.HandlerFunc { latency := time.Since(start) status := c.Writer.Status() - + if route := c.FullPath(); route != "" { + path = route + } + applogger.L().Infof("HTTP %s %s %d %v", method, path, status, latency) - - c.Next() } } diff --git a/backend/internal/middleware/security_hardening_test.go b/backend/internal/middleware/security_hardening_test.go new file mode 100644 index 00000000..eb1a1bbe --- /dev/null +++ b/backend/internal/middleware/security_hardening_test.go @@ -0,0 +1,86 @@ +package middleware + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "github.com/gochat/gochat/internal/config" + applogger "github.com/gochat/gochat/pkg/logger" +) + +func TestSecurityLimitsAreIndependentAndIgnoreForgedXFF(t *testing.T) { + gin.SetMode(gin.TestMode) + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + cfg := config.RateLimitConfig{ + Enabled: true, + Login: config.RouteLimitConfig{Requests: 1, WindowSeconds: 60}, + Webhook: config.RouteLimitConfig{Requests: 1, WindowSeconds: 60}, + } + router := gin.New() + require.NoError(t, router.SetTrustedProxies([]string{})) + router.Use(SecurityRateLimit(rdb, cfg)) + router.POST("/api/v1/auth/login", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + router.POST("/webhooks/provider", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + request := func(path, forwardedFor string) int { + req := httptest.NewRequest(http.MethodPost, path, nil) + req.RemoteAddr = "192.0.2.10:4321" + req.Header.Set("X-Forwarded-For", forwardedFor) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + return res.Code + } + require.Equal(t, http.StatusNoContent, request("/api/v1/auth/login", "198.51.100.1")) + require.Equal(t, http.StatusTooManyRequests, request("/api/v1/auth/login", "198.51.100.2")) + require.Equal(t, http.StatusNoContent, request("/webhooks/provider", "198.51.100.2")) +} + +func TestSecurityRateLimitFailsClosedWhenRedisIsUnavailable(t *testing.T) { + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + mr.Close() + t.Cleanup(func() { _ = rdb.Close() }) + router := gin.New() + router.Use(SecurityRateLimit(rdb, config.RateLimitConfig{Enabled: true, Login: config.RouteLimitConfig{Requests: 1, WindowSeconds: 60}})) + router.POST("/api/v1/auth/login", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + res := httptest.NewRecorder() + router.ServeHTTP(res, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)) + require.Equal(t, http.StatusServiceUnavailable, res.Code) +} + +func TestRequestLoggerDoesNotWriteQueryCredentials(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "requests.log") + require.NoError(t, applogger.Init(applogger.Config{Level: "info", Format: "json", Output: logPath, ErrorOutput: logPath})) + router := gin.New() + router.Use(RequestLogger()) + router.GET("/cable", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + router.POST("/webhooks/telegram/:bot_token", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + res := httptest.NewRecorder() + router.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/cable?ticket=do-not-log&token=also-secret", nil)) + res = httptest.NewRecorder() + router.ServeHTTP(res, httptest.NewRequest(http.MethodPost, "/webhooks/telegram/path-secret", nil)) + applogger.Sync() + file, err := os.Open(logPath) + require.NoError(t, err) + t.Cleanup(func() { _ = file.Close() }) + contents, err := io.ReadAll(file) + require.NoError(t, err) + require.Contains(t, string(contents), "/cable") + require.False(t, strings.Contains(string(contents), "do-not-log")) + require.False(t, strings.Contains(string(contents), "also-secret")) + require.False(t, strings.Contains(string(contents), "path-secret")) +} diff --git a/backend/internal/middleware/security_rate_limit.go b/backend/internal/middleware/security_rate_limit.go new file mode 100644 index 00000000..08f2c60e --- /dev/null +++ b/backend/internal/middleware/security_rate_limit.go @@ -0,0 +1,94 @@ +package middleware + +import ( + "context" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + + "github.com/gochat/gochat/internal/config" + "github.com/gochat/gochat/pkg/response" +) + +type securityLimitPolicy struct { + name string + limit int + window time.Duration +} + +// SecurityRateLimit applies independent Redis-backed limits to credential and public ingress routes. +func SecurityRateLimit(rdb *redis.Client, cfg config.RateLimitConfig) gin.HandlerFunc { + if !cfg.Enabled { + return func(c *gin.Context) { c.Next() } + } + fallbacks := map[string]*inMemoryLimiter{} + var mu sync.Mutex + return func(c *gin.Context) { + policy, ok := securityPolicy(c.Request.Method, c.Request.URL.Path, cfg) + if !ok || policy.limit <= 0 || policy.window <= 0 { + c.Next() + return + } + key := fmt.Sprintf("gochat:security_limit:%s:%s", policy.name, c.ClientIP()) + allowed, err := checkSecurityLimit(c.Request.Context(), rdb, key, policy) + if rdb == nil { + mu.Lock() + limiter := fallbacks[policy.name] + if limiter == nil { + limiter = newInMemoryLimiter(policy.limit, policy.window) + fallbacks[policy.name] = limiter + } + mu.Unlock() + allowed, _ = limiter.checkInMemory(key) + err = nil + } + if err != nil { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, response.APIResponse{Success: false, Error: &response.ErrorBody{Code: response.ErrInternal, Message: "rate limit service unavailable"}}) + return + } + if !allowed { + c.Header("Retry-After", fmt.Sprintf("%d", int(policy.window.Seconds()))) + c.AbortWithStatusJSON(http.StatusTooManyRequests, response.APIResponse{Success: false, Error: &response.ErrorBody{Code: response.ErrRateLimit, Message: "Rate limit exceeded"}}) + return + } + c.Next() + } +} + +func checkSecurityLimit(ctx context.Context, rdb *redis.Client, key string, policy securityLimitPolicy) (bool, error) { + if rdb == nil { + return true, nil + } + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + count, err := redis.NewScript(` +local count = redis.call("INCR", KEYS[1]) +if count == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end +return count +`).Run(ctx, rdb, []string{key}, int(policy.window.Seconds())).Int64() + return count <= int64(policy.limit), err +} + +func securityPolicy(method, path string, cfg config.RateLimitConfig) (securityLimitPolicy, bool) { + toPolicy := func(name string, limit config.RouteLimitConfig) (securityLimitPolicy, bool) { + return securityLimitPolicy{name: name, limit: limit.Requests, window: time.Duration(limit.WindowSeconds) * time.Second}, true + } + if method == http.MethodPost && (path == "/auth/sign_in" || path == "/api/v1/auth/login") { + return toPolicy("login", cfg.Login) + } + if (method == http.MethodPost || method == http.MethodPut) && (path == "/auth/password" || path == "/api/v1/auth/reset_password") { + return toPolicy("password_reset", cfg.PasswordReset) + } + if strings.HasPrefix(path, "/webhooks/") { + return toPolicy("webhook", cfg.Webhook) + } + if strings.Contains(path, "/upload") || strings.Contains(path, "/direct_uploads") { + return toPolicy("public_upload", cfg.PublicUpload) + } + return securityLimitPolicy{}, false +} diff --git a/backend/internal/model/access_token.go b/backend/internal/model/access_token.go index 1d252db8..9dc56400 100644 --- a/backend/internal/model/access_token.go +++ b/backend/internal/model/access_token.go @@ -15,16 +15,16 @@ import ( // // OwnerType values: "User" (personal tokens), "PlatformApp" (bot/integration tokens). type AccessToken struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - OwnerType string `gorm:"size:100;not null;index" json:"owner_type"` // User, PlatformApp - OwnerID uint `gorm:"not null;index" json:"owner_id"` - Token string `gorm:"size:255;not null;uniqueIndex" json:"token"` // stores SHA-256 hash - TokenPrefix string `gorm:"size:20;not null;index" json:"token_prefix"` // first 8 chars for fast lookup - Name string `gorm:"size:255" json:"name"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - LastUsedAt *time.Time `json:"last_used_at,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + OwnerType string `gorm:"size:100;not null;index" json:"owner_type"` // User, PlatformApp + OwnerID uint `gorm:"not null;index" json:"owner_id"` + Token string `gorm:"size:255;not null;uniqueIndex" json:"-"` // stores SHA-256 hash + TokenPrefix string `gorm:"size:20;not null;index" json:"token_prefix"` // first 8 chars for fast lookup + Name string `gorm:"size:255" json:"name"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` // Relations (polymorphic — loaded based on OwnerType) @@ -38,4 +38,4 @@ func (AccessToken) TableName() string { return "access_tokens" } const ( AccessTokenOwnerTypeUser = "User" AccessTokenOwnerTypePlatformApp = "PlatformApp" -) \ No newline at end of file +) diff --git a/backend/internal/model/agent_bot.go b/backend/internal/model/agent_bot.go index 4fde2c57..17e8fb90 100644 --- a/backend/internal/model/agent_bot.go +++ b/backend/internal/model/agent_bot.go @@ -17,10 +17,10 @@ type AgentBot struct { Name string `gorm:"size:255;not null" json:"name"` Description string `gorm:"size:512" json:"description"` AvatarURL string `gorm:"size:512" json:"avatar_url"` - OutgoingURL string `gorm:"size:1024" json:"outgoing_url"` // Webhook push URL - BotType string `gorm:"size:50;default:'webhook'" json:"bot_type"` // webhook/default/custom - Secret string `gorm:"size:128;uniqueIndex" json:"secret,omitempty"` // Webhook signing secret (generated on create/reset) - AccessToken string `gorm:"size:128;uniqueIndex" json:"access_token,omitempty"` // Bot API access token (generated on create/reset) + OutgoingURL string `gorm:"size:1024" json:"outgoing_url"` // Webhook push URL + BotType string `gorm:"size:50;default:'webhook'" json:"bot_type"` // webhook/default/custom + Secret string `gorm:"size:512;uniqueIndex" json:"-" secure:"webhook_secret"` // Webhook signing secret (generated on create/reset) + AccessToken string `gorm:"size:512;uniqueIndex" json:"-" secure:"access_token"` // Bot API access token (generated on create/reset) Config json.RawMessage `gorm:"type:jsonb" json:"config"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` diff --git a/backend/internal/model/channel/api.go b/backend/internal/model/channel/api.go index 164fc8b4..fb840519 100644 --- a/backend/internal/model/channel/api.go +++ b/backend/internal/model/channel/api.go @@ -13,8 +13,8 @@ type ChannelAPI struct { ID uint `gorm:"primaryKey" json:"id"` InboxID uint `gorm:"uniqueIndex;not null" json:"inbox_id"` WebhookURL string `gorm:"size:512" json:"webhook_url"` - Secret string `gorm:"size:255" json:"secret"` - HMACToken string `gorm:"size:255" json:"hmac_token"` + Secret string `gorm:"size:512" json:"-" secure:"webhook_secret"` + HMACToken string `gorm:"size:512" json:"-" secure:"hmac_token"` HMACMandatory bool `gorm:"column:hmac_mandatory;default:false" json:"hmac_mandatory"` AdditionalAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"additional_attributes"` Identifier string `gorm:"size:255" json:"identifier"` diff --git a/backend/internal/model/channel/email.go b/backend/internal/model/channel/email.go index 8e10b906..74957001 100644 --- a/backend/internal/model/channel/email.go +++ b/backend/internal/model/channel/email.go @@ -72,7 +72,7 @@ type ChannelEmail struct { IMAPLogin string `gorm:"size:255" json:"imap_login,omitempty"` // IMAP password (stored encrypted at application level) - IMAPPassword string `gorm:"size:255" json:"imap_password,omitempty"` + IMAPPassword string `gorm:"size:512" json:"-" secure:"password"` // IMAP SSL mode: "none", "starttls", "ssl" IMAPSSLMode string `gorm:"size:20;default:ssl" json:"imap_ssl_mode,omitempty"` @@ -95,7 +95,7 @@ type ChannelEmail struct { SMTPLogin string `gorm:"size:255" json:"smtp_login,omitempty"` // SMTP password (stored encrypted at application level) - SMTPPassword string `gorm:"size:255" json:"smtp_password,omitempty"` + SMTPPassword string `gorm:"size:512" json:"-" secure:"password"` // SMTP SSL mode: "none", "starttls", "ssl" SMTPSSLMode string `gorm:"size:20;default:starttls" json:"smtp_ssl_mode,omitempty"` @@ -159,4 +159,4 @@ func formatPort(port int) string { return "default" } return fmt.Sprintf("%d", port) -} \ No newline at end of file +} diff --git a/backend/internal/model/channel/facebook.go b/backend/internal/model/channel/facebook.go index 276d76a7..cce4240e 100644 --- a/backend/internal/model/channel/facebook.go +++ b/backend/internal/model/channel/facebook.go @@ -30,10 +30,10 @@ type ChannelFacebook struct { // Long-lived Page access token for Graph API calls // Requires pages_messaging permission for Messenger - PageAccessToken string `gorm:"size:512;not null" json:"page_access_token"` + PageAccessToken string `gorm:"size:1024;not null" json:"-" secure:"access_token"` // Long-lived user access token used to discover and reauthorize pages. - UserAccessToken string `gorm:"size:512" json:"user_access_token,omitempty"` + UserAccessToken string `gorm:"size:1024" json:"-" secure:"access_token"` // Display name of the Facebook Page PageName string `gorm:"size:255" json:"page_name,omitempty"` @@ -43,7 +43,7 @@ type ChannelFacebook struct { // Webhook verify token — used during FB webhook subscription // Reference: Chatwoot doesn't store this; GoChat adds for verification - WebhookVerifyToken string `gorm:"size:255" json:"webhook_verify_token,omitempty"` + WebhookVerifyToken string `gorm:"size:512" json:"-" secure:"webhook_secret"` // Instagram Business Account ID — links to IG account for cross-channel // When set, this FB Page also manages Instagram DMs diff --git a/backend/internal/model/channel/instagram.go b/backend/internal/model/channel/instagram.go index 84601908..eb0527fb 100644 --- a/backend/internal/model/channel/instagram.go +++ b/backend/internal/model/channel/instagram.go @@ -38,7 +38,7 @@ type ChannelInstagram struct { // Page access token inherited from the connected Facebook Page // Instagram DM requires a Page access token with instagram_manage_messages permission - PageAccessToken string `gorm:"size:512;not null" json:"page_access_token"` + PageAccessToken string `gorm:"size:1024;not null" json:"-" secure:"access_token"` // Connected Facebook Page ID — the FB Page that manages this IG account // Reference: Chatwoot connected_fb_page_id diff --git a/backend/internal/model/channel/tiktok.go b/backend/internal/model/channel/tiktok.go index e7c737f1..0fcf0289 100644 --- a/backend/internal/model/channel/tiktok.go +++ b/backend/internal/model/channel/tiktok.go @@ -34,12 +34,12 @@ type ChannelTikTok struct { TikTokBusinessID string `gorm:"column:tiktok_business_id;type:varchar(255);not null" json:"tiktok_business_id"` // OAuth2 tokens (obtained via BuildAuthURL → ExchangeToken flow) - AccessToken string `gorm:"type:varchar(512)" json:"access_token,omitempty"` - RefreshToken string `gorm:"type:varchar(512)" json:"refresh_token,omitempty"` + AccessToken string `gorm:"type:text" json:"-" secure:"access_token"` + RefreshToken string `gorm:"type:text" json:"-" secure:"refresh_token"` TokenExpiresAt time.Time `gorm:"" json:"token_expires_at,omitempty"` // Webhook verification token (set during channel creation for webhook setup) - WebhookVerifyToken string `gorm:"type:varchar(255)" json:"webhook_verify_token,omitempty"` + WebhookVerifyToken string `gorm:"type:text" json:"-" secure:"webhook_secret"` // Flag indicating the channel needs re-authorization (token expired/revoked) ReauthorizationRequired bool `gorm:"default:false" json:"reauthorization_required,omitempty"` diff --git a/backend/internal/model/channel/web_widget.go b/backend/internal/model/channel/web_widget.go index 753f982b..c57018db 100644 --- a/backend/internal/model/channel/web_widget.go +++ b/backend/internal/model/channel/web_widget.go @@ -6,14 +6,14 @@ import "github.com/gochat/gochat/internal/model" // Reference: Chatwoot app/models/channel/web_widget.rb type ChannelWebWidget struct { model.BaseModelWithoutID - InboxID uint `gorm:"not null;uniqueIndex" json:"inbox_id"` - WebsiteURL string `gorm:"size:1024;not null" json:"website_url"` - WelcomeTitle string `gorm:"size:255" json:"welcome_title"` - WelcomeTagline string `gorm:"size:255" json:"welcome_tagline"` - WidgetColor string `gorm:"size:20;default:#1f93ff" json:"widget_color"` - ReplyTime string `gorm:"size:50;default:in_a_few_minutes" json:"reply_time"` - HmacToken string `gorm:"size:255" json:"hmac_token,omitempty"` - PreChatFormEnabled bool `gorm:"default:false" json:"pre_chat_form_enabled"` + InboxID uint `gorm:"not null;uniqueIndex" json:"inbox_id"` + WebsiteURL string `gorm:"size:1024;not null" json:"website_url"` + WelcomeTitle string `gorm:"size:255" json:"welcome_title"` + WelcomeTagline string `gorm:"size:255" json:"welcome_tagline"` + WidgetColor string `gorm:"size:20;default:#1f93ff" json:"widget_color"` + ReplyTime string `gorm:"size:50;default:in_a_few_minutes" json:"reply_time"` + HmacToken string `gorm:"size:512" json:"-" secure:"hmac_token"` + PreChatFormEnabled bool `gorm:"default:false" json:"pre_chat_form_enabled"` } func (ChannelWebWidget) TableName() string { return "channel_web_widgets" } diff --git a/backend/internal/model/channel/whatsapp.go b/backend/internal/model/channel/whatsapp.go index 03780c4b..e90f9f48 100644 --- a/backend/internal/model/channel/whatsapp.go +++ b/backend/internal/model/channel/whatsapp.go @@ -1,7 +1,12 @@ package channel import ( + "crypto/sha256" + "encoding/hex" + "strings" + "github.com/gochat/gochat/internal/model" + "gorm.io/gorm" ) // ChannelWhatsApp represents a WhatsApp Business channel configuration. @@ -56,7 +61,7 @@ type ChannelWhatsApp struct { // Access token for WhatsApp Business API (Cloud API or 360dialog API) // For Cloud API: permanent access token from Meta App dashboard // For 360dialog: API key from 360dialog hub - AccessToken string `gorm:"size:512;not null" json:"access_token"` + AccessToken string `gorm:"type:text;not null" json:"-" secure:"access_token"` // WhatsApp provider backend — determines which API to use // Reference: model.WhatsAppProvider enum (whatsapp_cloud / 360dialog) @@ -72,11 +77,13 @@ type ChannelWhatsApp struct { // Provider-specific configuration (JSON blob) // For 360dialog: { "api_key": "...", "namespace": "..." } // For Cloud API: { "app_id": "...", "webhook_verify_token": "..." } - ProviderConfig string `gorm:"type:text" json:"provider_config"` + ProviderConfig string `gorm:"type:text" json:"-" secure:"provider_config"` // Webhook verify token — used during WhatsApp webhook subscription verification // Reference: Cloud API GET webhook with hub.mode=subscribe, hub.verify_token - WebhookVerifyToken string `gorm:"size:255" json:"webhook_verify_token"` + WebhookVerifyToken string `gorm:"type:text" json:"-" secure:"webhook_secret"` + // Digest keeps webhook verification lookup indexed while the token is encrypted with randomized AES-GCM. + WebhookVerifyTokenDigest string `gorm:"size:64;index" json:"-"` // Whether to auto-create contacts from incoming WhatsApp messages AutoCreateContact bool `gorm:"default:false" json:"auto_create_contact"` @@ -91,6 +98,14 @@ type ChannelWhatsApp struct { // TableName returns the GORM table name for ChannelWhatsApp. func (ChannelWhatsApp) TableName() string { return "channel_whatsapps" } +func (c *ChannelWhatsApp) BeforeSave(*gorm.DB) error { + if c.WebhookVerifyToken != "" && !strings.HasPrefix(c.WebhookVerifyToken, "enc:v") { + digest := sha256.Sum256([]byte(c.WebhookVerifyToken)) + c.WebhookVerifyTokenDigest = hex.EncodeToString(digest[:]) + } + return nil +} + // GetChannelBase returns a ChannelBase populated from ChannelWhatsApp fields. // Required by the Channelable interface. func (c *ChannelWhatsApp) GetChannelBase() ChannelBase { @@ -119,4 +134,4 @@ func (c *ChannelWhatsApp) IsCloudAPI() bool { // Is360Dialog returns true if this channel uses the 360dialog provider. func (c *ChannelWhatsApp) Is360Dialog() bool { return c.Provider == "360dialog" -} \ No newline at end of file +} diff --git a/backend/internal/model/inbox.go b/backend/internal/model/inbox.go index b4a34bc5..841fe68c 100644 --- a/backend/internal/model/inbox.go +++ b/backend/internal/model/inbox.go @@ -24,7 +24,7 @@ type Inbox struct { EnableAutoAssignment bool `gorm:"default:false" json:"enable_auto_assignment"` AutoAssignmentLimit int `gorm:"default:0" json:"auto_assignment_limit"` Enabled bool `gorm:"default:true" json:"enabled"` - ChannelConfig string `gorm:"type:text" json:"channel_config,omitempty"` // JSON-encoded per-inbox channel configuration + ChannelConfig string `gorm:"type:text" json:"-" secure:"channel_config"` // JSON-encoded per-inbox channel configuration // Chatwoot inbox settings (from permitted_params) GreetingEnabled bool `gorm:"default:false" json:"greeting_enabled"` @@ -43,8 +43,8 @@ type Inbox struct { PortalID *uint `gorm:"index" json:"portal_id,omitempty"` // FK to help-center portal (nullable) // API inbox specific fields - WebhookURL string `gorm:"size:1024" json:"webhook_url,omitempty"` // webhook URL for API inboxes - Secret string `gorm:"size:255" json:"secret,omitempty"` // HMAC secret for API inbox webhook verification + WebhookURL string `gorm:"size:1024" json:"webhook_url,omitempty"` // webhook URL for API inboxes + Secret string `gorm:"size:512" json:"-" secure:"webhook_secret"` // HMAC secret for API inbox webhook verification WorkingHours []WorkingHour `gorm:"foreignKey:InboxID" json:"working_hours,omitempty"` } diff --git a/backend/internal/model/integration_hook.go b/backend/internal/model/integration_hook.go index 1f90727f..1bdab939 100644 --- a/backend/internal/model/integration_hook.go +++ b/backend/internal/model/integration_hook.go @@ -27,10 +27,10 @@ type IntegrationHook struct { AppID string `gorm:"size:100;index" json:"app_id,omitempty"` InboxID *uint `gorm:"index" json:"inbox_id,omitempty"` // nil = account-level hook Inbox *Inbox `gorm:"foreignKey:InboxID" json:"inbox,omitempty"` - HookType HookType `gorm:"size:50;not null;index" json:"hook_type"` // webhook/slack/shopify/linear/notion - Status HookStatus `gorm:"size:20;default:'active'" json:"status"` // active/inactive - URL string `gorm:"size:1024" json:"url"` // webhook callback URL - AccessToken string `gorm:"size:256;uniqueIndex" json:"access_token,omitempty"` // API token for the hook + HookType HookType `gorm:"size:50;not null;index" json:"hook_type"` // webhook/slack/shopify/linear/notion + Status HookStatus `gorm:"size:20;default:'active'" json:"status"` // active/inactive + URL string `gorm:"size:1024" json:"url"` // webhook callback URL + AccessToken string `gorm:"size:512;uniqueIndex" json:"-" secure:"access_token"` // API token for the hook ReferenceID string `gorm:"size:255" json:"reference_id,omitempty"` Settings datatypes.JSON `gorm:"type:jsonb" json:"settings"` // provider-specific config (Slack channel_id, Shopify shop_domain, etc.) CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` diff --git a/backend/internal/model/sensitive_json_test.go b/backend/internal/model/sensitive_json_test.go new file mode 100644 index 00000000..be655eef --- /dev/null +++ b/backend/internal/model/sensitive_json_test.go @@ -0,0 +1,32 @@ +package model_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" +) + +func TestSensitiveFieldsAreNotSerialized(t *testing.T) { + const secret = "must-not-appear" + values := []any{ + model.AccessToken{Token: secret}, + model.AgentBot{Secret: secret, AccessToken: secret}, + model.Inbox{Secret: secret, ChannelConfig: secret}, + model.IntegrationHook{AccessToken: secret}, + model.WebhookSubscription{Secret: secret}, + channelmodel.ChannelAPI{Secret: secret, HMACToken: secret}, + channelmodel.ChannelEmail{IMAPPassword: secret, SMTPPassword: secret}, + channelmodel.ChannelFacebook{PageAccessToken: secret, UserAccessToken: secret, WebhookVerifyToken: secret}, + channelmodel.ChannelTikTok{AccessToken: secret, RefreshToken: secret, WebhookVerifyToken: secret}, + channelmodel.ChannelWhatsApp{AccessToken: secret, ProviderConfig: secret, WebhookVerifyToken: secret}, + } + for _, value := range values { + encoded, err := json.Marshal(value) + require.NoError(t, err) + require.NotContains(t, string(encoded), secret, "%T exposed a sensitive value", value) + } +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 43e96777..fad9406d 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -12,37 +12,37 @@ import ( // User represents an agent/admin user in the system. type User struct { Base - AccountID uint `gorm:"index;not null" json:"account_id"` - Name string `gorm:"size:255;not null" json:"name"` - Email string `gorm:"size:255;uniqueIndex;not null" json:"email"` - Password string `gorm:"size:255;not null" json:"-"` // hashed password (bcrypt) - PasswordDigest string `gorm:"size:255" json:"-"` // alias used by auth service - Provider string `gorm:"size:50;default:email" json:"provider"` // email, google, oidc - UID string `gorm:"size:255" json:"uid,omitempty"` // external ID for OAuth providers - AvatarURL string `gorm:"size:512" json:"avatar_url"` - DisplayName string `gorm:"size:255" json:"display_name"` - MessageSignature string `gorm:"type:text" json:"message_signature"` - PubsubToken string `gorm:"size:255;uniqueIndex" json:"pubsub_token"` - UISettings datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"ui_settings"` - CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"` - Role string `gorm:"size:50;default:agent" json:"role"` // agent, admin - Type string `gorm:"size:50;default:user" json:"type"` - Active bool `gorm:"default:true" json:"active"` - Available bool `gorm:"default:false" json:"available"` + AccountID uint `gorm:"index;not null" json:"account_id"` + Name string `gorm:"size:255;not null" json:"name"` + Email string `gorm:"size:255;uniqueIndex;not null" json:"email"` + Password string `gorm:"size:255;not null" json:"-"` // hashed password (bcrypt) + PasswordDigest string `gorm:"size:255" json:"-"` // alias used by auth service + Provider string `gorm:"size:50;default:email" json:"provider"` // email, google, oidc + UID string `gorm:"size:255" json:"uid,omitempty"` // external ID for OAuth providers + AvatarURL string `gorm:"size:512" json:"avatar_url"` + DisplayName string `gorm:"size:255" json:"display_name"` + MessageSignature string `gorm:"type:text" json:"message_signature"` + PubsubToken string `gorm:"size:255;uniqueIndex" json:"pubsub_token"` + UISettings datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"ui_settings"` + CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"` + Role string `gorm:"size:50;default:agent" json:"role"` // agent, admin + Type string `gorm:"size:50;default:user" json:"type"` + Active bool `gorm:"default:true" json:"active"` + Available bool `gorm:"default:false" json:"available"` // Deprecated: MFA/TOTP functionality has been removed. These fields are kept // only to avoid GORM schema mismatch with existing DB columns. - TOTPSecret string `gorm:"size:255" json:"totp_secret,omitempty"` - TOTPEnabled bool `gorm:"default:false" json:"totp_enabled"` - CustomRoleID *uint `gorm:"index" json:"custom_role_id,omitempty"` - SignInCount int `gorm:"default:0" json:"sign_in_count"` - ResetPasswordToken string `gorm:"size:255;index" json:"-"` - ResetPasswordSentAt *time.Time `json:"-"` - ConfirmationToken string `gorm:"size:255;index" json:"-"` - ConfirmationSentAt *time.Time `json:"-"` - UnconfirmedEmail string `gorm:"size:255" json:"unconfirmed_email,omitempty"` - ConfirmedAt *time.Time `json:"confirmed_at,omitempty"` - LastSignInAt *time.Time `json:"last_sign_in_at,omitempty"` - CurrentSignInAt *time.Time `json:"current_sign_in_at,omitempty"` + TOTPSecret string `gorm:"size:512" json:"-" secure:"totp_secret"` + TOTPEnabled bool `gorm:"default:false" json:"totp_enabled"` + CustomRoleID *uint `gorm:"index" json:"custom_role_id,omitempty"` + SignInCount int `gorm:"default:0" json:"sign_in_count"` + ResetPasswordToken string `gorm:"size:255;index" json:"-"` + ResetPasswordSentAt *time.Time `json:"-"` + ConfirmationToken string `gorm:"size:255;index" json:"-"` + ConfirmationSentAt *time.Time `json:"-"` + UnconfirmedEmail string `gorm:"size:255" json:"unconfirmed_email,omitempty"` + ConfirmedAt *time.Time `json:"confirmed_at,omitempty"` + LastSignInAt *time.Time `json:"last_sign_in_at,omitempty"` + CurrentSignInAt *time.Time `json:"current_sign_in_at,omitempty"` } func (User) TableName() string { return "users" } diff --git a/backend/internal/model/webhook_subscription.go b/backend/internal/model/webhook_subscription.go index 15f1306c..604db820 100644 --- a/backend/internal/model/webhook_subscription.go +++ b/backend/internal/model/webhook_subscription.go @@ -15,8 +15,8 @@ type WebhookSubscription struct { InboxID *uint `gorm:"index" json:"inbox_id,omitempty"` Name string `gorm:"size:255" json:"name,omitempty"` URL string `gorm:"size:2048;not null" json:"url"` - Events json.RawMessage `gorm:"type:jsonb;not null" json:"subscriptions"` // JSON array of event types, e.g. ["conversation_created","message_created"] - Secret string `gorm:"size:128;not null" json:"secret,omitempty"` // HMAC-SHA256 signing secret + Events json.RawMessage `gorm:"type:jsonb;not null" json:"subscriptions"` // JSON array of event types, e.g. ["conversation_created","message_created"] + Secret string `gorm:"size:512;not null" json:"-" secure:"webhook_secret"` // HMAC-SHA256 signing secret WebhookType int `gorm:"default:0" json:"webhook_type,omitempty"` Active bool `gorm:"default:true" json:"active"` VerifiedAt *time.Time `json:"verified_at,omitempty"` diff --git a/backend/internal/security/encryption.go b/backend/internal/security/encryption.go index 1f9020ff..3f17170b 100644 --- a/backend/internal/security/encryption.go +++ b/backend/internal/security/encryption.go @@ -12,6 +12,8 @@ import ( "errors" "fmt" "io" + "strconv" + "strings" applogger "github.com/gochat/gochat/pkg/logger" ) @@ -35,11 +37,11 @@ import ( type SensitiveFieldType string const ( - FieldTypeChannelToken SensitiveFieldType = "channel_token" - FieldTypeAPIKey SensitiveFieldType = "api_key" - FieldTypeWebhookSecret SensitiveFieldType = "webhook_secret" - FieldTypeAccessToken SensitiveFieldType = "access_token" - FieldTypeRefreshToken SensitiveFieldType = "refresh_token" + FieldTypeChannelToken SensitiveFieldType = "channel_token" + FieldTypeAPIKey SensitiveFieldType = "api_key" + FieldTypeWebhookSecret SensitiveFieldType = "webhook_secret" + FieldTypeAccessToken SensitiveFieldType = "access_token" + FieldTypeRefreshToken SensitiveFieldType = "refresh_token" FieldTypeOAuthClientSecret SensitiveFieldType = "oauth_client_secret" ) @@ -72,6 +74,7 @@ func DefaultEncryptionConfig() EncryptionConfig { // Encryptor provides AES-256-GCM encryption and decryption operations. type Encryptor struct { aead cipher.AEAD + aeads map[int]cipher.AEAD keyVersion int enabled bool } @@ -80,6 +83,14 @@ type Encryptor struct { // Returns error if the key is not exactly 32 bytes or if AES // initialization fails. func NewEncryptor(cfg EncryptionConfig) (*Encryptor, error) { + return NewEncryptorWithPreviousKeys(cfg, nil) +} + +// NewEncryptorWithPreviousKeys keeps old keys decrypt-only while new writes use cfg.KeyVersion. +func NewEncryptorWithPreviousKeys(cfg EncryptionConfig, previousKeys map[int]string) (*Encryptor, error) { + if cfg.KeyVersion == 0 { + cfg.KeyVersion = 1 + } if !cfg.Enabled { return &Encryptor{ aead: nil, @@ -88,32 +99,45 @@ func NewEncryptor(cfg EncryptionConfig) (*Encryptor, error) { }, nil } - keyBytes, err := base64.StdEncoding.DecodeString(cfg.AESKey) + aead, err := newAEAD(cfg.AESKey) if err != nil { - return nil, fmt.Errorf("failed to decode base64 AES key: %w", err) + return nil, err } - - if len(keyBytes) != 32 { - return nil, fmt.Errorf("AES key must be 32 bytes for AES-256, got %d bytes", len(keyBytes)) - } - - block, err := aes.NewCipher(keyBytes) - if err != nil { - return nil, fmt.Errorf("failed to create AES cipher: %w", err) - } - - aead, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("failed to create GCM mode: %w", err) + aeads := map[int]cipher.AEAD{cfg.KeyVersion: aead} + for version, key := range previousKeys { + if version < 1 || version == cfg.KeyVersion { + return nil, fmt.Errorf("invalid previous encryption key version v%d", version) + } + oldAEAD, err := newAEAD(key) + if err != nil { + return nil, fmt.Errorf("invalid previous encryption key v%d: %w", version, err) + } + aeads[version] = oldAEAD } return &Encryptor{ aead: aead, + aeads: aeads, keyVersion: cfg.KeyVersion, enabled: true, }, nil } +func newAEAD(encodedKey string) (cipher.AEAD, error) { + keyBytes, err := base64.StdEncoding.DecodeString(encodedKey) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 AES key: %w", err) + } + if len(keyBytes) != 32 { + return nil, fmt.Errorf("AES key must be 32 bytes for AES-256, got %d bytes", len(keyBytes)) + } + block, err := aes.NewCipher(keyBytes) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + return cipher.NewGCM(block) +} + // Encrypt encrypts plaintext using AES-256-GCM and returns a base64-encoded // string prefixed with the key version for rotation support. // @@ -157,9 +181,19 @@ func (e *Encryptor) Decrypt(ciphertext string) (string, error) { return "", nil } - // Strip version prefix if present + aead := e.aead payload := ciphertext if isEncryptedPrefix(ciphertext) { + parts := strings.SplitN(ciphertext, ":", 3) + version, err := strconv.Atoi(strings.TrimPrefix(parts[1], "v")) + if err != nil || len(parts) != 3 { + return "", errors.New("invalid encrypted value prefix") + } + var ok bool + aead, ok = e.aeads[version] + if !ok { + return "", fmt.Errorf("encryption key v%d is not configured", version) + } payload = stripEncryptedPrefix(ciphertext) } @@ -168,14 +202,14 @@ func (e *Encryptor) Decrypt(ciphertext string) (string, error) { return "", fmt.Errorf("failed to decode base64 ciphertext: %w", err) } - nonceSize := e.aead.NonceSize() + nonceSize := aead.NonceSize() if len(data) < nonceSize { return "", errors.New("ciphertext too short: missing nonce") } nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] - plaintext, err := e.aead.Open(nil, nonce, ciphertextBytes, nil) + plaintext, err := aead.Open(nil, nonce, ciphertextBytes, nil) if err != nil { applogger.L().Errorf("failed to decrypt data (GCM authentication failed): %v", err) return "", fmt.Errorf("decryption failed: ciphertext may be corrupted or tampered with: %w", err) @@ -250,4 +284,4 @@ func GenerateAESKey() (string, error) { return "", fmt.Errorf("failed to generate AES key: %w", err) } return base64.StdEncoding.EncodeToString(key), nil -} \ No newline at end of file +} diff --git a/backend/internal/security/gorm_encryption.go b/backend/internal/security/gorm_encryption.go new file mode 100644 index 00000000..cf4bb212 --- /dev/null +++ b/backend/internal/security/gorm_encryption.go @@ -0,0 +1,111 @@ +package security + +import ( + "reflect" + + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +// RegisterGORMEncryption encrypts string fields tagged secure:"..." on writes and +// decrypts them after reads. Existing plaintext remains readable for online migration. +func RegisterGORMEncryption(db *gorm.DB, encryptor *Encryptor) error { + if encryptor == nil || !encryptor.IsEnabled() { + return nil + } + before := func(tx *gorm.DB) { transformStatement(tx, encryptor, true) } + after := func(tx *gorm.DB) { transformStatement(tx, encryptor, false) } + if err := db.Callback().Create().Before("gorm:create").Register("gochat:encrypt", before); err != nil { + return err + } + if err := db.Callback().Create().After("gorm:create").Register("gochat:decrypt", after); err != nil { + return err + } + if err := db.Callback().Update().Before("gorm:update").Register("gochat:encrypt", before); err != nil { + return err + } + if err := db.Callback().Update().After("gorm:update").Register("gochat:decrypt", after); err != nil { + return err + } + return db.Callback().Query().After("gorm:after_query").Register("gochat:decrypt", after) +} + +func transformStatement(tx *gorm.DB, encryptor *Encryptor, encrypt bool) { + if tx.Statement == nil || tx.Statement.Schema == nil { + return + } + if values, ok := tx.Statement.Dest.(map[string]interface{}); ok { + transformMap(tx, encryptor, values, encrypt) + return + } + transformValue(tx, encryptor, tx.Statement.ReflectValue, tx.Statement.Schema, encrypt) +} + +func transformMap(tx *gorm.DB, encryptor *Encryptor, values map[string]interface{}, encrypt bool) { + for key, value := range values { + field := tx.Statement.Schema.LookUpField(key) + if field == nil || field.StructField.Tag.Get("secure") == "" { + continue + } + text, ok := value.(string) + if !ok { + continue + } + transformed, err := transformSecret(encryptor, text, encrypt) + if err != nil { + tx.AddError(err) + return + } + values[key] = transformed + } +} + +func transformValue(tx *gorm.DB, encryptor *Encryptor, value reflect.Value, modelSchema *schema.Schema, encrypt bool) { + for value.IsValid() && (value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface) { + if value.IsNil() { + return + } + value = value.Elem() + } + if value.Kind() == reflect.Slice || value.Kind() == reflect.Array { + for i := 0; i < value.Len(); i++ { + transformValue(tx, encryptor, value.Index(i), modelSchema, encrypt) + } + return + } + if value.Kind() != reflect.Struct { + return + } + for _, field := range modelSchema.Fields { + if field.StructField.Tag.Get("secure") == "" { + continue + } + current, zero := field.ValueOf(tx.Statement.Context, value) + text, ok := current.(string) + if !ok || zero || text == "" { + continue + } + transformed, err := transformSecret(encryptor, text, encrypt) + if err != nil { + tx.AddError(err) + return + } + if err := field.Set(tx.Statement.Context, value, transformed); err != nil { + tx.AddError(err) + return + } + } +} + +func transformSecret(encryptor *Encryptor, value string, encrypt bool) (string, error) { + if encrypt { + if IsEncrypted(value) { + return value, nil + } + return encryptor.Encrypt(value) + } + if !IsEncrypted(value) { + return value, nil + } + return encryptor.Decrypt(value) +} diff --git a/backend/internal/security/gorm_encryption_test.go b/backend/internal/security/gorm_encryption_test.go new file mode 100644 index 00000000..43e191cc --- /dev/null +++ b/backend/internal/security/gorm_encryption_test.go @@ -0,0 +1,71 @@ +package security + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type encryptedTestRecord struct { + ID uint `gorm:"primaryKey"` + Secret string `secure:"access_token"` + JSONData string `secure:"provider_config"` +} + +func TestGORMEncryptionSupportsOnlineKeyRotation(t *testing.T) { + keyV1 := base64.StdEncoding.EncodeToString([]byte("11111111111111111111111111111111")) + keyV2 := base64.StdEncoding.EncodeToString([]byte("22222222222222222222222222222222")) + dsn := "file:gorm-encryption?mode=memory&cache=shared" + dbV1, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, dbV1.AutoMigrate(&encryptedTestRecord{})) + encV1, err := NewEncryptor(EncryptionConfig{Enabled: true, AESKey: keyV1, KeyVersion: 1}) + require.NoError(t, err) + require.NoError(t, RegisterGORMEncryption(dbV1, encV1)) + + record := encryptedTestRecord{Secret: "sensitive-value", JSONData: `{"api_key":"provider-secret"}`} + require.NoError(t, dbV1.Create(&record).Error) + require.Equal(t, "sensitive-value", record.Secret) + require.True(t, IsEncrypted(rawEncryptedSecret(t, dbV1, record.ID))) + require.Contains(t, rawEncryptedSecret(t, dbV1, record.ID), "enc:v1:") + require.NotContains(t, rawEncryptedJSON(t, dbV1, record.ID), "provider-secret") + + dbV2, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + encV2, err := NewEncryptorWithPreviousKeys(EncryptionConfig{Enabled: true, AESKey: keyV2, KeyVersion: 2}, map[int]string{1: keyV1}) + require.NoError(t, err) + require.NoError(t, RegisterGORMEncryption(dbV2, encV2)) + var loaded encryptedTestRecord + require.NoError(t, dbV2.First(&loaded, record.ID).Error) + require.Equal(t, "sensitive-value", loaded.Secret) + require.Equal(t, `{"api_key":"provider-secret"}`, loaded.JSONData) + require.NoError(t, dbV2.Save(&loaded).Error) + require.Contains(t, rawEncryptedSecret(t, dbV2, record.ID), "enc:v2:") +} + +func rawEncryptedJSON(t *testing.T, db *gorm.DB, id uint) string { + t.Helper() + sqlDB, err := db.DB() + require.NoError(t, err) + var secret string + require.NoError(t, sqlDB.QueryRow("SELECT json_data FROM encrypted_test_records WHERE id = ?", id).Scan(&secret)) + return secret +} + +func TestEncryptorRejectsPreviousKeyAtCurrentVersion(t *testing.T) { + key := base64.StdEncoding.EncodeToString([]byte("11111111111111111111111111111111")) + _, err := NewEncryptorWithPreviousKeys(EncryptionConfig{Enabled: true, AESKey: key, KeyVersion: 2}, map[int]string{2: key}) + require.ErrorContains(t, err, "invalid previous encryption key version") +} + +func rawEncryptedSecret(t *testing.T, db *gorm.DB, id uint) string { + t.Helper() + sqlDB, err := db.DB() + require.NoError(t, err) + var secret string + require.NoError(t, sqlDB.QueryRow("SELECT secret FROM encrypted_test_records WHERE id = ?", id).Scan(&secret)) + return secret +} diff --git a/backend/internal/service/auth_security_test.go b/backend/internal/service/auth_security_test.go new file mode 100644 index 00000000..f61c027a --- /dev/null +++ b/backend/internal/service/auth_security_test.go @@ -0,0 +1,35 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/gochat/gochat/internal/auth" + "github.com/gochat/gochat/internal/config" + "github.com/gochat/gochat/internal/model" +) + +func TestRevokedSessionRejectsExistingAccessToken(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:revoked-session?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{})) + user := model.User{Base: model.Base{ID: 77}, AccountID: 3, Name: "Agent", Email: "agent@example.test", Provider: "email", Active: true} + require.NoError(t, db.Create(&user).Error) + + cfg := &config.JWTConfig{Secret: "test-secret", AccessExpiryMinutes: 15, RefreshExpiryHours: 168} + jwtService := auth.NewJWTService(cfg) + refreshStore := auth.NewRefreshTokenStore(nil, cfg) + service := NewAuthService(db, jwtService, refreshStore) + login := &LoginOutput{User: &user, AccountID: 3, Role: "agent"} + require.NoError(t, service.TrackChatwootSession(context.Background(), login, "browser", "127.0.0.1", "test")) + + _, err = service.ValidateAccessToken(context.Background(), login.TokenPair.AccessToken) + require.NoError(t, err) + require.NoError(t, service.RevokeChatwootSession(context.Background(), user.ID, login.ClientID)) + _, err = service.ValidateAccessToken(context.Background(), login.TokenPair.AccessToken) + require.ErrorContains(t, err, "session revoked") +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 04d174cb..0ca9010a 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -115,6 +115,14 @@ func (s *AuthService) RevokeChatwootSession(ctx context.Context, userID uint, cl return s.refreshStore.RevokeClient(ctx, userID, clientID) } +func (s *AuthService) RevokeBrowserSession(ctx context.Context, refreshToken string) error { + claims, err := s.jwtService.ValidateRefreshToken(refreshToken) + if err != nil { + return err + } + return s.RevokeChatwootSession(ctx, claims.UserID, claims.ClientID) +} + func chatwootSessionUserAgent(userAgent string) (browserName, browserVersion, deviceName, platformName, platformVersion string) { browserName = "Unknown" deviceName = "Desktop" @@ -241,6 +249,9 @@ type RefreshInput struct { type RefreshOutput struct { TokenPair *auth.TokenPair User *model.User + AccountID uint + Role string + ClientID string } // Refresh rotates a refresh token: validates old token, generates new pair. @@ -253,6 +264,7 @@ func (s *AuthService) Refresh(ctx context.Context, input *RefreshInput) (*Refres } var user model.User + var accountUser AccountUser var tokenPair *auth.TokenPair err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, claims.UserID).Error; err != nil { @@ -267,7 +279,6 @@ func (s *AuthService) Refresh(ctx context.Context, input *RefreshInput) (*Refres return auth.ErrSessionRevoked } } - var accountUser AccountUser if err := tx.Where("user_id = ?", user.ID).Order("id ASC").First(&accountUser).Error; err != nil { return fmt.Errorf("failed to get user account: %w", err) } @@ -292,6 +303,9 @@ func (s *AuthService) Refresh(ctx context.Context, input *RefreshInput) (*Refres return &RefreshOutput{ TokenPair: tokenPair, User: &user, + AccountID: accountUser.AccountID, + Role: accountUser.Role, + ClientID: claims.ClientID, }, nil } @@ -309,6 +323,7 @@ func (s *AuthService) Logout(ctx context.Context, userID uint) error { type SwitchAccountInput struct { UserID uint AccountID uint + ClientID string } // SwitchAccountOutput holds account switch response. @@ -334,15 +349,15 @@ func (s *AuthService) SwitchAccount(ctx context.Context, input *SwitchAccountInp } // Generate new token pair with switched account - tokenPair, err := s.jwtService.GenerateTokenPair(&user, input.AccountID, accountUser.Role) + tokenPair, err := s.jwtService.GenerateTokenPairForClient(&user, input.AccountID, accountUser.Role, input.ClientID) if err != nil { return nil, fmt.Errorf("failed to generate tokens: %w", err) } // Rotate refresh token - if err := s.refreshStore.Rotate(ctx, input.UserID, tokenPair.RefreshToken); err != nil { + if err := s.refreshStore.RotateForClient(ctx, input.UserID, input.ClientID, tokenPair.RefreshToken); err != nil { // If no old token to rotate, just store new one - if err := s.refreshStore.Store(ctx, input.UserID, tokenPair.RefreshToken); err != nil { + if err := s.refreshStore.StoreForClient(ctx, input.UserID, input.ClientID, tokenPair.RefreshToken); err != nil { return nil, fmt.Errorf("failed to store refresh token: %w", err) } } diff --git a/backend/internal/service/push_delivery_service.go b/backend/internal/service/push_delivery_service.go index c7f2e552..67002d2f 100644 --- a/backend/internal/service/push_delivery_service.go +++ b/backend/internal/service/push_delivery_service.go @@ -79,15 +79,15 @@ func (s *PushDeliveryService) SendPushNotification(ctx context.Context, userID u switch t.Platform { case "web": if err := s.deliverWebPush(ctx, t, payloadJSON); err != nil { - applogger.L().Errorf("Web push delivery failed: user=%d token=%s err=%v", userID, t.Token[:min(8, len(t.Token))]+"...", err) + applogger.L().Errorf("Web push delivery failed: user=%d token_id=%d err=%v", userID, t.ID, err) failCount++ } else { successCount++ } case "ios", "android": // FCM/APNs delivery requires Firebase/Apple config — log as pending integration - applogger.L().Infof("Mobile push delivery pending (FCM/APNs): user=%d platform=%s token=%s payload=%s", - userID, t.Platform, t.Token[:min(8, len(t.Token))]+"...", string(payloadJSON)) + applogger.L().Infof("Mobile push delivery pending (FCM/APNs): user=%d platform=%s token_id=%d", + userID, t.Platform, t.ID) // Future: call FCM HTTP v1 API or APNs HTTP/2 API default: applogger.L().Warnf("Unknown push platform %s for user %d, skipping", t.Platform, userID) diff --git a/backend/internal/service/widget_service.go b/backend/internal/service/widget_service.go index 6219f42e..392e8a57 100644 --- a/backend/internal/service/widget_service.go +++ b/backend/internal/service/widget_service.go @@ -1648,7 +1648,7 @@ func (s *WidgetService) findInboxByWebsiteToken(ctx context.Context, websiteToke } } - return nil, fmt.Errorf("no inbox found for website_token %s", websiteToken) + return nil, errors.New("no inbox found for website token") } // GetInboxByWebsiteToken is the public wrapper for findInboxByWebsiteToken, diff --git a/backend/internal/ws/auth.go b/backend/internal/ws/auth.go index 665e3485..47558941 100644 --- a/backend/internal/ws/auth.go +++ b/backend/internal/ws/auth.go @@ -40,16 +40,22 @@ type WSAuthenticator struct { jwtService *auth.JWTService contactInboxRepo *repository.ContactInboxRepo db *gorm.DB + ticketStore *auth.WSTicketStore } // NewWSAuthenticator creates a new WebSocket authenticator. -func NewWSAuthenticator(jwtService *auth.JWTService, contactInboxRepo *repository.ContactInboxRepo, db ...*gorm.DB) *WSAuthenticator { +func NewWSAuthenticator(jwtService *auth.JWTService, contactInboxRepo *repository.ContactInboxRepo, dependencies ...any) *WSAuthenticator { authenticator := &WSAuthenticator{ jwtService: jwtService, contactInboxRepo: contactInboxRepo, } - if len(db) > 0 { - authenticator.db = db[0] + for _, dependency := range dependencies { + switch value := dependency.(type) { + case *gorm.DB: + authenticator.db = value + case *auth.WSTicketStore: + authenticator.ticketStore = value + } } return authenticator } @@ -66,39 +72,55 @@ func NewWSAuthenticator(jwtService *auth.JWTService, contactInboxRepo *repositor // // Returns WSClaims on success, or an error suitable for HTTP 401 rejection. func (a *WSAuthenticator) Authenticate(c *gin.Context) (*WSClaims, error) { - // --- Path 1: Agent/User authentication via JWT --- - token := extractWSToken(c) - if token != "" { - claims, _, err := auth.ValidateUserAccessToken(c.Request.Context(), a.jwtService, a.db, token) - if err != nil { - logger.L().Debugf("ws auth: JWT validation failed: %v", err) - return nil, fmt.Errorf("invalid JWT token: %w", err) + if a.ticketStore != nil { + if ticket := c.Query("ticket"); ticket != "" { + claims, err := a.ticketStore.Consume(c.Request.Context(), ticket) + if err != nil { + return nil, auth.ErrInvalidWSTicket + } + if _, err := auth.ValidateUserAccess(c.Request.Context(), a.db, claims.UserID, claims.ClientID); err != nil { + return nil, auth.ErrInvalidWSTicket + } + return &WSClaims{ + UserID: claims.UserID, AccountID: claims.AccountID, Role: claims.Role, + Provider: claims.Provider, ClientID: claims.ClientID, + }, nil } + } else { + // --- Path 1: Agent/User authentication via JWT --- + token := extractWSToken(c) + if token != "" { + claims, _, err := auth.ValidateUserAccessToken(c.Request.Context(), a.jwtService, a.db, token) + if err != nil { + logger.L().Debugf("ws auth: JWT validation failed: %v", err) + return nil, fmt.Errorf("invalid JWT token: %w", err) + } - wsClaims := &WSClaims{ - UserID: claims.UserID, - AccountID: claims.AccountID, - Role: claims.Role, - Provider: claims.Provider, - ClientID: claims.ClientID, - IsContact: false, + wsClaims := &WSClaims{ + UserID: claims.UserID, + AccountID: claims.AccountID, + Role: claims.Role, + Provider: claims.Provider, + ClientID: claims.ClientID, + IsContact: false, + } + + // Also extract pubsub_token and user_id if present (for dual auth context) + pubsubToken := c.Query("pubsub_token") + if pubsubToken != "" { + wsClaims.PubsubToken = pubsubToken + } + + logger.L().Infof("ws auth: agent authenticated (user_id=%d, account_id=%d, role=%s)", + wsClaims.UserID, wsClaims.AccountID, wsClaims.Role) + return wsClaims, nil } - - // Also extract pubsub_token and user_id if present (for dual auth context) - pubsubToken := c.Query("pubsub_token") - if pubsubToken != "" { - wsClaims.PubsubToken = pubsubToken - } - - logger.L().Infof("ws auth: agent authenticated (user_id=%d, account_id=%d, role=%s)", - wsClaims.UserID, wsClaims.AccountID, wsClaims.Role) - return wsClaims, nil } // --- Path 2: Contact authentication via pubsub_token --- pubsubToken := c.Query("pubsub_token") if pubsubToken == "" { - return nil, errors.New("authentication required: provide 'token' (JWT) or 'pubsub_token'") + return nil, errors.New("authentication required") } var providedContactID *uint if userIDStr := c.Query("user_id"); userIDStr != "" { @@ -258,6 +280,7 @@ func ParseWSQueryParams(query url.Values) map[string]string { "pubsub_token", "user_id", "token", + "ticket", } { if v := query.Get(key); v != "" { params[key] = v diff --git a/backend/internal/ws/security_hardening_test.go b/backend/internal/ws/security_hardening_test.go new file mode 100644 index 00000000..086b8a59 --- /dev/null +++ b/backend/internal/ws/security_hardening_test.go @@ -0,0 +1,66 @@ +package ws + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/gochat/gochat/internal/auth" + "github.com/gochat/gochat/internal/config" + "github.com/gochat/gochat/internal/model" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestWSAuthenticatorRequiresAndConsumesOneTimeTicket(t *testing.T) { + gin.SetMode(gin.TestMode) + jwtService := auth.NewJWTService(&config.JWTConfig{Secret: "test-secret", AccessExpiryMinutes: 15}) + tickets := auth.NewWSTicketStore(nil, time.Minute) + authenticator := NewWSAuthenticator(jwtService, nil, tickets) + want := auth.WSTicketClaims{UserID: 7, AccountID: 3, Role: "agent", Provider: "email", ClientID: "browser-session"} + ticket, err := tickets.Issue(context.Background(), want) + require.NoError(t, err) + + ctx := wsTestContext("/cable?ticket=" + ticket) + claims, err := authenticator.Authenticate(ctx) + require.NoError(t, err) + require.Equal(t, want.UserID, claims.UserID) + require.Equal(t, want.ClientID, claims.ClientID) + _, err = authenticator.Authenticate(wsTestContext("/cable?ticket=" + ticket)) + require.ErrorIs(t, err, auth.ErrInvalidWSTicket) + + pair, err := jwtService.GenerateTokenPair(&model.User{Base: model.Base{ID: 7}, Provider: "email"}, 3, "agent") + require.NoError(t, err) + _, err = authenticator.Authenticate(wsTestContext("/cable?token=" + pair.AccessToken)) + require.Error(t, err) +} + +func TestWSAuthenticatorRejectsTicketAfterSessionRevocation(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{})) + user := &model.User{Name: "Agent", Email: "agent@example.com", Provider: "email", Active: true} + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(&model.UserSession{UserID: user.ID, ClientID: "browser-session"}).Error) + + tickets := auth.NewWSTicketStore(nil, time.Minute) + ticket, err := tickets.Issue(context.Background(), auth.WSTicketClaims{ + UserID: user.ID, AccountID: 3, Role: "agent", Provider: "email", ClientID: "browser-session", + }) + require.NoError(t, err) + require.NoError(t, db.Where("user_id = ? AND client_id = ?", user.ID, "browser-session").Delete(&model.UserSession{}).Error) + + authenticator := NewWSAuthenticator(auth.NewJWTService(&config.JWTConfig{Secret: "test-secret"}), nil, db, tickets) + _, err = authenticator.Authenticate(wsTestContext("/cable?ticket=" + ticket)) + require.ErrorIs(t, err, auth.ErrInvalidWSTicket) +} + +func wsTestContext(target string) *gin.Context { + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("GET", target, nil) + return ctx +} diff --git a/backend/migrations/000084_expand_encrypted_secret_fields.down.sql b/backend/migrations/000084_expand_encrypted_secret_fields.down.sql new file mode 100644 index 00000000..be1125a6 --- /dev/null +++ b/backend/migrations/000084_expand_encrypted_secret_fields.down.sql @@ -0,0 +1,5 @@ +-- Keep expanded columns on rollback: encrypted values can exceed the old limits, +-- and narrowing them would risk data loss. +DROP INDEX IF EXISTS idx_channel_whatsapps_webhook_verify_token_digest; +ALTER TABLE IF EXISTS channel_whatsapps DROP COLUMN IF EXISTS webhook_verify_token_digest; +SELECT 1; diff --git a/backend/migrations/000084_expand_encrypted_secret_fields.up.sql b/backend/migrations/000084_expand_encrypted_secret_fields.up.sql new file mode 100644 index 00000000..ddffbcea --- /dev/null +++ b/backend/migrations/000084_expand_encrypted_secret_fields.up.sql @@ -0,0 +1,38 @@ +DO $$ +DECLARE + target_table text; + target_column text; +BEGIN + FOR target_table, target_column IN + SELECT * FROM (VALUES + ('agent_bots', 'secret'), ('agent_bots', 'access_token'), + ('inboxes', 'secret'), ('integration_hooks', 'access_token'), + ('webhook_subscriptions', 'secret'), ('users', 'totp_secret'), + ('channel_api', 'secret'), ('channel_api', 'hmac_token'), + ('channel_facebook_pages', 'page_access_token'), ('channel_facebook_pages', 'user_access_token'), + ('channel_facebook_pages', 'webhook_verify_token'), ('channel_instagrams', 'page_access_token'), + ('channel_tiktoks', 'access_token'), ('channel_tiktoks', 'refresh_token'), + ('channel_tiktoks', 'webhook_verify_token'), ('channel_whatsapps', 'access_token'), + ('channel_whatsapps', 'provider_config'), ('channel_whatsapps', 'webhook_verify_token'), + ('channel_emails', 'imap_password'), ('channel_emails', 'smtp_password'), + ('channel_web_widgets', 'hmac_token') + ) AS fields(table_name, column_name) + LOOP + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = target_table AND column_name = target_column + ) THEN + EXECUTE format('ALTER TABLE %I ALTER COLUMN %I TYPE text', target_table, target_column); + END IF; + END LOOP; +END $$; + +ALTER TABLE IF EXISTS channel_whatsapps ADD COLUMN IF NOT EXISTS provider_config text; +ALTER TABLE IF EXISTS channel_whatsapps ADD COLUMN IF NOT EXISTS webhook_verify_token_digest varchar(64); +DO $$ +BEGIN + IF to_regclass('channel_whatsapps') IS NOT NULL THEN + CREATE INDEX IF NOT EXISTS idx_channel_whatsapps_webhook_verify_token_digest + ON channel_whatsapps (webhook_verify_token_digest); + END IF; +END $$; diff --git a/backend/tests/helpers/pg_helper.go b/backend/tests/helpers/pg_helper.go index d68d6ea5..92b7ca64 100644 --- a/backend/tests/helpers/pg_helper.go +++ b/backend/tests/helpers/pg_helper.go @@ -168,6 +168,7 @@ func allModels() []interface{} { &model.Account{}, &model.AccountUser{}, &model.User{}, + &model.UserSession{}, &model.Contact{}, &model.ContactInbox{}, &model.Conversation{}, diff --git a/deploy/docker/docker-compose.prod-smoke.yml b/deploy/docker/docker-compose.prod-smoke.yml new file mode 100644 index 00000000..1112c41c --- /dev/null +++ b/deploy/docker/docker-compose.prod-smoke.yml @@ -0,0 +1,48 @@ +services: + postgres: + entrypoint: + - /bin/sh + - -ec + - | + install -o postgres -g postgres -m 600 /run/tls/postgres.key /var/lib/postgresql/server.key + install -o postgres -g postgres -m 644 /run/tls/postgres.crt /var/lib/postgresql/server.crt + exec docker-entrypoint.sh postgres -c ssl=on -c ssl_cert_file=/var/lib/postgresql/server.crt -c ssl_key_file=/var/lib/postgresql/server.key + command: [] + volumes: + - ${GOCHAT_TLS_DIR:?set GOCHAT_TLS_DIR}:/run/tls:ro + + redis: + entrypoint: + - /bin/sh + - -ec + - | + install -o redis -g redis -m 600 /run/tls/redis.key /data/redis.key + install -o redis -g redis -m 644 /run/tls/redis.crt /data/redis.crt + exec /usr/bin/setpriv --reuid redis --regid redis --clear-groups redis-server --port 0 --tls-port 6379 --tls-cert-file /data/redis.crt --tls-key-file /data/redis.key --tls-ca-cert-file /run/tls/ca.crt --tls-auth-clients no --requirepass "$${REDIS_PASSWORD}" --appendonly yes + command: [] + volumes: + - ${GOCHAT_TLS_DIR:?set GOCHAT_TLS_DIR}:/run/tls:ro + healthcheck: + test: + [ + "CMD-SHELL", + "redis-cli --tls --cacert /run/tls/ca.crt -h redis -a '$${REDIS_PASSWORD}' ping", + ] + + gochat: + environment: + SSL_CERT_FILE: /run/tls/ca.crt + volumes: + - ${GOCHAT_TLS_DIR:?set GOCHAT_TLS_DIR}:/run/tls:ro + + worker: + environment: + SSL_CERT_FILE: /run/tls/ca.crt + volumes: + - ${GOCHAT_TLS_DIR:?set GOCHAT_TLS_DIR}:/run/tls:ro + + migrate: + environment: + SSL_CERT_FILE: /run/tls/ca.crt + volumes: + - ${GOCHAT_TLS_DIR:?set GOCHAT_TLS_DIR}:/run/tls:ro diff --git a/deploy/docker/docker-compose.prod.yml b/deploy/docker/docker-compose.prod.yml index 928351ec..2d97c5f4 100644 --- a/deploy/docker/docker-compose.prod.yml +++ b/deploy/docker/docker-compose.prod.yml @@ -7,15 +7,18 @@ x-gochat-environment: &gochat-environment 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:-postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable} + GOCHAT_DATABASE_DSN: ${GOCHAT_DATABASE_DSN:?set an external PostgreSQL DSN with sslmode=verify-ca or verify-full} GOCHAT_DATABASE_RUN_MIGRATIONS: "false" GOCHAT_DATABASE_MIGRATIONS_PATH: /app/migrations - GOCHAT_REDIS_DSN: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379 + GOCHAT_REDIS_DSN: ${GOCHAT_REDIS_DSN:?set an external Redis rediss:// DSN} GOCHAT_SEARCH_ENGINE: meilisearch GOCHAT_SEARCH_HOST: http://meilisearch:7700 GOCHAT_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY} GOCHAT_JWT_SECRET: ${GOCHAT_JWT_SECRET:?set GOCHAT_JWT_SECRET} GOCHAT_JWT_PREVIOUS_SECRETS: ${GOCHAT_JWT_PREVIOUS_SECRETS:-} + GOCHAT_ENCRYPTION_ENABLED: "true" + GOCHAT_ENCRYPTION_CURRENT_KEY_VERSION: ${GOCHAT_ENCRYPTION_CURRENT_KEY_VERSION:-1} + GOCHAT_ENCRYPTION_AES_KEY: ${GOCHAT_ENCRYPTION_AES_KEY:?set a base64-encoded 32-byte encryption key} GOCHAT_LOG_LEVEL: info GOCHAT_LOG_FORMAT: json GOCHAT_STORAGE_PROVIDER: local @@ -143,7 +146,6 @@ services: restart: "no" environment: <<: *gochat-environment - GOCHAT_DATABASE_DSN: postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable&lock_timeout=5000&statement_timeout=900000 GOCHAT_DATABASE_RUN_MIGRATIONS: "false" backup: @@ -156,7 +158,7 @@ services: entrypoint: ["/app/scripts/db_backup.sh"] restart: "no" environment: - GOCHAT_DATABASE_DSN: postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable + GOCHAT_DATABASE_DSN: ${GOCHAT_DATABASE_DSN:?set an external PostgreSQL DSN with sslmode=verify-ca or verify-full} GOCHAT_STORAGE_PATH: /source/storage/uploads GOCHAT_CONNECTOR_BACKUP_FILE: /source/connector/${GOCHAT_CONNECTOR_BACKUP_NAME:-latest.db} GOCHAT_BACKUP_DIR: /backup/local @@ -186,7 +188,7 @@ services: command: ["/backup/offsite/${GOCHAT_RESTORE_BUNDLE:-missing.tar.enc}"] restart: "no" environment: - GOCHAT_DATABASE_DSN: postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable + GOCHAT_DATABASE_DSN: ${GOCHAT_DATABASE_DSN:?set an external PostgreSQL DSN with sslmode=verify-ca or verify-full} GOCHAT_STORAGE_PATH: /restore/storage/uploads GOCHAT_CONNECTOR_DB_PATH: /restore/connector/connector.db GOCHAT_BACKUP_PASSPHRASE_FILE: /run/secrets/backup-passphrase diff --git a/deploy/docker/preflight_test.sh b/deploy/docker/preflight_test.sh index 812bd93c..7af8ce4f 100755 --- a/deploy/docker/preflight_test.sh +++ b/deploy/docker/preflight_test.sh @@ -31,7 +31,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.example.test +export GOCHAT_SERVER_CORS_ALLOWED_ORIGINS=https://chat.ci.rogeecn.com export POSTGRES_PASSWORD=ci-postgres-secret export REDIS_PASSWORD=ci-redis-secret export MEILI_MASTER_KEY=ci-meili-secret-16 diff --git a/docs/qa/2026-07-15-cdp-user-function-test-plan.md b/docs/qa/2026-07-15-cdp-user-function-test-plan.md index 553239d0..3aabcbc2 100644 --- a/docs/qa/2026-07-15-cdp-user-function-test-plan.md +++ b/docs/qa/2026-07-15-cdp-user-function-test-plan.md @@ -629,7 +629,7 @@ curl -fsS http://127.0.0.1:9100/health # expect {"status":"ok"} curl -fsS -X POST http://127.0.0.1:9100/api/reset # === 3. 确认 inbox 和数据 === -PGPASSWORD=xiha02 psql -h 127.0.0.1 -p 5444 -U postgres -d gochat_dev \ +PGPASSWORD='' psql -h 127.0.0.1 -p 5444 -U postgres -d gochat_dev \ -c "SELECT count(*) FROM conversations; SELECT count(*) FROM contacts;" # === 4. 配置 fake webhook === diff --git a/docs/security-key-rotation.md b/docs/security-key-rotation.md new file mode 100644 index 00000000..4b7de56a --- /dev/null +++ b/docs/security-key-rotation.md @@ -0,0 +1,10 @@ +## Sensitive-field key rotation + +1. Keep the old key under `encryption.previous_keys.`. +2. Set a new `encryption.current_key_version` and `encryption.aes_key`, then restart GoChat. New writes immediately use the new key; reads accept both. +3. Back up PostgreSQL, then run `cd backend && GOCHAT_ENV=prod go run ./cmd/rotate_secrets`. +4. Verify normal channel, webhook and integration traffic. Remove the old key only after every application and worker instance runs the new version. + +The rotation command includes WhatsApp `provider_config` together with the channel access and webhook credentials. + +Configuration rollback: restore the previous key version/key as current while retaining the new key in `previous_keys`, then restart all instances. The schema rollback does not narrow ciphertext-bearing columns. Rolling back to a binary that predates encrypted-field support requires restoring the pre-rotation database backup; that binary cannot decrypt ciphertext. diff --git a/frontend/app/javascript/dashboard/api/auth.js b/frontend/app/javascript/dashboard/api/auth.js index a1b15ee7..2b53e585 100644 --- a/frontend/app/javascript/dashboard/api/auth.js +++ b/frontend/app/javascript/dashboard/api/auth.js @@ -14,29 +14,13 @@ export default { }, logout() { const urlData = endPoints('logout'); - const fetchPromise = new Promise((resolve, reject) => { - axios - .delete(urlData.url) - .then(response => { - deleteIndexedDBOnLogout(); - clearCookiesOnLogout(); - resolve(response); - }) - .catch(error => { - reject(error); - }); + return axios.delete(urlData.url).finally(() => { + deleteIndexedDBOnLogout(); + clearCookiesOnLogout(); }); - return fetchPromise; }, hasAuthCookie() { - return !!Cookies.get('cw_d_session_info'); - }, - getAuthData() { - if (this.hasAuthCookie()) { - const savedAuthInfo = Cookies.get('cw_d_session_info'); - return JSON.parse(savedAuthInfo || '{}'); - } - return false; + return !!Cookies.get('cw_d_session_state'); }, profileUpdate({ displayName, avatar, ...profileAttributes }) { const formData = new FormData(); diff --git a/frontend/app/javascript/dashboard/api/auth.spec.js b/frontend/app/javascript/dashboard/api/auth.spec.js new file mode 100644 index 00000000..d4dc54d2 --- /dev/null +++ b/frontend/app/javascript/dashboard/api/auth.spec.js @@ -0,0 +1,17 @@ +import * as APIUtils from '../store/utils/api'; +import Auth from './auth'; + +vi.spyOn(APIUtils, 'clearCookiesOnLogout').mockImplementation(() => {}); +vi.spyOn(APIUtils, 'deleteIndexedDBOnLogout').mockResolvedValue(); + +describe('Auth.logout', () => { + it('clears browser credentials even when the server rejects logout', async () => { + globalThis.axios = { + delete: vi.fn().mockRejectedValue({ response: { status: 401 } }), + }; + + await expect(Auth.logout()).rejects.toEqual({ response: { status: 401 } }); + expect(APIUtils.deleteIndexedDBOnLogout).toHaveBeenCalledOnce(); + expect(APIUtils.clearCookiesOnLogout).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/app/javascript/dashboard/helper/APIHelper.js b/frontend/app/javascript/dashboard/helper/APIHelper.js index 8119d121..cfc6d408 100644 --- a/frontend/app/javascript/dashboard/helper/APIHelper.js +++ b/frontend/app/javascript/dashboard/helper/APIHelper.js @@ -1,31 +1,48 @@ import Auth from '../api/auth'; const parseErrorCode = error => Promise.reject(error); +const AUTH_HEADERS = ['access-token', 'token-type', 'client', 'expiry', 'uid']; export default axios => { const { apiHost = '' } = window.chatwootConfig || {}; - const wootApi = axios.create({ baseURL: `${apiHost}/` }); - // Add Auth Headers to requests if logged in - if (Auth.hasAuthCookie()) { - const { - 'access-token': accessToken, - 'token-type': tokenType, - client, - expiry, - uid, - } = Auth.getAuthData(); - Object.assign(wootApi.defaults.headers.common, { - 'access-token': accessToken, - 'token-type': tokenType, - client, - expiry, - uid, - }); - } - // Response parsing interceptor + const wootApi = axios.create({ + baseURL: `${apiHost}/`, + withCredentials: true, + }); + const authHeaders = {}; + let refreshRequest; + + wootApi.interceptors.request.use(config => { + Object.assign(config.headers, authHeaders); + return config; + }); wootApi.interceptors.response.use( - response => response, - error => parseErrorCode(error) + response => { + AUTH_HEADERS.forEach(header => { + if (response.headers?.[header]) + authHeaders[header] = response.headers[header]; + }); + return response; + }, + async error => { + const request = error.config; + if ( + error.response?.status !== 401 || + request?._retry || + request?._sessionRefresh || + !Auth.hasAuthCookie() + ) { + return parseErrorCode(error); + } + request._retry = true; + refreshRequest ||= wootApi + .get('/auth/validate_token', { _sessionRefresh: true }) + .finally(() => { + refreshRequest = null; + }); + await refreshRequest; + return wootApi(request); + } ); return wootApi; }; diff --git a/frontend/app/javascript/dashboard/helper/APIHelper.spec.js b/frontend/app/javascript/dashboard/helper/APIHelper.spec.js new file mode 100644 index 00000000..a69acf4c --- /dev/null +++ b/frontend/app/javascript/dashboard/helper/APIHelper.spec.js @@ -0,0 +1,43 @@ +import axios from 'axios'; +import Cookies from 'js-cookie'; +import createAPIClient from './APIHelper'; + +describe('APIHelper browser session exchange', () => { + it('keeps the access token in memory across reload exchange and 401 refresh', async () => { + vi.spyOn(Cookies, 'get').mockImplementation(name => + name === 'cw_d_session_state' ? '1' : undefined + ); + const requests = []; + let protectedCalls = 0; + const client = createAPIClient(axios); + client.defaults.adapter = config => { + requests.push(config); + if (config.url === '/auth/validate_token') { + return Promise.resolve({ + data: {}, + status: 200, + statusText: 'OK', + headers: { 'access-token': 'short-lived', client: 'browser' }, + config, + }); + } + protectedCalls += 1; + if (protectedCalls === 1) { + return Promise.reject({ config, response: { status: 401 } }); + } + return Promise.resolve({ + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config, + }); + }; + + await client.get('/auth/validate_token'); + await client.get('/api/v1/protected'); + + expect(requests.at(-1).headers['access-token']).toBe('short-lived'); + expect(localStorage.getItem('access-token')).toBeNull(); + }); +}); diff --git a/frontend/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js b/frontend/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js index b5dfebe2..28ce99da 100644 --- a/frontend/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js +++ b/frontend/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js @@ -7,7 +7,6 @@ import '../../../../routes'; vi.spyOn(APIHelpers, 'setUser'); vi.spyOn(APIHelpers, 'clearCookiesOnLogout'); -vi.spyOn(APIHelpers, 'getHeaderExpiry'); vi.spyOn(Cookies, 'get'); const commit = vi.fn(); diff --git a/frontend/app/javascript/dashboard/store/utils/api.js b/frontend/app/javascript/dashboard/store/utils/api.js index dccd881c..9f471ae9 100644 --- a/frontend/app/javascript/dashboard/store/utils/api.js +++ b/frontend/app/javascript/dashboard/store/utils/api.js @@ -1,5 +1,3 @@ -import fromUnixTime from 'date-fns/fromUnixTime'; -import differenceInDays from 'date-fns/differenceInDays'; import Cookies from 'js-cookie'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage'; @@ -25,21 +23,18 @@ export const setUser = user => { emitter.emit(ANALYTICS_IDENTITY, { user }); }; -export const getHeaderExpiry = response => - fromUnixTime(response.headers.expiry); - export const setAuthCredentials = response => { - const expiryDate = getHeaderExpiry(response); - Cookies.set('cw_d_session_info', JSON.stringify(response.headers), { - expires: differenceInDays(expiryDate, new Date()), - }); - setUser(response.data.data, expiryDate); + setUser(response.data.data); }; export const clearBrowserSessionCookies = () => { Cookies.remove('cw_d_session_info'); + Cookies.remove('cw_d_session_state'); Cookies.remove('auth_data'); Cookies.remove('user'); + ['access-token', 'client', 'uid', 'token-type', 'expiry'].forEach(key => + localStorage.removeItem(key) + ); }; export const clearLocalStorageOnLogout = () => { diff --git a/frontend/app/javascript/dashboard/store/utils/api.spec.js b/frontend/app/javascript/dashboard/store/utils/api.spec.js new file mode 100644 index 00000000..ec66731f --- /dev/null +++ b/frontend/app/javascript/dashboard/store/utils/api.spec.js @@ -0,0 +1,32 @@ +import Cookies from 'js-cookie'; +import { clearBrowserSessionCookies, setAuthCredentials } from './api'; + +describe('browser auth storage', () => { + it('never persists response auth headers in JavaScript-readable storage', () => { + const cookie = vi.spyOn(Cookies, 'set'); + const storage = vi.spyOn(Storage.prototype, 'setItem'); + + setAuthCredentials({ + data: { data: { id: 1 } }, + headers: { 'access-token': 'xss-readable-token', client: 'browser' }, + }); + + expect(cookie).not.toHaveBeenCalled(); + expect(storage).not.toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('xss-readable-token') + ); + }); + + it('removes legacy auth storage during logout', () => { + const removeCookie = vi.spyOn(Cookies, 'remove'); + const removeStorage = vi.spyOn(Storage.prototype, 'removeItem'); + + clearBrowserSessionCookies(); + + expect(removeCookie).toHaveBeenCalledWith('cw_d_session_info'); + expect(removeCookie).toHaveBeenCalledWith('cw_d_session_state'); + expect(removeStorage).toHaveBeenCalledWith('access-token'); + expect(removeStorage).toHaveBeenCalledWith('client'); + }); +}); diff --git a/frontend/app/javascript/entrypoints/dashboardConfig.js b/frontend/app/javascript/entrypoints/dashboardConfig.js index 8f386f95..c068ea5e 100644 --- a/frontend/app/javascript/entrypoints/dashboardConfig.js +++ b/frontend/app/javascript/entrypoints/dashboardConfig.js @@ -38,23 +38,6 @@ window.globalConfig = Object.assign( window.errorLoggingConfig = ''; window.browserConfig = { browser_name: navigator.userAgent }; -try { - const sessionCookie = document.cookie - .split('; ') - .find(cookie => cookie.startsWith('cw_d_session_info=')); - if (sessionCookie) { - const raw = decodeURIComponent(sessionCookie.split('=').slice(1).join('=')); - const extract = key => - raw.match(new RegExp(`"${key}":"([^"]+)"`))?.[1] || ''; - const token = extract('access-token'); - if (token) { - localStorage.setItem('access-token', token); - localStorage.setItem('client', extract('client')); - localStorage.setItem('uid', extract('uid')); - localStorage.setItem('token-type', extract('token-type') || 'Bearer'); - localStorage.setItem('expiry', extract('expiry')); - } - } -} catch { - // Ignore malformed legacy session cookies. -} +['access-token', 'client', 'uid', 'token-type', 'expiry'].forEach(key => + localStorage.removeItem(key) +); diff --git a/frontend/app/javascript/shared/helpers/BaseActionCableConnector.js b/frontend/app/javascript/shared/helpers/BaseActionCableConnector.js index b5ead52b..2526f48b 100644 --- a/frontend/app/javascript/shared/helpers/BaseActionCableConnector.js +++ b/frontend/app/javascript/shared/helpers/BaseActionCableConnector.js @@ -13,34 +13,50 @@ class BaseActionCableConnector { websocketHost = '', presenceInterval = PRESENCE_INTERVAL ) { - // Read access-token for WebSocket auth from cookie - let accessToken = ''; - try { - const raw = Cookies.get('cw_d_session_info'); - if (raw) { - const parsed = JSON.parse(raw); - accessToken = parsed['access-token'] || ''; - } - } catch (e) { - // Ignore cookie parse errors - } + this.consumer = null; + this.subscription = null; + this.websocketHost = websocketHost; + this.pubsubToken = pubsubToken; + this.app = app; + this.events = {}; + this.reconnectTimer = null; + this.isAValidEvent = () => true; + this.connect(); + this.triggerPresenceInterval = () => { + setTimeout(() => { + this.subscription?.updatePresence(); + this.triggerPresenceInterval(); + }, presenceInterval); + }; + this.triggerPresenceInterval(); + } - // Default to the page origin so the URL is never undefined. - const wsOrigin = websocketHost || window.location.origin; + async connect() { + const wsOrigin = this.websocketHost || window.location.origin; let websocketURL = `${wsOrigin}/cable`; - if (accessToken) { - websocketURL += `?access-token=${encodeURIComponent(accessToken)}`; - } else if (pubsubToken) { - websocketURL += `?pubsub_token=${encodeURIComponent(pubsubToken)}`; + if (Cookies.get('cw_d_session_state')) { + try { + const response = await window.axios.post('/api/v1/auth/ws_ticket'); + const ticket = response.data?.data?.ticket; + if (!ticket) throw new Error('missing websocket ticket'); + websocketURL += `?ticket=${encodeURIComponent(ticket)}`; + this.usesWSTicket = true; + } catch (error) { + this.initReconnectTimer(); + return; + } + } else if (this.pubsubToken) { + websocketURL += `?pubsub_token=${encodeURIComponent(this.pubsubToken)}`; + this.usesWSTicket = false; } this.consumer = createConsumer(websocketURL); this.subscription = this.consumer.subscriptions.create( { channel: 'RoomChannel', - pubsub_token: pubsubToken, - account_id: app.$store.getters.getCurrentAccountId, - user_id: app.$store.getters.getCurrentUserID, + pubsub_token: this.pubsubToken, + account_id: this.app.$store.getters.getCurrentAccountId, + user_id: this.app.$store.getters.getCurrentUserID, }, { updatePresence() { @@ -49,25 +65,23 @@ class BaseActionCableConnector { received: this.onReceived, disconnected: () => { BaseActionCableConnector.isDisconnected = true; + if (this.usesWSTicket) { + this.consumer?.disconnect(); + this.consumer = null; + this.subscription = null; + } this.onDisconnected(); this.initReconnectTimer(); }, } ); - this.app = app; - this.events = {}; - this.reconnectTimer = null; - this.isAValidEvent = () => true; - this.triggerPresenceInterval = () => { - setTimeout(() => { - this.subscription.updatePresence(); - this.triggerPresenceInterval(); - }, presenceInterval); - }; - this.triggerPresenceInterval(); } checkConnection() { + if (!this.consumer) { + this.connect(); + return; + } const isConnectionActive = this.consumer.connection.isOpen(); const isReconnected = BaseActionCableConnector.isDisconnected && isConnectionActive; @@ -101,7 +115,7 @@ class BaseActionCableConnector { onDisconnected = () => {}; disconnect() { - this.consumer.disconnect(); + this.consumer?.disconnect(); } onReceived = ({ event, data } = {}) => { diff --git a/frontend/app/javascript/shared/helpers/specs/BaseActionCableConnector.spec.js b/frontend/app/javascript/shared/helpers/specs/BaseActionCableConnector.spec.js index da41e50d..efd4dbce 100644 --- a/frontend/app/javascript/shared/helpers/specs/BaseActionCableConnector.spec.js +++ b/frontend/app/javascript/shared/helpers/specs/BaseActionCableConnector.spec.js @@ -1,25 +1,47 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { createConsumer, createSubscription, getCookie, subscriptionState } = - vi.hoisted(() => { - const subscriptionState = { callbacks: null }; - return { - createConsumer: vi.fn(), - createSubscription: vi.fn((_identifier, callbacks) => { - subscriptionState.callbacks = callbacks; - return { updatePresence: vi.fn() }; - }), - getCookie: vi.fn(), - subscriptionState, - }; - }); +const { + createConsumer, + createSubscription, + getCookie, + post, + subscriptionState, +} = vi.hoisted(() => { + const subscriptionState = { callbacks: null }; + return { + createConsumer: vi.fn(), + createSubscription: vi.fn((_identifier, callbacks) => { + subscriptionState.callbacks = callbacks; + return { updatePresence: vi.fn() }; + }), + getCookie: vi.fn(), + post: vi.fn(), + subscriptionState, + }; +}); vi.mock('@rails/actioncable', () => ({ createConsumer })); vi.mock('js-cookie', () => ({ default: { get: getCookie } })); import BaseActionCableConnector from '../BaseActionCableConnector'; +const app = { + $store: { + getters: { getCurrentAccountId: 3, getCurrentUserID: 7 }, + }, +}; + describe('BaseActionCableConnector', () => { + beforeEach(() => { + vi.useFakeTimers(); + createConsumer.mockReturnValue({ + connection: { isOpen: vi.fn(() => true) }, + disconnect: vi.fn(), + subscriptions: { create: createSubscription }, + }); + window.axios = { post }; + }); + afterEach(() => { vi.clearAllMocks(); vi.useRealTimers(); @@ -28,16 +50,9 @@ describe('BaseActionCableConnector', () => { }); it('authenticates a widget socket and RoomChannel with its pubsub token', () => { - vi.useFakeTimers(); getCookie.mockReturnValue(undefined); - createConsumer.mockReturnValue({ - subscriptions: { create: createSubscription }, - }); - new BaseActionCableConnector( - { $store: { getters: {} } }, - 'visitor token' - ); + new BaseActionCableConnector(app, 'visitor token'); expect(createConsumer).toHaveBeenCalledWith( `${window.location.origin}/cable?pubsub_token=visitor%20token` @@ -51,18 +66,9 @@ describe('BaseActionCableConnector', () => { ); }); - it('continues consuming widget events after ActionCable reconnects', () => { - vi.useFakeTimers(); + it('continues consuming widget events after ActionCable reconnects', async () => { getCookie.mockReturnValue(undefined); - const isOpen = vi.fn(() => true); - createConsumer.mockReturnValue({ - connection: { isOpen }, - subscriptions: { create: createSubscription }, - }); - const connector = new BaseActionCableConnector( - { $store: { getters: {} } }, - 'visitor-token' - ); + const connector = new BaseActionCableConnector(app, 'visitor-token'); const onMessage = vi.fn(); connector.events['message.created'] = onMessage; connector.onDisconnected = vi.fn(); @@ -73,7 +79,7 @@ describe('BaseActionCableConnector', () => { data: { id: 1, content: 'before reconnect' }, }); subscriptionState.callbacks.disconnected(); - vi.advanceTimersByTime(1000); + await vi.advanceTimersByTimeAsync(1000); subscriptionState.callbacks.received({ event: 'message.created', data: { id: 2, content: 'after reconnect' }, @@ -90,4 +96,51 @@ describe('BaseActionCableConnector', () => { content: 'after reconnect', }); }); + + it('exchanges the dashboard session for a ticket without putting JWT in the URL', async () => { + getCookie.mockReturnValue('1'); + post.mockResolvedValue({ data: { data: { ticket: 'one-time' } } }); + + const connector = new BaseActionCableConnector( + app, + 'pubsub', + 'wss://chat.test' + ); + await vi.waitFor(() => expect(createConsumer).toHaveBeenCalledOnce()); + + expect(post).toHaveBeenCalledWith('/api/v1/auth/ws_ticket'); + expect(createConsumer).toHaveBeenCalledWith( + 'wss://chat.test/cable?ticket=one-time' + ); + expect(createConsumer.mock.calls[0][0]).not.toContain('access-token'); + connector.disconnect(); + }); + + it('gets a fresh ticket after a dashboard disconnect', async () => { + getCookie.mockReturnValue('1'); + post + .mockResolvedValueOnce({ data: { data: { ticket: 'first' } } }) + .mockResolvedValueOnce({ data: { data: { ticket: 'second' } } }); + + const connector = new BaseActionCableConnector(app, 'pubsub'); + await vi.waitFor(() => expect(createConsumer).toHaveBeenCalledOnce()); + subscriptionState.callbacks.disconnected(); + await vi.advanceTimersByTimeAsync(1000); + await vi.waitFor(() => expect(createConsumer).toHaveBeenCalledTimes(2)); + + expect(createConsumer).toHaveBeenLastCalledWith( + `${window.location.origin}/cable?ticket=second` + ); + connector.disconnect(); + }); + + it('does not open a dashboard socket when ticket exchange returns 401', async () => { + getCookie.mockReturnValue('1'); + post.mockRejectedValue({ response: { status: 401 } }); + + new BaseActionCableConnector(app, 'pubsub'); + await vi.waitFor(() => expect(post).toHaveBeenCalledOnce()); + + expect(createConsumer).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/app/javascript/v3/api/apiClient.js b/frontend/app/javascript/v3/api/apiClient.js index f8fdb5f5..cdc3ae35 100644 --- a/frontend/app/javascript/v3/api/apiClient.js +++ b/frontend/app/javascript/v3/api/apiClient.js @@ -1,6 +1,6 @@ import axios from 'axios'; const { apiHost = '' } = window.chatwootConfig || {}; -const wootAPI = axios.create({ baseURL: `${apiHost}/` }); +const wootAPI = axios.create({ baseURL: `${apiHost}/`, withCredentials: true }); export default wootAPI; diff --git a/frontend/app/javascript/v3/helpers/AuthHelper.js b/frontend/app/javascript/v3/helpers/AuthHelper.js index c2afec98..5093c33f 100644 --- a/frontend/app/javascript/v3/helpers/AuthHelper.js +++ b/frontend/app/javascript/v3/helpers/AuthHelper.js @@ -3,7 +3,7 @@ import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals'; import { frontendURL } from 'dashboard/helper/URLHelper'; export const hasAuthCookie = () => { - return !!Cookies.get('cw_d_session_info'); + return !!Cookies.get('cw_d_session_state'); }; const getSSOAccountPath = ({ ssoAccountId, user }) => { diff --git a/frontend/super_admin.html b/frontend/super_admin.html index 186e109f..8a9a272b 100644 --- a/frontend/super_admin.html +++ b/frontend/super_admin.html @@ -45,29 +45,6 @@ ); window.errorLoggingConfig = ''; window.browserConfig = { browser_name: navigator.userAgent }; - - // sync auth tokens from cookie to localStorage - (function() { - try { - var cookies = document.cookie.split('; '); - var sessionCookie = cookies.find(function(c) { return c.startsWith('cw_d_session_info='); }); - if (sessionCookie) { - var raw = decodeURIComponent(sessionCookie.split('=').slice(1).join('=')); - var extract = function(key) { - var match = raw.match(new RegExp('"' + key + '":"([^"]+)"')); - return match ? match[1] : ''; - }; - var token = extract('access-token'); - if (token) { - localStorage.setItem('access-token', token); - localStorage.setItem('client', extract('client')); - localStorage.setItem('uid', extract('uid')); - localStorage.setItem('token-type', extract('token-type') || 'Bearer'); - localStorage.setItem('expiry', extract('expiry')); - } - } - } catch(e) { /* ignore */ } - })(); })();