Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
280 lines
9.2 KiB
Go
280 lines
9.2 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/microcosm-cc/bluemonday"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// XSSProtectionConfig holds configuration for the XSS protection middleware.
|
|
type XSSProtectionConfig struct {
|
|
// HTMLPolicy is the bluemonday policy used for HTML sanitization.
|
|
// If nil, a strict default policy is used that strips all HTML tags.
|
|
HTMLPolicy *bluemonday.Policy
|
|
// SanitizeJSONResponse enables HTML escaping in JSON responses.
|
|
SanitizeJSONResponse bool
|
|
// SanitizeInputFields enables sanitization of form/query parameters.
|
|
SanitizeInputFields bool
|
|
// InputFieldsToSanitize lists specific field names to sanitize from input.
|
|
// If empty, all common text fields (content, message, description, name, title, body) are sanitized.
|
|
InputFieldsToSanitize []string
|
|
}
|
|
|
|
// DefaultXSSProtectionConfig returns a strict default configuration.
|
|
func DefaultXSSProtectionConfig() *XSSProtectionConfig {
|
|
policy := bluemonday.StrictPolicy() // Strip all HTML tags — strict sanitization
|
|
|
|
return &XSSProtectionConfig{
|
|
HTMLPolicy: policy,
|
|
SanitizeJSONResponse: true,
|
|
SanitizeInputFields: true,
|
|
InputFieldsToSanitize: []string{
|
|
"content", "message", "description", "name",
|
|
"title", "body", "subject", "comment", "note",
|
|
"bio", "display_name", "email",
|
|
},
|
|
}
|
|
}
|
|
|
|
// UGCPolicy returns a policy that allows safe user-generated content HTML.
|
|
// This permits basic formatting tags (b, i, em, strong, a with href, p, br, ul, ol, li)
|
|
// while stripping dangerous elements (script, iframe, object, etc.).
|
|
func UGCPolicy() *bluemonday.Policy {
|
|
p := bluemonday.UGCPolicy()
|
|
// Restrict links to safe protocols only
|
|
p.AllowStandardURLs()
|
|
return p
|
|
}
|
|
|
|
// SanitizeHTML cleans HTML content using bluemonday with the given policy.
|
|
// If policy is nil, the strict policy (strip all tags) is used.
|
|
func SanitizeHTML(input string, policy *bluemonday.Policy) string {
|
|
if policy == nil {
|
|
policy = bluemonday.StrictPolicy()
|
|
}
|
|
return policy.Sanitize(input)
|
|
}
|
|
|
|
// EscapeJSONHTML safely marshals data to JSON with HTML-escaped strings.
|
|
// This prevents XSS when JSON responses are embedded in HTML pages.
|
|
// Go's encoding/json already escapes <, >, and & by default, but this
|
|
// function provides an extra layer of safety by also escaping single quotes
|
|
// and ensuring proper handling of unsafe unicode sequences.
|
|
func EscapeJSONHTML(data interface{}) ([]byte, error) {
|
|
raw, err := json.Marshal(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Go's json.Marshal already escapes &, <, >, and \u2028/\u2029.
|
|
// We additionally escape single quotes for inline script safety.
|
|
buf := bytes.ReplaceAll(raw, []byte("'"), []byte("\\u0027"))
|
|
|
|
return buf, nil
|
|
}
|
|
|
|
// sanitizeInputValue sanitizes a string value for XSS prevention.
|
|
// This removes HTML tags and escapes dangerous characters.
|
|
func sanitizeInputValue(value string, policy *bluemonday.Policy) string {
|
|
if value == "" {
|
|
return value
|
|
}
|
|
|
|
// First strip any HTML tags using bluemonday
|
|
cleaned := SanitizeHTML(value, policy)
|
|
|
|
// Additionally remove null bytes and control characters that could be
|
|
// used for injection attacks
|
|
var result []rune
|
|
for _, r := range cleaned {
|
|
if r < 32 && r != '\t' && r != '\n' && r != '\r' {
|
|
continue // skip control characters
|
|
}
|
|
if r == 0 {
|
|
continue // skip null bytes
|
|
}
|
|
result = append(result, r)
|
|
}
|
|
|
|
return string(result)
|
|
}
|
|
|
|
// sanitizeRecursive walks through interface{} values and sanitizes string fields
|
|
// found in maps or slices. This is used for JSON body sanitization.
|
|
func sanitizeRecursive(data interface{}, policy *bluemonday.Policy, fieldsToSanitize map[string]bool) interface{} {
|
|
switch v := data.(type) {
|
|
case map[string]interface{}:
|
|
result := make(map[string]interface{}, len(v))
|
|
for key, val := range v {
|
|
if fieldsToSanitize[key] {
|
|
if strVal, ok := val.(string); ok {
|
|
result[key] = sanitizeInputValue(strVal, policy)
|
|
} else {
|
|
result[key] = sanitizeRecursive(val, policy, fieldsToSanitize)
|
|
}
|
|
} else {
|
|
result[key] = sanitizeRecursive(val, policy, fieldsToSanitize)
|
|
}
|
|
}
|
|
return result
|
|
case []interface{}:
|
|
result := make([]interface{}, len(v))
|
|
for i, val := range v {
|
|
result[i] = sanitizeRecursive(val, policy, fieldsToSanitize)
|
|
}
|
|
return result
|
|
case string:
|
|
return sanitizeInputValue(v, policy)
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
// shouldSanitizeField determines if a field name should be sanitized.
|
|
func shouldSanitizeField(fieldName string, config *XSSProtectionConfig) bool {
|
|
fieldLower := strings.ToLower(fieldName)
|
|
for _, f := range config.InputFieldsToSanitize {
|
|
if strings.ToLower(f) == fieldLower {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// XSSProtectionMiddleware provides comprehensive XSS protection:
|
|
// - HTML sanitization of input fields using bluemonday
|
|
// - JSON response HTML escaping for safe embedding in HTML pages
|
|
// - Input cleansing of form/query/body parameters
|
|
// - Content-Type header enforcement
|
|
func XSSProtectionMiddleware(cfg *XSSProtectionConfig) gin.HandlerFunc {
|
|
if cfg == nil {
|
|
cfg = DefaultXSSProtectionConfig()
|
|
}
|
|
if cfg.HTMLPolicy == nil {
|
|
cfg.HTMLPolicy = bluemonday.StrictPolicy()
|
|
}
|
|
|
|
// Build set of fields to sanitize for quick lookup
|
|
fieldsSet := make(map[string]bool, len(cfg.InputFieldsToSanitize))
|
|
for _, f := range cfg.InputFieldsToSanitize {
|
|
fieldsSet[f] = true
|
|
}
|
|
|
|
return func(c *gin.Context) {
|
|
// 1. Set XSS prevention security headers
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|
c.Header("X-XSS-Protection", "1; mode=block")
|
|
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'")
|
|
|
|
// 2. Sanitize input query/form parameters
|
|
if cfg.SanitizeInputFields {
|
|
// Sanitize query parameters
|
|
for key, values := range c.Request.URL.Query() {
|
|
if shouldSanitizeField(key, cfg) {
|
|
sanitizedValues := make([]string, len(values))
|
|
for i, v := range values {
|
|
sanitizedValues[i] = sanitizeInputValue(v, cfg.HTMLPolicy)
|
|
}
|
|
// Replace query values by updating the URL
|
|
q := c.Request.URL.Query()
|
|
q[key] = sanitizedValues
|
|
c.Request.URL.RawQuery = q.Encode()
|
|
}
|
|
}
|
|
|
|
// Sanitize form POST parameters
|
|
if c.Request.Method == http.MethodPost || c.Request.Method == http.MethodPut || c.Request.Method == http.MethodPatch {
|
|
if err := c.Request.ParseForm(); err == nil {
|
|
for key, values := range c.Request.PostForm {
|
|
if shouldSanitizeField(key, cfg) {
|
|
sanitizedValues := make([]string, len(values))
|
|
for i, v := range values {
|
|
sanitizedValues[i] = sanitizeInputValue(v, cfg.HTMLPolicy)
|
|
}
|
|
c.Request.PostForm[key] = sanitizedValues
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sanitize JSON body
|
|
if c.Request.Method == http.MethodPost || c.Request.Method == http.MethodPut || c.Request.Method == http.MethodPatch {
|
|
contentType := c.GetHeader("Content-Type")
|
|
if contentType != "" && contentType == "application/json" {
|
|
if c.Request.Body != nil {
|
|
bodyBytes, err := io.ReadAll(c.Request.Body)
|
|
if err == nil && len(bodyBytes) > 0 {
|
|
var bodyData interface{}
|
|
if err := json.Unmarshal(bodyBytes, &bodyData); err == nil {
|
|
sanitizedData := sanitizeRecursive(bodyData, cfg.HTMLPolicy, fieldsSet)
|
|
sanitizedBytes, err := json.Marshal(sanitizedData)
|
|
if err == nil {
|
|
c.Request.Body = io.NopCloser(bytes.NewReader(sanitizedBytes))
|
|
// Update Content-Length to reflect potentially changed body
|
|
c.Request.ContentLength = int64(len(sanitizedBytes))
|
|
}
|
|
} else {
|
|
// Restore original body if JSON parse fails
|
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. JSON response HTML escaping — intercept c.JSON calls
|
|
if cfg.SanitizeJSONResponse {
|
|
// We replace the writer to ensure HTML-safe JSON output
|
|
originalWriter := c.Writer
|
|
xssWriter := &xssResponseWriter{
|
|
ResponseWriter: originalWriter,
|
|
policy: cfg.HTMLPolicy,
|
|
}
|
|
c.Writer = xssWriter
|
|
}
|
|
|
|
c.Next()
|
|
|
|
// Restore original writer after handler completes
|
|
if cfg.SanitizeJSONResponse {
|
|
c.Writer = c.Writer.(*xssResponseWriter).ResponseWriter
|
|
}
|
|
}
|
|
}
|
|
|
|
// xssResponseWriter wraps gin.ResponseWriter to provide XSS-safe JSON output.
|
|
type xssResponseWriter struct {
|
|
gin.ResponseWriter
|
|
policy *bluemonday.Policy
|
|
}
|
|
|
|
// WriteJSON overrides the default JSON writing to apply HTML escaping.
|
|
func (w *xssResponseWriter) WriteJSON(data interface{}) error {
|
|
escaped, err := EscapeJSONHTML(data)
|
|
if err != nil {
|
|
applogger.L().Errorf("XSS protection: failed to escape JSON response: %v", err)
|
|
return err
|
|
}
|
|
_, writeErr := w.ResponseWriter.Write(escaped)
|
|
return writeErr
|
|
}
|
|
|
|
// Write ensures that any written content is also XSS-safe for JSON content types.
|
|
func (w *xssResponseWriter) Write(data []byte) (int, error) {
|
|
// Only escape if the content type is JSON
|
|
contentType := w.Header().Get("Content-Type")
|
|
if strings.HasPrefix(contentType, "application/json") {
|
|
// The data is already JSON-encoded, just escape single quotes for safety
|
|
escaped := bytes.ReplaceAll(data, []byte("'"), []byte("\\u0027"))
|
|
return w.ResponseWriter.Write(escaped)
|
|
}
|
|
return w.ResponseWriter.Write(data)
|
|
} |