Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
247
consent-service/internal/middleware/input_gate.go
Normal file
247
consent-service/internal/middleware/input_gate.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// InputGateConfig holds configuration for input validation.
|
||||
type InputGateConfig struct {
|
||||
// Maximum request body size (default: 10MB)
|
||||
MaxBodySize int64
|
||||
|
||||
// Maximum file upload size (default: 50MB)
|
||||
MaxFileSize int64
|
||||
|
||||
// Allowed content types
|
||||
AllowedContentTypes map[string]bool
|
||||
|
||||
// Allowed file types for uploads
|
||||
AllowedFileTypes map[string]bool
|
||||
|
||||
// Blocked file extensions
|
||||
BlockedExtensions map[string]bool
|
||||
|
||||
// Paths that allow larger uploads
|
||||
LargeUploadPaths []string
|
||||
|
||||
// Paths excluded from validation
|
||||
ExcludedPaths []string
|
||||
|
||||
// Enable strict content type checking
|
||||
StrictContentType bool
|
||||
}
|
||||
|
||||
// DefaultInputGateConfig returns sensible default configuration.
|
||||
func DefaultInputGateConfig() InputGateConfig {
|
||||
maxSize := int64(10 * 1024 * 1024) // 10MB
|
||||
if envSize := os.Getenv("MAX_REQUEST_BODY_SIZE"); envSize != "" {
|
||||
if size, err := strconv.ParseInt(envSize, 10, 64); err == nil {
|
||||
maxSize = size
|
||||
}
|
||||
}
|
||||
|
||||
return InputGateConfig{
|
||||
MaxBodySize: maxSize,
|
||||
MaxFileSize: 50 * 1024 * 1024, // 50MB
|
||||
AllowedContentTypes: map[string]bool{
|
||||
"application/json": true,
|
||||
"application/x-www-form-urlencoded": true,
|
||||
"multipart/form-data": true,
|
||||
"text/plain": true,
|
||||
},
|
||||
AllowedFileTypes: map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"application/pdf": true,
|
||||
"text/csv": true,
|
||||
"application/msword": true,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
||||
"application/vnd.ms-excel": true,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
|
||||
},
|
||||
BlockedExtensions: map[string]bool{
|
||||
".exe": true, ".bat": true, ".cmd": true, ".com": true, ".msi": true,
|
||||
".dll": true, ".scr": true, ".pif": true, ".vbs": true, ".js": true,
|
||||
".jar": true, ".sh": true, ".ps1": true, ".app": true,
|
||||
},
|
||||
LargeUploadPaths: []string{
|
||||
"/api/v1/files/upload",
|
||||
"/api/v1/documents/upload",
|
||||
"/api/v1/attachments",
|
||||
},
|
||||
ExcludedPaths: []string{
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/api/v1/health",
|
||||
},
|
||||
StrictContentType: true,
|
||||
}
|
||||
}
|
||||
|
||||
// isExcludedPath checks if path is excluded from validation.
|
||||
func (c *InputGateConfig) isExcludedPath(path string) bool {
|
||||
for _, excluded := range c.ExcludedPaths {
|
||||
if path == excluded {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isLargeUploadPath checks if path allows larger uploads.
|
||||
func (c *InputGateConfig) isLargeUploadPath(path string) bool {
|
||||
for _, uploadPath := range c.LargeUploadPaths {
|
||||
if strings.HasPrefix(path, uploadPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getMaxSize returns the maximum allowed body size for the path.
|
||||
func (c *InputGateConfig) getMaxSize(path string) int64 {
|
||||
if c.isLargeUploadPath(path) {
|
||||
return c.MaxFileSize
|
||||
}
|
||||
return c.MaxBodySize
|
||||
}
|
||||
|
||||
// validateContentType validates the content type.
|
||||
func (c *InputGateConfig) validateContentType(contentType string) (bool, string) {
|
||||
if contentType == "" {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// Extract base content type (remove charset, boundary, etc.)
|
||||
baseType := strings.Split(contentType, ";")[0]
|
||||
baseType = strings.TrimSpace(strings.ToLower(baseType))
|
||||
|
||||
if !c.AllowedContentTypes[baseType] {
|
||||
return false, "Content-Type '" + baseType + "' is not allowed"
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// hasBlockedExtension checks if filename has a blocked extension.
|
||||
func (c *InputGateConfig) hasBlockedExtension(filename string) bool {
|
||||
if filename == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
lowerFilename := strings.ToLower(filename)
|
||||
for ext := range c.BlockedExtensions {
|
||||
if strings.HasSuffix(lowerFilename, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// InputGate returns a middleware that validates incoming request bodies.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// r.Use(middleware.InputGate())
|
||||
//
|
||||
// // Or with custom config:
|
||||
// config := middleware.DefaultInputGateConfig()
|
||||
// config.MaxBodySize = 5 * 1024 * 1024 // 5MB
|
||||
// r.Use(middleware.InputGateWithConfig(config))
|
||||
func InputGate() gin.HandlerFunc {
|
||||
return InputGateWithConfig(DefaultInputGateConfig())
|
||||
}
|
||||
|
||||
// InputGateWithConfig returns an input gate middleware with custom configuration.
|
||||
func InputGateWithConfig(config InputGateConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Skip excluded paths
|
||||
if config.isExcludedPath(c.Request.URL.Path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Skip validation for GET, HEAD, OPTIONS requests
|
||||
method := c.Request.Method
|
||||
if method == "GET" || method == "HEAD" || method == "OPTIONS" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Validate content type for requests with body
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
if config.StrictContentType {
|
||||
valid, errMsg := config.validateContentType(contentType)
|
||||
if !valid {
|
||||
c.AbortWithStatusJSON(http.StatusUnsupportedMediaType, gin.H{
|
||||
"error": "unsupported_media_type",
|
||||
"message": errMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check Content-Length header
|
||||
contentLength := c.GetHeader("Content-Length")
|
||||
if contentLength != "" {
|
||||
length, err := strconv.ParseInt(contentLength, 10, 64)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_content_length",
|
||||
"message": "Invalid Content-Length header",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
maxSize := config.getMaxSize(c.Request.URL.Path)
|
||||
if length > maxSize {
|
||||
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{
|
||||
"error": "payload_too_large",
|
||||
"message": "Request body exceeds maximum size",
|
||||
"max_size": maxSize,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Set max multipart memory for file uploads
|
||||
if strings.Contains(contentType, "multipart/form-data") {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, config.MaxFileSize)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateFileUpload validates a file upload.
|
||||
// Use this in upload handlers for detailed validation.
|
||||
func ValidateFileUpload(filename, contentType string, size int64, config *InputGateConfig) (bool, string) {
|
||||
if config == nil {
|
||||
defaultConfig := DefaultInputGateConfig()
|
||||
config = &defaultConfig
|
||||
}
|
||||
|
||||
// Check size
|
||||
if size > config.MaxFileSize {
|
||||
return false, "File size exceeds maximum allowed"
|
||||
}
|
||||
|
||||
// Check extension
|
||||
if config.hasBlockedExtension(filename) {
|
||||
return false, "File extension is not allowed"
|
||||
}
|
||||
|
||||
// Check content type
|
||||
if contentType != "" && !config.AllowedFileTypes[contentType] {
|
||||
return false, "File type '" + contentType + "' is not allowed"
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
Reference in New Issue
Block a user