From 783d5c147bf6a1dc7d1e30ca3049f529ed5ff6e2 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Fri, 1 May 2026 09:06:32 +0300 Subject: [PATCH] feat: Add XSS protection and HATEOAS links to subscriber and auth handlers with cursor pagination - Add bluemonday import to auth.go and subscriber.go - Add UGCPolicy sanitization for username, email, firstName, lastName, and role in Register - Add conditional XSS sanitization for firstName, lastName, email, and MSISDN in CreateSubscriber and UpdateSubscriber - Replace Page and PageSize with Cursor and Limit in ListSubscribers query parameters - Add HATEOAS links to CreateSubscriber, GetSubscriber --- apps/api-server/internal/handlers/auth.go | 9 ++ .../internal/handlers/subscriber.go | 97 +++++++++++++++++-- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/apps/api-server/internal/handlers/auth.go b/apps/api-server/internal/handlers/auth.go index 94b7b5d..ead2879 100644 --- a/apps/api-server/internal/handlers/auth.go +++ b/apps/api-server/internal/handlers/auth.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/microcosm-cc/bluemonday" "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" "github.com/nutcas3/telecom-platform/apps/api-server/internal/services" ) @@ -82,6 +83,14 @@ func (h *AuthHandler) Register(c *gin.Context) { return } + // Sanitize user input to prevent XSS + policy := bluemonday.UGCPolicy() + req.Username = policy.Sanitize(req.Username) + req.Email = policy.Sanitize(req.Email) + req.FirstName = policy.Sanitize(req.FirstName) + req.LastName = policy.Sanitize(req.LastName) + req.Role = policy.Sanitize(req.Role) + user, err := h.authService.Register(req.Username, req.Email, req.Password, req.FirstName, req.LastName, req.Role) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/apps/api-server/internal/handlers/subscriber.go b/apps/api-server/internal/handlers/subscriber.go index 5f346d9..caaca2d 100644 --- a/apps/api-server/internal/handlers/subscriber.go +++ b/apps/api-server/internal/handlers/subscriber.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/gin-gonic/gin" + "github.com/microcosm-cc/bluemonday" "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" "github.com/nutcas3/telecom-platform/apps/api-server/internal/services" ) @@ -43,6 +44,21 @@ func (h *SubscriberHandler) CreateSubscriber(c *gin.Context) { return } + // Sanitize user input to prevent XSS + policy := bluemonday.UGCPolicy() + if req.FirstName != "" { + req.FirstName = policy.Sanitize(req.FirstName) + } + if req.LastName != "" { + req.LastName = policy.Sanitize(req.LastName) + } + if req.Email != "" { + req.Email = policy.Sanitize(req.Email) + } + if req.MSISDN != "" { + req.MSISDN = policy.Sanitize(req.MSISDN) + } + subscriber, err := h.subscriberService.CreateSubscriber(c.Request.Context(), &req) if err != nil { c.JSON(http.StatusInternalServerError, ErrorResponse{ @@ -52,7 +68,20 @@ func (h *SubscriberHandler) CreateSubscriber(c *gin.Context) { return } - c.JSON(http.StatusCreated, subscriber) + // Add HATEOAS links + baseURL := c.Request.Host + if c.Request.TLS != nil { + baseURL = "https://" + baseURL + } else { + baseURL = "http://" + baseURL + } + + subscriberWithLinks := models.SubscriberWithLinks{ + Subscriber: *subscriber, + Links: models.NewSubscriberLinks(baseURL, subscriber), + } + + c.JSON(http.StatusCreated, subscriberWithLinks) } // GetSubscriber retrieves a subscriber by ID @@ -85,7 +114,20 @@ func (h *SubscriberHandler) GetSubscriber(c *gin.Context) { return } - c.JSON(http.StatusOK, subscriber) + // Add HATEOAS links + baseURL := c.Request.Host + if c.Request.TLS != nil { + baseURL = "https://" + baseURL + } else { + baseURL = "http://" + baseURL + } + + subscriberWithLinks := models.SubscriberWithLinks{ + Subscriber: *subscriber, + Links: models.NewSubscriberLinks(baseURL, subscriber), + } + + c.JSON(http.StatusOK, subscriberWithLinks) } // GetSubscriberByIMSI retrieves a subscriber by IMSI @@ -146,6 +188,21 @@ func (h *SubscriberHandler) UpdateSubscriber(c *gin.Context) { return } + // Sanitize user input to prevent XSS + policy := bluemonday.UGCPolicy() + if req.FirstName != nil && *req.FirstName != "" { + sanitized := policy.Sanitize(*req.FirstName) + req.FirstName = &sanitized + } + if req.LastName != nil && *req.LastName != "" { + sanitized := policy.Sanitize(*req.LastName) + req.LastName = &sanitized + } + if req.Email != nil && *req.Email != "" { + sanitized := policy.Sanitize(*req.Email) + req.Email = &sanitized + } + subscriber, err := h.subscriberService.UpdateSubscriber(c.Request.Context(), uint(id), &req) if err != nil { c.JSON(http.StatusInternalServerError, ErrorResponse{ @@ -172,16 +229,15 @@ func (h *SubscriberHandler) UpdateSubscriber(c *gin.Context) { // @Failure 500 {object} ErrorResponse // @Router /api/v1/subscribers [get] func (h *SubscriberHandler) ListSubscribers(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - if pageSize > 100 { - pageSize = 100 + if limit > 100 { + limit = 100 } req := &services.ListSubscribersRequest{ - Page: page, - PageSize: pageSize, + Cursor: c.Query("cursor"), + Limit: limit, Status: models.SubscriberStatus(c.Query("status")), OrganizationID: c.Query("organization_id"), Search: c.Query("search"), @@ -196,7 +252,30 @@ func (h *SubscriberHandler) ListSubscribers(c *gin.Context) { return } - c.JSON(http.StatusOK, response) + // Add HATEOAS links + baseURL := c.Request.Host + if c.Request.TLS != nil { + baseURL = "https://" + baseURL + } else { + baseURL = "http://" + baseURL + } + + subscribersWithLinks := make([]models.SubscriberWithLinks, len(response.Subscribers)) + for i, subscriber := range response.Subscribers { + subscribersWithLinks[i] = models.SubscriberWithLinks{ + Subscriber: subscriber, + Links: models.NewSubscriberLinks(baseURL, &subscriber), + } + } + + listResponse := models.SubscriberListWithLinks{ + Subscribers: subscribersWithLinks, + NextCursor: response.NextCursor, + HasMore: response.HasMore, + Links: models.NewSubscriberListLinks(baseURL, response.NextCursor, req.Limit, response.HasMore), + } + + c.JSON(http.StatusOK, listResponse) } // SuspendSubscriber suspends a subscriber