Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
)
|
||||
|
||||
// UploadSecurityConfig holds configuration for upload security middleware.
|
||||
type UploadSecurityConfig struct {
|
||||
// MaxFileSize is the maximum allowed file size in bytes (default 20MB).
|
||||
MaxFileSize int64
|
||||
// AllowedMIMETypes is the whitelist of allowed MIME types.
|
||||
AllowedMIMETypes map[string]bool
|
||||
// AllowedExtensions is the whitelist of allowed file extensions.
|
||||
AllowedExtensions map[string]bool
|
||||
// MaxZipCompressionRatio is the maximum compression ratio for ZIP files
|
||||
// to prevent zip bomb attacks (default 10.0, meaning compressed size * 10 < uncompressed size).
|
||||
MaxZipCompressionRatio float64
|
||||
// MaxZipEntries is the maximum number of files inside a ZIP archive (default 1000).
|
||||
MaxZipEntries int64
|
||||
}
|
||||
|
||||
// DefaultUploadSecurityConfig returns a sensible default configuration.
|
||||
func DefaultUploadSecurityConfig() *UploadSecurityConfig {
|
||||
return &UploadSecurityConfig{
|
||||
MaxFileSize: 20 * 1024 * 1024, // 20MB
|
||||
AllowedMIMETypes: map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"application/pdf": true,
|
||||
"text/plain": true,
|
||||
"audio/mpeg": true,
|
||||
"audio/ogg": true,
|
||||
"video/mp4": true,
|
||||
"application/zip": true,
|
||||
"application/x-zip-compressed": true,
|
||||
},
|
||||
AllowedExtensions: map[string]bool{
|
||||
".jpeg": true,
|
||||
".jpg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".pdf": true,
|
||||
".txt": true,
|
||||
".mp3": true,
|
||||
".ogg": true,
|
||||
".mp4": true,
|
||||
".zip": true,
|
||||
},
|
||||
MaxZipCompressionRatio: 10.0,
|
||||
MaxZipEntries: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeFilename removes dangerous characters from uploaded filenames.
|
||||
// Removes path components, null bytes, and special characters that could
|
||||
// be used for directory traversal or other attacks.
|
||||
func sanitizeFilename(filename string) string {
|
||||
// Remove any directory path components
|
||||
filename = filepath.Base(filename)
|
||||
|
||||
// Remove null bytes
|
||||
filename = strings.ReplaceAll(filename, "\x00", "")
|
||||
|
||||
// Remove potentially dangerous characters: backslash, control chars
|
||||
sanitized := strings.Map(func(r rune) rune {
|
||||
if r < 32 && r != '\t' && r != '\n' && r != '\r' {
|
||||
return -1 // drop control characters
|
||||
}
|
||||
switch r {
|
||||
case '\\', '/', ':', '*', '?', '"', '<', '>', '|':
|
||||
return -1 // drop path/dangerous characters
|
||||
}
|
||||
return r
|
||||
}, filename)
|
||||
|
||||
// Trim whitespace and dots from the edges (prevent hidden files or extension tricks)
|
||||
sanitized = strings.Trim(sanitized, " .")
|
||||
|
||||
// Ensure we have a valid filename
|
||||
if sanitized == "" {
|
||||
sanitized = "upload"
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// validateFileExtension checks if the file extension is in the whitelist.
|
||||
func validateFileExtension(filename string, cfg *UploadSecurityConfig) bool {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
return cfg.AllowedExtensions[ext]
|
||||
}
|
||||
|
||||
// validateMIMEType checks if the detected MIME type is in the whitelist.
|
||||
func validateMIMEType(mimeType string, cfg *UploadSecurityConfig) bool {
|
||||
// Normalize: strip parameters like charset
|
||||
mimeType = strings.Split(mimeType, ";")[0]
|
||||
mimeType = strings.TrimSpace(strings.ToLower(mimeType))
|
||||
return cfg.AllowedMIMETypes[mimeType]
|
||||
}
|
||||
|
||||
// detectMIMEType reads the first 512 bytes to detect the actual content type.
|
||||
func detectMIMEType(data []byte) string {
|
||||
return http.DetectContentType(data)
|
||||
}
|
||||
|
||||
// UploadSecurityMiddleware validates uploaded files against security rules:
|
||||
// - Enforces file size limits (default 20MB)
|
||||
// - Validates file extensions against a whitelist
|
||||
// - Sanitizes filenames to prevent directory traversal
|
||||
// - Verifies actual MIME type matches declared type
|
||||
// - Inspects ZIP archives for zip bomb and zip fragment attacks
|
||||
func UploadSecurityMiddleware(cfg *UploadSecurityConfig) gin.HandlerFunc {
|
||||
if cfg == nil {
|
||||
cfg = DefaultUploadSecurityConfig()
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
// No multipart form — skip this middleware (not an upload request)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
files := form.File["file"]
|
||||
if len(files) == 0 {
|
||||
// Check other common field names
|
||||
for key, fileHeaders := range form.File {
|
||||
if key == "attachment" || key == "upload" || key == "avatar" || key == "media" {
|
||||
files = fileHeaders
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, fileHeader := range files {
|
||||
// 1. File size validation
|
||||
if fileHeader.Size > cfg.MaxFileSize {
|
||||
applogger.L().Errorf("Upload rejected: file %s exceeds size limit (%d > %d bytes)",
|
||||
fileHeader.Filename, fileHeader.Size, cfg.MaxFileSize)
|
||||
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{
|
||||
"error": fmt.Sprintf("file size exceeds maximum allowed size of %d bytes", cfg.MaxFileSize),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Filename sanitization
|
||||
sanitized := sanitizeFilename(fileHeader.Filename)
|
||||
if sanitized != fileHeader.Filename {
|
||||
applogger.L().Errorf("Upload rejected: filename contains dangerous characters: original=%s sanitized=%s",
|
||||
fileHeader.Filename, sanitized)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "filename contains invalid or dangerous characters",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. File extension whitelist
|
||||
if !validateFileExtension(fileHeader.Filename, cfg) {
|
||||
applogger.L().Errorf("Upload rejected: file extension not allowed: %s", fileHeader.Filename)
|
||||
c.AbortWithStatusJSON(http.StatusUnsupportedMediaType, gin.H{
|
||||
"error": fmt.Sprintf("file type not allowed: %s", filepath.Ext(fileHeader.Filename)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Open file to validate MIME type and inspect ZIP archives
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Upload rejected: cannot open file %s: %v", fileHeader.Filename, err)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "cannot read uploaded file",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Read first 512 bytes for MIME detection
|
||||
buf := make([]byte, 512)
|
||||
n, err := file.Read(buf)
|
||||
if err != nil && n == 0 {
|
||||
file.Close()
|
||||
applogger.L().Errorf("Upload rejected: cannot read file content for %s: %v", fileHeader.Filename, err)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "cannot read file content",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 5. MIME type validation — verify actual content matches whitelist
|
||||
detectedMIME := detectMIMEType(buf[:n])
|
||||
if !validateMIMEType(detectedMIME, cfg) {
|
||||
file.Close()
|
||||
applogger.L().Errorf("Upload rejected: MIME type not allowed: detected=%s file=%s",
|
||||
detectedMIME, fileHeader.Filename)
|
||||
c.AbortWithStatusJSON(http.StatusUnsupportedMediaType, gin.H{
|
||||
"error": fmt.Sprintf("file content type not allowed: %s", detectedMIME),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 6. ZIP bomb / zip fragment protection
|
||||
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
|
||||
if ext == ".zip" {
|
||||
if err := inspectZipArchive(file, fileHeader.Size, cfg); err != nil {
|
||||
file.Close()
|
||||
applogger.L().Errorf("Upload rejected: ZIP inspection failed for %s: %v", fileHeader.Filename, err)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
file.Close()
|
||||
}
|
||||
|
||||
// Store sanitized filenames in context for downstream handlers
|
||||
c.Set("upload_verified", true)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user