DRY up widget inbox validation, use validateWidgetInbox middleware

This commit is contained in:
Abhinav Raut
2026-04-15 00:11:19 +05:30
parent 567e6cd683
commit 99daf4e8da
4 changed files with 20 additions and 107 deletions
+7 -81
View File
@@ -19,7 +19,6 @@ import (
bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/httputil"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
@@ -97,9 +96,9 @@ type conversationResponseWithBusinessHours struct {
// handleGetChatLauncherSettings returns the live chat launcher settings for the widget.
func handleGetChatLauncherSettings(r *fastglue.Request) error {
r.RequestCtx.Response.Header.Set("Access-Control-Allow-Origin", "*")
_, config, err := validateLiveChatInbox(r)
config, err := getWidgetConfig(r)
if err != nil {
return sendErrorEnvelope(r, err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, err.Error(), nil))
}
return r.SendEnvelope(map[string]any{
@@ -112,9 +111,9 @@ func handleGetChatLauncherSettings(r *fastglue.Request) error {
func handleGetChatSettings(r *fastglue.Request) error {
app := r.Context.(*App)
_, config, err := validateLiveChatInbox(r)
config, err := getWidgetConfig(r)
if err != nil {
return sendErrorEnvelope(r, err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, err.Error(), nil))
}
response := chatSettingsResponse{
@@ -711,14 +710,6 @@ func getContactConversation(r *fastglue.Request, conversationUUID string) (int,
return contactID, conversation, nil
}
func parseLiveChatConfig(inbox imodels.Inbox) (livechat.Config, error) {
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
return livechat.Config{}, fmt.Errorf("parsing live chat config: %w", err)
}
return config, nil
}
func userTypeLabel(isVisitor bool) string {
if isVisitor {
return "visitor"
@@ -726,67 +717,6 @@ func userTypeLabel(isVisitor bool) string {
return "user"
}
// validateLiveChatInbox validates inbox_id from query params and returns the inbox and parsed config.
// Used by public widget endpoints that don't require JWT authentication.
func validateLiveChatInbox(r *fastglue.Request) (imodels.Inbox, livechat.Config, error) {
app := r.Context.(*App)
inboxUUID := string(r.RequestCtx.QueryArgs().Peek("inbox_id"))
if inboxUUID == "" {
return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.InputError,
app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
inbox, err := app.inbox.GetDBRecord(inboxUUID)
if err != nil {
app.lo.Error("error fetching inbox", "inbox_uuid", inboxUUID, "error", err)
return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.GeneralError,
app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
if inbox.Channel != livechat.ChannelLiveChat {
return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.InputError,
app.i18n.T("validation.notFoundInbox"), nil)
}
if !inbox.Enabled {
return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.InputError,
app.i18n.T("status.disabledInbox"), nil)
}
config, err := parseLiveChatConfig(inbox)
if err != nil {
app.lo.Error("error parsing live chat config", "error", err)
return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.GeneralError,
app.i18n.T("validation.invalidInbox"), nil)
}
// Check if the client's IP is blocked.
if len(config.BlockedIPs) > 0 {
clientIP := realip.FromRequest(r.RequestCtx)
if httputil.IsIPBlocked(clientIP, config.BlockedIPs) {
app.lo.Info("client IP blocked for live chat inbox", "client_id", clientIP, "inbox_uuid", inboxUUID)
return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.PermissionError,
app.i18n.T("widget.ipBlocked"), nil)
}
}
// Validate referer against trusted domains.
if len(config.TrustedDomains) > 0 {
referer := string(r.RequestCtx.Request.Header.Peek("Referer"))
if referer != "" && !httputil.IsOriginTrusted(referer, config.TrustedDomains) {
app.lo.Warn("widget request from untrusted referer blocked",
"referer", referer,
"inbox_uuid", inboxUUID,
"trusted_domains", config.TrustedDomains)
return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.PermissionError,
app.i18n.T("widget.ipBlocked"), nil)
}
}
return inbox, config, nil
}
// saveContactAttrsAndCollectConvoAttrs validates and saves contact custom attributes from JWT and form data.
// Returns conversation custom attributes from the pre-chat form for the caller to apply after conversation creation.
func saveContactAttrsAndCollectConvoAttrs(app *App, contactID int, claims *Claims, formData map[string]any, config livechat.Config) map[string]any {
@@ -928,8 +858,8 @@ func buildConversationResponseWithBusinessHours(app *App, conversation cmodels.C
return resp, nil
}
// resolveUserIDFromClaims resolves the actual user ID from JWT claims,
// handling both regular user_id and external_user_id cases
// resolveUserFromClaims resolves the actual user from JWT claims,
// handling both regular user_id and external_user_id cases.
func resolveUserFromClaims(app *App, claims Claims) (umodels.User, error) {
var (
user umodels.User
@@ -955,22 +885,18 @@ func resolveUserFromClaims(app *App, claims Claims) (umodels.User, error) {
return user, nil
}
// verifyJWT verifies and validates a JWT token with proper signature verification
// verifyJWT verifies and validates a JWT token with proper signature verification.
func verifyJWT(tokenString string, secretKey []byte) (*Claims, error) {
// Parse and verify the token
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Verify the signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return secretKey, nil
})
if err != nil {
return nil, err
}
// Extract claims if token is valid
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
+4 -9
View File
@@ -272,9 +272,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/widget/ws", rateLimit(handleWidgetWS, "widget"))
// Widget APIs.
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/auth/exchange", rateLimit(widgetInboxAuth(handleAuthExchange), "widget"))
g.GET("/api/v1/widget/chat/settings/launcher", rateLimit(validateWidgetInbox(handleGetChatLauncherSettings), "widget"))
g.GET("/api/v1/widget/chat/settings", rateLimit(validateWidgetInbox(handleGetChatSettings), "widget"))
g.POST("/api/v1/widget/chat/auth/exchange", rateLimit(validateWidgetInbox(handleAuthExchange), "widget"))
g.GET("/api/v1/widget/chat/auth/me", rateLimit(widgetAuth(handleWidgetAuthMe), "widget"))
g.POST("/api/v1/widget/chat/conversations/init", rateLimit(widgetAuth(handleChatInit), "widget"))
g.GET("/api/v1/widget/chat/conversations", rateLimit(widgetAuth(handleGetConversations), "widget"))
@@ -285,7 +285,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Frontend pages.
g.GET("/", notAuthPage(serveIndexPage))
g.GET("/widget", serveWidgetIndexPage)
g.GET("/widget", validateWidgetInbox(serveWidgetIndexPage))
g.GET("/inboxes/{all:*}", authPage(serveIndexPage))
g.GET("/teams/{all:*}", authPage(serveIndexPage))
g.GET("/views/{all:*}", authPage(serveIndexPage))
@@ -342,11 +342,6 @@ func serveIndexPage(r *fastglue.Request) error {
func serveWidgetIndexPage(r *fastglue.Request) error {
app := r.Context.(*App)
// Validate inbox and referer/IP.
if _, _, err := validateLiveChatInbox(r); err != nil {
return sendErrorEnvelope(r, err)
}
// Prevent caching of the index page.
r.RequestCtx.Response.Header.Add("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
r.RequestCtx.Response.Header.Add("Pragma", "no-cache")
+8 -5
View File
@@ -25,13 +25,16 @@ const (
hdrClearVisitorToken = "X-Libredesk-Clear-Visitor"
)
// widgetInboxAuth middleware validates the inbox from the request header, checks IP/domain
// restrictions, and sets inbox + config in context. Does not require session token.
func widgetInboxAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error {
// validateWidgetInbox middleware validates the inbox from the request header or query param,
// checks IP/domain restrictions, and sets inbox + config in context.
func validateWidgetInbox(next func(*fastglue.Request) error) func(*fastglue.Request) error {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
inboxUUID := string(r.RequestCtx.Request.Header.Peek(hdrWidgetInboxID))
if inboxUUID == "" {
inboxUUID = string(r.RequestCtx.QueryArgs().Peek("inbox_id"))
}
if inboxUUID == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
@@ -79,10 +82,10 @@ func widgetInboxAuth(next func(*fastglue.Request) error) func(*fastglue.Request)
}
// widgetAuth middleware validates the session token from the Authorization header
// using Redis lookup. Wraps widgetInboxAuth for inbox validation.
// using Redis lookup. Wraps validateWidgetInbox for inbox validation.
// For /conversations/init without a token, allows visitor creation.
func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error {
return widgetInboxAuth(func(r *fastglue.Request) error {
return validateWidgetInbox(func(r *fastglue.Request) error {
app := r.Context.(*App)
inbox, err := getWidgetInbox(r)
if err != nil {
+1 -12
View File
@@ -138,15 +138,4 @@ batch_check_interval = "5m"
[sla]
# How often to evaluate SLA compliance for conversations
evaluation_interval = "5m"
[rate_limit]
[rate_limit.widget]
enabled = true
requests_per_minute = 100
[rate_limit.auth]
enabled = true
requests_per_minute = 20
[rate_limit.public]
enabled = true
requests_per_minute = 60
evaluation_interval = "5m"