// middleware/security.go package middleware import ( "api-service/internal/config" "context" "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/go-redis/redis_rate/v10" // Tambahkan library ini: go get github.com/go-redis/redis_rate/v10 "github.com/redis/go-redis/v9" ) // Config menyimpan konfigurasi untuk middleware keamanan type Config struct { // CORS TrustedOrigins []string // Rate Limiting RedisClient *redis.Client RequestsPerMin int // Input Validation MaxInputLength int } // SwaggerSecurityHeaders adalah middleware khusus untuk route dokumentasi. // CSP-nya dilonggarkan untuk mengizinkan skrip dan gaya inline yang dibutuhkan Swagger UI. func SwaggerSecurityHeaders() gin.HandlerFunc { return func(c *gin.Context) { // Header lainnya tetap bisa diterapkan c.Header("X-Frame-Options", "DENY") c.Header("X-Content-Type-Options", "nosniff") c.Header("Referrer-Policy", "strict-origin-when-cross-origin") c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()") // CSP yang lebih longgar untuk Swagger UI // 'unsafe-inline' dibutuhkan untuk skrip dan gaya yang ada di dalam HTML // data: dibutuhkan jika ada gambar atau resource yang di-encode base64 cspHeader := "default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " + // <--- PERUBAHAN UTAMA "style-src 'self' 'unsafe-inline'; " + // <--- Juga sering dibutuhkan "img-src 'self' data:; " + // <--- Untuk gambar base64 "object-src 'none'; " + "base-uri 'self'; " + "frame-ancestors 'none';" c.Header("Content-Security-Policy", cspHeader) // HSTS juga bisa diterapkan jika menggunakan HTTPS if c.Request.TLS != nil { c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") } c.Next() } } // SecurityHeaders menambahkan header keamanan standar ke semua respons func SecurityHeaders() gin.HandlerFunc { return func(c *gin.Context) { // Mencegah clickjacking c.Header("X-Frame-Options", "DENY") // Mencegah MIME type sniffing c.Header("X-Content-Type-Options", "nosniff") // Mengaktifkan proteksi XSS (sudah usang di browser modern tapi tetap baik) c.Header("X-XSS-Protection", "1; mode=block") // Kebijakan referrer c.Header("Referrer-Policy", "strict-origin-when-cross-origin") // Kebijakan Keamanan Konten (CSP) - Lebih ketat // Hindari 'unsafe-inline' di produksi. Gunakan nonce atau hash jika memungkinkan. c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';") // Kebijakan Izin (Permissions Policy) - Menonaktifkan fitur browser yang tidak dibutuhkan c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()") // HSTS (HTTP Strict Transport Security) - Hanya untuk HTTPS if c.Request.TLS != nil { c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") } c.Next() } } // SecureCORSConfig menyediakan konfigurasi CORS yang aman dan fleksibel func SecureCORSConfig(cfg config.SecurityConfig) gin.HandlerFunc { return cors.New(cors.Config{ AllowOrigins: cfg.TrustedOrigins, AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, // Hanya gunakan 'true' jika Anda benar-benar membutuhkannya (cookie, auth) MaxAge: 12 * time.Hour, }) } // RateLimitByIPRedis membatasi permintaan per IP menggunakan Redis untuk skalabilitas func RateLimitByIPRedis(cfg config.SecurityConfig) gin.HandlerFunc { // Buat koneksi Redis dari konfigurasi rdb := redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%d", cfg.RateLimit.Redis.Host, cfg.RateLimit.Redis.Port), Password: cfg.RateLimit.Redis.Password, DB: cfg.RateLimit.Redis.DB, }) // Cek koneksi ke Redis ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if _, err := rdb.Ping(ctx).Result(); err != nil { // Jika gagal konek, gunakan fallback di memori dan log error fmt.Printf("WARNING: Could not connect to Redis: %v. Falling back to in-memory rate limiter.\n", err) return rateLimitByIPFallback(cfg.RateLimit.RequestsPerMinute) } limiter := redis_rate.NewLimiter(rdb) return func(c *gin.Context) { res, err := limiter.Allow(c.Request.Context(), c.ClientIP(), redis_rate.PerMinute(cfg.RateLimit.RequestsPerMinute)) if err != nil { fmt.Printf("Rate limiter error: %v\n", err) c.Next() return } h := c.Writer.Header() h.Set("X-RateLimit-Limit", fmt.Sprintf("%d", cfg.RateLimit.RequestsPerMinute)) h.Set("X-RateLimit-Remaining", fmt.Sprintf("%d", res.Remaining)) if res.Allowed == 0 { h.Set("X-RateLimit-Reset", fmt.Sprintf("%d", time.Now().Add(res.RetryAfter).Unix())) c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ "error": "Rate limit exceeded", }) return } c.Next() } } // rateLimitByIPFallback adalah rate limiter sederhana di memori, HANYA untuk pengembangan func rateLimitByIPFallback(requestsPerMinute int) gin.HandlerFunc { type client struct { count int resetTime int64 } clients := make(map[string]*client) return func(c *gin.Context) { ip := c.ClientIP() now := time.Now().Unix() if _, exists := clients[ip]; !exists { clients[ip] = &client{count: 0, resetTime: now + 60} } cl := clients[ip] if now > cl.resetTime { cl.count = 0 cl.resetTime = now + 60 } if cl.count >= requestsPerMinute { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"}) return } cl.count++ c.Next() } } // InputValidation memvalidasi input untuk mencegah serangan injeksi dan buffer overflow func InputValidation(cfg config.SecurityConfig) gin.HandlerFunc { // Pola-pola yang mencurigakan. Ini adalah lapisan pertahanan tambahan (WAF), bukan pengganti prepared statements. suspiciousPatterns := []string{ "union select", "union all select", "select.*from", "insert.*into", "update.*set", "delete.*from", "drop table", "drop database", "alter table", "create table", "exec(", "execute(", "xp_", "sp_", "information_schema", "sysobjects", "syscolumns", "mysql.", "pg_", "sqlite_", ";--", "/*", "*/", "@@", "script>", " maxLength { log.Printf("ERROR: Parameter '%s' with value '%s' (length: %d) exceeds max length %d", key, value, len(value), maxLength) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": "Input too long", "message": fmt.Sprintf("Query parameter '%s' exceeds maximum length", key), }) return false } } } // Periksa form data (jika sudah di-parse) if c.Request.PostForm != nil { for key, values := range c.Request.PostForm { for _, value := range values { if len(value) > maxLength { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": "Input too long", "message": fmt.Sprintf("Form parameter '%s' exceeds maximum length", key), }) return false } } } } return true } // hasInjectionPatterns memeriksa pola injeksi pada query, form, dan body JSON func hasInjectionPatterns(c *gin.Context, patterns []string) bool { // Periksa query string query := strings.ToLower(c.Request.URL.RawQuery) for _, pattern := range patterns { if strings.Contains(query, pattern) { return true } } // Periksa form data if err := c.Request.ParseForm(); err == nil { for _, values := range c.Request.Form { for _, value := range values { lowerValue := strings.ToLower(value) for _, pattern := range patterns { if strings.Contains(lowerValue, pattern) { return true } } } } } // Periksa body JSON if c.ContentType() == "application/json" { bodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { return false } // **PENTING**: Kembalikan body agar bisa dibaca lagi oleh handler (misalnya c.ShouldBindJSON) c.Request.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) var jsonData map[string]interface{} if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { if checkMapForPatterns(jsonData, patterns) { return true } } } return false } // checkMapForPatterns memeriksa nilai-nilai di dalam map JSON secara rekursif func checkMapForPatterns(data map[string]interface{}, patterns []string) bool { for _, value := range data { if checkValueForPatterns(value, patterns) { return true } } return false } func checkValueForPatterns(value interface{}, patterns []string) bool { switch v := value.(type) { case string: lowerValue := strings.ToLower(v) for _, pattern := range patterns { if strings.Contains(lowerValue, pattern) { return true } } case map[string]interface{}: return checkMapForPatterns(v, patterns) case []interface{}: for _, item := range v { if checkValueForPatterns(item, patterns) { return true } } } return false }