From 2b645fa4e8c010e367f97018af7962cb43fe1afd Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sat, 28 Mar 2026 19:27:41 +0530 Subject: [PATCH] Security fixes: rate-limit widget endpoints, remove JWT from logs, harden middleware checks Increase max page size to 500 Allow large out of bound page sizes by overriding to maximum set. Reset websocket reconnect attempts when widget is back online --- cmd/contacts.go | 10 +++++----- cmd/csat.go | 5 +++++ cmd/handlers.go | 19 +++++++++++++------ cmd/widget_middleware.go | 4 ++-- frontend/apps/widget/src/websocket.js | 1 + internal/conversation/conversation.go | 4 ++-- internal/conversation/message.go | 2 +- internal/inbox/channel/email/imap.go | 2 +- internal/inbox/inbox.go | 2 +- internal/user/contact.go | 6 +----- internal/user/user.go | 7 ++++++- 11 files changed, 38 insertions(+), 24 deletions(-) diff --git a/cmd/contacts.go b/cmd/contacts.go index 8c28a16b..4a2c237e 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -57,7 +57,7 @@ func handleGetContact(r *fastglue.Request) error { if id <= 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) } - c, err := app.user.GetContact(id, "") + c, err := app.user.GetContactOrVisitor(id, "") if err != nil { return sendErrorEnvelope(r, err) } @@ -74,7 +74,7 @@ func handleUpdateContact(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) } - contact, err := app.user.GetContact(id, "") + contact, err := app.user.GetContactOrVisitor(id, "") if err != nil { return sendErrorEnvelope(r, err) } @@ -171,7 +171,7 @@ func handleUpdateContact(r *fastglue.Request) error { } // Refetch contact and return it - contact, err = app.user.GetContact(id, "") + contact, err = app.user.GetContactOrVisitor(id, "") if err != nil { return sendErrorEnvelope(r, err) } @@ -277,7 +277,7 @@ func handleBlockContact(r *fastglue.Request) error { app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID) - contact, err := app.user.GetContact(contactID, "") + contact, err := app.user.GetContactOrVisitor(contactID, "") if err != nil { return sendErrorEnvelope(r, err) } @@ -286,7 +286,7 @@ func handleBlockContact(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - contact, err = app.user.GetContact(contactID, "") + contact, err = app.user.GetContactOrVisitor(contactID, "") if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/csat.go b/cmd/csat.go index 7042cfbc..739b4d3f 100644 --- a/cmd/csat.go +++ b/cmd/csat.go @@ -144,6 +144,11 @@ func handleSubmitCSATResponse(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError) } + // Trim feedback if it exceeds max length. + if len(req.Feedback) > maxCsatFeedbackLength { + req.Feedback = req.Feedback[:maxCsatFeedbackLength] + } + // Update CSAT response if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback); err != nil { return sendErrorEnvelope(r, err) diff --git a/cmd/handlers.go b/cmd/handlers.go index 7c31bcec..d743a2e9 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -17,6 +17,8 @@ import ( "github.com/zerodha/fastglue" ) +const maxPageSize = 500 + // initHandlers initializes the HTTP routes and handlers for the application. func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Authentication. @@ -258,11 +260,11 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { })) // Live chat widget websocket. - g.GET("/widget/ws", handleWidgetWS) + g.GET("/widget/ws", rateLimit(handleWidgetWS, "widget")) // Widget APIs. - g.GET("/api/v1/widget/chat/settings/launcher", handleGetChatLauncherSettings) - g.GET("/api/v1/widget/chat/settings", handleGetChatSettings) + g.GET("/api/v1/widget/chat/settings/launcher", rateLimit(handleGetChatLauncherSettings, "widget")) + g.GET("/api/v1/widget/chat/settings", rateLimit(handleGetChatSettings, "widget")) g.POST("/api/v1/widget/chat/conversations/init", rateLimit(widgetAuth(handleChatInit), "widget")) g.GET("/api/v1/widget/chat/conversations", rateLimit(widgetAuth(handleGetConversations), "widget")) g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", rateLimit(widgetAuth(handleChatUpdateLastSeen), "widget")) @@ -363,7 +365,7 @@ func validateWidgetReferer(app *App, r *fastglue.Request, inboxID int) error { "referer", referer, "inbox_id", inboxID, "trusted_domains", config.TrustedDomains) - return r.SendErrorEnvelope(http.StatusForbidden, "Widget not allowed from this origin: "+referer, nil, envelope.PermissionError) + return r.SendErrorEnvelope(http.StatusForbidden, "Widget not allowed from this origin", nil, envelope.PermissionError) } app.lo.Debug("widget request from trusted referer allowed", "referer", referer, "inbox_id", inboxID) return nil @@ -373,8 +375,11 @@ func validateWidgetReferer(app *App, r *fastglue.Request, inboxID int) error { func serveWidgetIndexPage(r *fastglue.Request) error { app := r.Context.(*App) - // Extract inbox ID and validate trusted domains if present + // Extract and validate inbox ID. inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id") + if inboxID <= 0 { + return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "inbox_id"), nil, envelope.InputError) + } if err := validateWidgetReferer(app, r, inboxID); err != nil { return err } @@ -488,7 +493,6 @@ func serveWidgetJS(r *fastglue.Request) error { } // getPagination extracts page and page_size from query params with defaults. -// Defaults: page=1, pageSize=30 func getPagination(r *fastglue.Request) (page, pageSize int) { page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) @@ -498,6 +502,9 @@ func getPagination(r *fastglue.Request) (page, pageSize int) { if pageSize < 1 { pageSize = 30 } + if pageSize > maxPageSize { + pageSize = maxPageSize + } return page, pageSize } diff --git a/cmd/widget_middleware.go b/cmd/widget_middleware.go index 7caf9f4f..30dfd481 100644 --- a/cmd/widget_middleware.go +++ b/cmd/widget_middleware.go @@ -80,7 +80,7 @@ func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) erro authHeader := string(r.RequestCtx.Request.Header.Peek("Authorization")) // For init endpoint, allow requests without JWT (visitor creation) - if authHeader == "" && strings.Contains(string(r.RequestCtx.Path()), "/conversations/init") { + if authHeader == "" && strings.HasSuffix(string(r.RequestCtx.Path()), "/conversations/init") { return next(r) } @@ -93,7 +93,7 @@ func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) erro // Verify JWT using inbox secret claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String) if err != nil { - app.lo.Error("invalid JWT", "jwt", jwtToken, "error", err) + app.lo.Error("invalid widget JWT", "error", err) return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) } diff --git a/frontend/apps/widget/src/websocket.js b/frontend/apps/widget/src/websocket.js index 94c04777..f4ebc1b5 100644 --- a/frontend/apps/widget/src/websocket.js +++ b/frontend/apps/widget/src/websocket.js @@ -158,6 +158,7 @@ export class WidgetWebSocketClient { setupNetworkListeners () { window.addEventListener('online', () => { if (this.socket?.readyState !== WebSocket.OPEN) { + this.reconnectAttempts = 0 this.reconnectInterval = 1000 this.reconnect() } diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 75b6e9b3..78d122b8 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -54,7 +54,7 @@ var ( ) const ( - conversationsListMaxPageSize = 100 + conversationsListMaxPageSize = 500 ) // Manager handles the operations related to conversations @@ -1469,7 +1469,7 @@ func (c *Manager) makeConversationsListQuery(viewingUserID, userID int, teamIDs // Validate inputs if pageSize > conversationsListMaxPageSize { - return "", nil, fmt.Errorf("invalid page size: must be between 1 and %d", conversationsListMaxPageSize) + pageSize = conversationsListMaxPageSize } if len(listTypes) == 0 { diff --git a/internal/conversation/message.go b/internal/conversation/message.go index c0f4e204..b6cf8f2c 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -29,7 +29,7 @@ import ( ) const ( - maxMessagesPerPage = 100 + maxMessagesPerPage = 500 ) // Run starts a pool of worker goroutines to handle message dispatching via inbox's channel and processes incoming messages. It scans for diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go index 1e4ffa23..2d6e02c2 100644 --- a/internal/inbox/channel/email/imap.go +++ b/internal/inbox/channel/email/imap.go @@ -343,7 +343,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, } // Check if contact with this email is blocked / disabed, if so, ignore the message. - if contact, err := e.userStore.GetContact(0, fromAddress); err != nil { + if contact, err := e.userStore.GetContactOrVisitor(0, fromAddress); err != nil { envErr, ok := err.(envelope.Error) if !ok || envErr.ErrorType != envelope.NotFoundError { e.lo.Error("error checking if user is blocked", "email", fromAddress, "error", err) diff --git a/internal/inbox/inbox.go b/internal/inbox/inbox.go index d39e870e..972b24df 100644 --- a/internal/inbox/inbox.go +++ b/internal/inbox/inbox.go @@ -73,7 +73,7 @@ type MessageStore interface { // UserStore defines methods for fetching user information. type UserStore interface { - GetContact(id int, email string) (umodels.User, error) + GetContactOrVisitor(id int, email string) (umodels.User, error) GetAgent(id int, email string) (umodels.User, error) } diff --git a/internal/user/contact.go b/internal/user/contact.go index 1050315f..d1cde684 100644 --- a/internal/user/contact.go +++ b/internal/user/contact.go @@ -74,14 +74,10 @@ func (u *Manager) UpdateContact(id int, user models.User) error { return nil } -func (u *Manager) GetContact(id int, email string) (models.User, error) { - return u.Get(id, email, []string{models.UserTypeContact, models.UserTypeVisitor}) -} - // GetAllContacts returns a list of all contacts. func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) { if pageSize > maxListPageSize { - return nil, envelope.NewError(envelope.InputError, u.i18n.Ts("globals.messages.pageTooLarge", "max", fmt.Sprintf("%d", maxListPageSize)), nil) + pageSize = maxListPageSize } if page < 1 { page = 1 diff --git a/internal/user/user.go b/internal/user/user.go index 7ac6a699..38c1f76b 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -34,7 +34,7 @@ var ( minPassword = 10 maxPassword = 72 - maxListPageSize = 100 + maxListPageSize = 500 // ErrPasswordTooLong is returned when the password passed to // GenerateFromPassword is too long (i.e. > 72 bytes). @@ -175,6 +175,11 @@ func (u *Manager) Get(id int, email string, userType []string) (models.User, err return user, nil } +// GetContactOrVisitor retrieves a user by ID or email that is either a contact or visitor. +func (u *Manager) GetContactOrVisitor(id int, email string) (models.User, error) { + return u.Get(id, email, []string{models.UserTypeContact, models.UserTypeVisitor}) +} + // GetSystemUser retrieves the system user. func (u *Manager) GetSystemUser() (models.User, error) { return u.Get(0, models.SystemUserEmail, []string{models.UserTypeAgent})