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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user