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
This commit is contained in:
Abhinav Raut
2026-03-28 19:27:41 +05:30
parent 6a7ecf6b8a
commit 2b645fa4e8
11 changed files with 38 additions and 24 deletions
+5 -5
View File
@@ -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)
}
+5
View File
@@ -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)
+13 -6
View File
@@ -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
}
+2 -2
View File
@@ -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)
}
+1
View File
@@ -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()
}
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+1 -5
View File
@@ -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
+6 -1
View File
@@ -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})