From 7fb182f1801972095ea6c617d2b690758f91b1de Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sun, 7 Dec 2025 23:52:17 +0330 Subject: [PATCH] xss validation --- cmd/web/main.go | 10 +- cmd/web/router/routes.go | 21 ++++- go.mod | 3 + go.sum | 6 ++ internal/inquiry/email_template.go | 24 +++-- internal/inquiry/form.go | 76 ++++++++++++++- internal/inquiry/handler.go | 26 +++--- internal/inquiry/service.go | 9 +- pkg/security/csp.go | 144 +++++++++++++++++++++++++++++ pkg/security/xss.go | 82 ++++++++++++++++ 10 files changed, 372 insertions(+), 29 deletions(-) create mode 100644 pkg/security/csp.go create mode 100644 pkg/security/xss.go diff --git a/cmd/web/main.go b/cmd/web/main.go index f5b7db2..c5e46ee 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -107,6 +107,7 @@ import ( "tm/internal/tender" "tm/internal/tender_approval" "tm/internal/user" + "tm/pkg/security" "tm/cmd/web/bootstrap" _ "tm/cmd/web/docs" // This is generated by swag @@ -143,6 +144,7 @@ func main() { // Initialize notification service notificationSDK := bootstrap.InitNotificationService(conf.Notification, logger) + xssPolicy := security.NewXSSPolicy() // Initialize authorization service userAuthService := bootstrap.InitAuthorizationService(conf.UserAuth.JWT, redisClient, logger) @@ -175,7 +177,7 @@ func main() { tenderService := tender.NewService(tenderRepository, companyService, logger) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger) - inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger) + inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy) flagService := assets.NewService(logger, conf.Assets.FlagsPath) notificationService := notification.NewService(notificationSDK, userService, customerService, logger) contactService := contact.NewService(contactRepository, logger) @@ -193,7 +195,7 @@ func main() { tenderHandler := tender.NewHandler(tenderService, logger) feedbackHandler := feedback.NewHandler(feedbackService, userHandler, customerHandler, logger) tenderApprovalHandler := tender_approval.NewHandler(tenderApprovalService, logger) - inquiryHandler := inquiry.NewHandler(inquiryService, logger) + inquiryHandler := inquiry.NewHandler(inquiryService, logger, xssPolicy) flagHandler := assets.NewHandler(flagService, logger) notificationHandler := notification.NewHandler(notificationService) contactHandler := contact.NewHandler(contactService) @@ -207,8 +209,8 @@ func main() { e := bootstrap.InitHTTPServer(conf, logger) // Register routes - router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, scraperHandler) - router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler) + router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, scraperHandler, xssPolicy) + router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, xssPolicy) // Start server serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port) diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index c584990..aa59c12 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -14,13 +14,23 @@ import ( "tm/internal/tender" "tm/internal/tender_approval" "tm/internal/user" + "tm/pkg/security" "github.com/labstack/echo/v4" ) -func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, scraperHandler *scraper.Handler) { +func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, scraperHandler *scraper.Handler, xssPolicy *security.XSSPolicy) { adminV1 := e.Group("/admin/v1") + // Add CSP middleware to admin routes (stricter policy) + adminCSPConfig := security.DefaultCSPConfig() + adminCSPConfig.ScriptSrc = []string{"'self'"} // No unsafe-inline for admin + adminCSPConfig.StyleSrc = []string{"'self'"} // No unsafe-inline for admin + e.Use(security.CSPMiddleware(adminCSPConfig)) + + // Add CSP report endpoint + e.POST("/api/v1/csp-report", security.CSPReportHandler) + // Admin Users Routes adminUsersGP := adminV1.Group("/users") { @@ -175,9 +185,16 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler } } -func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler) { +func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, xssPolicy *security.XSSPolicy) { v1 := e.Group("/api/v1") + // Add CSP middleware to public routes + cspConfig := security.DefaultCSPConfig() + e.Use(security.CSPMiddleware(cspConfig)) + + // Add CSP report endpoint + e.POST("/api/v1/csp-report", security.CSPReportHandler) + customerGP := v1.Group("/profile") { customerGP.POST("/login", customerHandler.Login) diff --git a/go.mod b/go.mod index 122c2cd..e2db4ab 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.4 require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/labstack/echo/v4 v4.13.4 + github.com/microcosm-cc/bluemonday v1.0.27 github.com/minio/minio-go/v7 v7.0.97 github.com/redis/go-redis/v9 v9.14.0 github.com/robfig/cron/v3 v3.0.1 @@ -21,6 +22,7 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -32,6 +34,7 @@ require ( github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/klauspost/crc32 v1.3.0 // indirect diff --git a/go.sum b/go.sum index 2eaa228..e8abd72 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -34,6 +36,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -57,6 +61,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= diff --git a/internal/inquiry/email_template.go b/internal/inquiry/email_template.go index 4025b0e..2caf41d 100644 --- a/internal/inquiry/email_template.go +++ b/internal/inquiry/email_template.go @@ -2,8 +2,11 @@ package inquiry import ( "fmt" + "html" "strings" "time" + + "tm/pkg/security" ) // EmailTemplateData represents the data needed for the email template @@ -20,7 +23,14 @@ type EmailTemplateData struct { } // GenerateNewInquiryEmailTemplate generates a beautiful HTML email template for new inquiry notifications -func GenerateNewInquiryEmailTemplate(data *EmailTemplateData) string { +func GenerateNewInquiryEmailTemplate(data *EmailTemplateData, xssPolicy *security.XSSPolicy) string { + // Sanitize all user inputs before inserting into HTML + safeFullName := html.EscapeString(data.FullName) + safeCompanyName := html.EscapeString(data.CompanyName) + safeWorkEmail := html.EscapeString(data.WorkEmail) + safePhoneNumber := html.EscapeString(data.PhoneNumber) + safeInquiryID := html.EscapeString(data.InquiryID) + // Format the creation date createdAt := time.Unix(data.CreatedAt, 0).Format("January 2, 2006 at 3:04 PM") @@ -249,12 +259,12 @@ func GenerateNewInquiryEmailTemplate(data *EmailTemplateData) string { ` - // Replace placeholders with actual data - html = strings.ReplaceAll(html, "{{COMPANY_NAME}}", data.CompanyName) - html = strings.ReplaceAll(html, "{{INQUIRY_ID}}", data.InquiryID) - html = strings.ReplaceAll(html, "{{FULL_NAME}}", data.FullName) - html = strings.ReplaceAll(html, "{{WORK_EMAIL}}", data.WorkEmail) - html = strings.ReplaceAll(html, "{{PHONE_NUMBER}}", data.PhoneNumber) + // Replace placeholders with sanitized data + html = strings.ReplaceAll(html, "{{COMPANY_NAME}}", safeCompanyName) + html = strings.ReplaceAll(html, "{{INQUIRY_ID}}", safeInquiryID) + html = strings.ReplaceAll(html, "{{FULL_NAME}}", safeFullName) + html = strings.ReplaceAll(html, "{{WORK_EMAIL}}", safeWorkEmail) + html = strings.ReplaceAll(html, "{{PHONE_NUMBER}}", safePhoneNumber) html = strings.ReplaceAll(html, "{{CREATED_AT}}", createdAt) return html diff --git a/internal/inquiry/form.go b/internal/inquiry/form.go index 74d4ef1..e986574 100644 --- a/internal/inquiry/form.go +++ b/internal/inquiry/form.go @@ -1,6 +1,11 @@ package inquiry -import "tm/pkg/response" +import ( + "tm/pkg/response" + "tm/pkg/security" + + "github.com/asaskevich/govalidator" +) // Inquiry DTOs @@ -83,3 +88,72 @@ func (i *Inquiry) ToResponse() *InquiryResponse { UpdatedAt: i.UpdatedAt, } } + +// ValidateAndSanitize validates and sanitizes the form +func (f *CreateInquiryForm) ValidateAndSanitize(xssPolicy *security.XSSPolicy) error { + var err error + + // Sanitize and validate FullName + f.FullName, err = xssPolicy.ValidateAndSanitizeInput(f.FullName, "text") + if err != nil { + return err + } + + // Sanitize and validate CompanyName + f.CompanyName, err = xssPolicy.ValidateAndSanitizeInput(f.CompanyName, "text") + if err != nil { + return err + } + + // Sanitize and validate WorkEmail (email validation is separate) + f.WorkEmail, err = xssPolicy.ValidateAndSanitizeInput(f.WorkEmail, "email") + if err != nil { + return err + } + + // Sanitize and validate PhoneNumber + f.PhoneNumber, err = xssPolicy.ValidateAndSanitizeInput(f.PhoneNumber, "phone") + if err != nil { + return err + } + + // Standard govalidator validation + _, err = govalidator.ValidateStruct(f) + return err +} + +// ValidateAndSanitize validates and sanitizes the status update form +func (f *UpdateInquiryStatusForm) ValidateAndSanitize(xssPolicy *security.XSSPolicy) error { + var err error + + // Sanitize Reason + f.Reason, err = xssPolicy.ValidateAndSanitizeInput(f.Reason, "text") + if err != nil { + return err + } + + // Sanitize Description + f.Description, err = xssPolicy.ValidateAndSanitizeInput(f.Description, "html") + if err != nil { + return err + } + + // Standard govalidator validation + _, err = govalidator.ValidateStruct(f) + return err +} + +// ValidateAndSanitize validates and sanitizes the search form +func (f *SearchInquiriesForm) ValidateAndSanitize(xssPolicy *security.XSSPolicy) error { + if f.Search != nil { + sanitized, err := xssPolicy.ValidateAndSanitizeInput(*f.Search, "text") + if err != nil { + return err + } + *f.Search = sanitized + } + + // Standard govalidator validation + _, err := govalidator.ValidateStruct(f) + return err +} diff --git a/internal/inquiry/handler.go b/internal/inquiry/handler.go index 3b6c690..152cca5 100644 --- a/internal/inquiry/handler.go +++ b/internal/inquiry/handler.go @@ -4,22 +4,24 @@ import ( "encoding/json" "tm/pkg/logger" "tm/pkg/response" + "tm/pkg/security" - "github.com/asaskevich/govalidator" "github.com/labstack/echo/v4" ) // Handler handles HTTP requests for inquiry operations type Handler struct { - service Service - logger logger.Logger + service Service + logger logger.Logger + xssPolicy *security.XSSPolicy } // NewInquiryHandler creates a new inquiry handler -func NewHandler(service Service, logger logger.Logger) *Handler { +func NewHandler(service Service, logger logger.Logger, xssPolicy *security.XSSPolicy) *Handler { return &Handler{ - service: service, - logger: logger, + service: service, + logger: logger, + xssPolicy: xssPolicy, } } @@ -42,8 +44,8 @@ func (h *Handler) CreateInquiry(c echo.Context) error { return response.BadRequest(c, "Invalid request format", "") } - // Validate form - if _, err := govalidator.ValidateStruct(form); err != nil { + // Validate and sanitize form + if err := form.ValidateAndSanitize(h.xssPolicy); err != nil { return response.ValidationError(c, err.Error(), "") } @@ -122,8 +124,8 @@ func (h *Handler) UpdateInquiryStatus(c echo.Context) error { return response.BadRequest(c, "Invalid request format", "") } - // Validate form - if _, err := govalidator.ValidateStruct(form); err != nil { + // Validate and sanitize form + if err := form.ValidateAndSanitize(h.xssPolicy); err != nil { return response.ValidationError(c, err.Error(), "") } @@ -163,8 +165,8 @@ func (h *Handler) SearchInquiries(c echo.Context) error { return response.BadRequest(c, "Invalid request format", "") } - // Validate form - if _, err := govalidator.ValidateStruct(form); err != nil { + // Validate and sanitize form + if err := form.ValidateAndSanitize(h.xssPolicy); err != nil { return response.ValidationError(c, err.Error(), "") } diff --git a/internal/inquiry/service.go b/internal/inquiry/service.go index dff3c72..c37bb4f 100644 --- a/internal/inquiry/service.go +++ b/internal/inquiry/service.go @@ -7,6 +7,7 @@ import ( "tm/pkg/logger" "tm/pkg/notification" "tm/pkg/response" + "tm/pkg/security" ) // Service defines business logic for inquiry operations @@ -23,14 +24,16 @@ type inquiryService struct { repository Repository sdk notification.SDK logger logger.Logger + xssPolicy *security.XSSPolicy } // NewService creates a new inquiry service -func NewService(repository Repository, sdk notification.SDK, logger logger.Logger) Service { +func NewService(repository Repository, sdk notification.SDK, logger logger.Logger, xssPolicy *security.XSSPolicy) Service { return &inquiryService{ repository: repository, sdk: sdk, logger: logger, + xssPolicy: xssPolicy, } } @@ -114,7 +117,7 @@ func (s *inquiryService) Create(ctx context.Context, form *CreateInquiryForm) (* CompanyLogo: "", CompanyURL: "https://opplens.com", SupportEmail: "info@opplens.com", - }), + }, s.xssPolicy), Type: "alert", Priority: "important", EventType: notification.EventTypeEmail, @@ -137,7 +140,7 @@ func (s *inquiryService) Create(ctx context.Context, form *CreateInquiryForm) (* CompanyLogo: "", CompanyURL: "https://opplens.com", SupportEmail: "info@opplens.com", - }), + }, s.xssPolicy), Type: "alert", Priority: "important", EventType: notification.EventTypeEmail, diff --git a/pkg/security/csp.go b/pkg/security/csp.go new file mode 100644 index 0000000..7bf066c --- /dev/null +++ b/pkg/security/csp.go @@ -0,0 +1,144 @@ +package security + +import ( + "net/http" + "strings" + + "github.com/labstack/echo/v4" +) + +// CSPConfig holds CSP configuration +type CSPConfig struct { + DefaultSrc []string + ScriptSrc []string + StyleSrc []string + ImgSrc []string + ConnectSrc []string + FontSrc []string + ObjectSrc []string + MediaSrc []string + FrameSrc []string + ReportURI string + ReportOnly bool +} + +// DefaultCSPConfig returns a secure default CSP configuration +func DefaultCSPConfig() *CSPConfig { + return &CSPConfig{ + DefaultSrc: []string{"'self'"}, + ScriptSrc: []string{"'self'"}, + StyleSrc: []string{"'self'"}, + ImgSrc: []string{"'self'", "data:", "https:", "http:"}, + ConnectSrc: []string{"'self'", "https://api.opplens.com"}, + FontSrc: []string{"'self'", "https://fonts.gstatic.com"}, + ObjectSrc: []string{"'none'"}, + MediaSrc: []string{"'self'"}, + FrameSrc: []string{"'none'"}, + ReportURI: "/api/v1/csp-report", + ReportOnly: false, + } +} + +// CSPSource represents a CSP directive source +type CSPSource string + +// CSPDirective represents a CSP directive +type CSPDirective struct { + Name string + Sources []CSPSource +} + +// CSPMiddleware creates Echo middleware for Content Security Policy +func CSPMiddleware(config *CSPConfig) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Build CSP header + directives := []string{} + + if len(config.DefaultSrc) > 0 { + directives = append(directives, "default-src "+strings.Join(toStringSlice(config.DefaultSrc), " ")) + } + + if len(config.ScriptSrc) > 0 { + directives = append(directives, "script-src "+strings.Join(toStringSlice(config.ScriptSrc), " ")) + } + + if len(config.StyleSrc) > 0 { + directives = append(directives, "style-src "+strings.Join(toStringSlice(config.StyleSrc), " ")) + } + + if len(config.ImgSrc) > 0 { + directives = append(directives, "img-src "+strings.Join(toStringSlice(config.ImgSrc), " ")) + } + + if len(config.ConnectSrc) > 0 { + directives = append(directives, "connect-src "+strings.Join(toStringSlice(config.ConnectSrc), " ")) + } + + if len(config.FontSrc) > 0 { + directives = append(directives, "font-src "+strings.Join(toStringSlice(config.FontSrc), " ")) + } + + if len(config.ObjectSrc) > 0 { + directives = append(directives, "object-src "+strings.Join(toStringSlice(config.ObjectSrc), " ")) + } + + if len(config.MediaSrc) > 0 { + directives = append(directives, "media-src "+strings.Join(toStringSlice(config.MediaSrc), " ")) + } + + if len(config.FrameSrc) > 0 { + directives = append(directives, "frame-src "+strings.Join(toStringSlice(config.FrameSrc), " ")) + } + + if config.ReportURI != "" { + directives = append(directives, "report-uri "+config.ReportURI) + } + + cspHeader := strings.Join(directives, "; ") + + if config.ReportOnly { + c.Response().Header().Set("Content-Security-Policy-Report-Only", cspHeader) + } else { + c.Response().Header().Set("Content-Security-Policy", cspHeader) + } + + return next(c) + } + } +} + +// CSPReportHandler handles CSP violation reports +func CSPReportHandler(c echo.Context) error { + var report struct { + CSPReport struct { + DocumentURI string `json:"document-uri"` + ViolatedDirective string `json:"violated-directive"` + EffectiveDirective string `json:"effective-directive"` + OriginalPolicy string `json:"original-policy"` + BlockedURI string `json:"blocked-uri"` + StatusCode int `json:"status-code"` + } `json:"csp-report"` + } + + if err := c.Bind(&report); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid CSP report"}) + } + + // Log CSP violation (implement proper logging here) + // logger.Warn("CSP Violation", map[string]interface{}{ + // "document_uri": report.CSPReport.DocumentURI, + // "violated_directive": report.CSPReport.ViolatedDirective, + // "blocked_uri": report.CSPReport.BlockedURI, + // }) + + return c.JSON(http.StatusOK, map[string]string{"status": "reported"}) +} + +func toStringSlice(sources []string) []string { + result := make([]string, len(sources)) + for i, src := range sources { + result[i] = string(src) + } + return result +} diff --git a/pkg/security/xss.go b/pkg/security/xss.go new file mode 100644 index 0000000..a8a505e --- /dev/null +++ b/pkg/security/xss.go @@ -0,0 +1,82 @@ +package security + +import ( + "html" + "regexp" + "strings" + + "github.com/microcosm-cc/bluemonday" +) + +// XSSPolicy defines the sanitization policy +type XSSPolicy struct { + policy *bluemonday.Policy +} + +// NewXSSPolicy creates a new XSS sanitization policy +func NewXSSPolicy() *XSSPolicy { + p := bluemonday.UGCPolicy() + + // Allow basic formatting but strip scripts and dangerous attributes + p.AllowAttrs("href", "target", "rel").OnElements("a") + p.AllowAttrs("src", "alt", "width", "height").OnElements("img") + p.AllowElements("p", "br", "strong", "em", "u", "h1", "h2", "h3", "h4", "h5", "h6") + p.AllowElements("ul", "ol", "li") + + return &XSSPolicy{policy: p} +} + +// SanitizeHTML sanitizes HTML input, allowing safe tags +func (x *XSSPolicy) SanitizeHTML(input string) string { + return x.policy.Sanitize(input) +} + +// SanitizeText completely escapes HTML entities for safe text display +func (x *XSSPolicy) SanitizeText(input string) string { + return html.EscapeString(input) +} + +// SanitizeEmail sanitizes input for use in email contexts +func (x *XSSPolicy) SanitizeEmail(input string) string { + // For emails, we want to allow basic formatting but be strict + return strings.TrimSpace(x.SanitizeText(input)) +} + +// SanitizeURL sanitizes URL input +func (x *XSSPolicy) SanitizeURL(input string) string { + // Remove javascript: and data: schemes + dangerous := regexp.MustCompile(`(?i)^(javascript|data|vbscript):`) + if dangerous.MatchString(input) { + return "" + } + return strings.TrimSpace(input) +} + +// SanitizePhoneNumber sanitizes phone number input +func (x *XSSPolicy) SanitizePhoneNumber(input string) string { + // Remove any HTML tags and trim whitespace + clean := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(input, "") + return strings.TrimSpace(clean) +} + +// ValidateAndSanitizeInput validates and sanitizes user input +func (x *XSSPolicy) ValidateAndSanitizeInput(input string, fieldType string) (string, error) { + if strings.TrimSpace(input) == "" { + return "", nil + } + + switch fieldType { + case "text": + return x.SanitizeText(input), nil + case "html": + return x.SanitizeHTML(input), nil + case "email": + return x.SanitizeEmail(input), nil + case "url": + return x.SanitizeURL(input), nil + case "phone": + return x.SanitizePhoneNumber(input), nil + default: + return x.SanitizeText(input), nil + } +}