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 <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-22 15:45:06 +08:00
committed by GitHub
co-authored by rogee
parent e1557e7f21
commit f719529d66
81 changed files with 2149 additions and 474 deletions
@@ -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
+137 -23
View File
@@ -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
@@ -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{
@@ -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",
@@ -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")
@@ -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),
@@ -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":"***"`)
}
@@ -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}