forked from rachmadiyanti.annisa.3004/service_antrean
317 lines
9.9 KiB
Go
317 lines
9.9 KiB
Go
// 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>", "<script", "javascript:", "vbscript:", "onload=", "onerror=", "eval(", "alert(",
|
|
}
|
|
|
|
return func(c *gin.Context) {
|
|
// 1. Validasi Panjang Input
|
|
log.Printf("DEBUG: InputValidation middleware called. MaxInputLength is set to: %d", cfg.MaxInputLength)
|
|
if !validateInputLength(c, cfg.MaxInputLength) {
|
|
return
|
|
}
|
|
|
|
// 2. Deteksi Pola Injeksi
|
|
if hasInjectionPatterns(c, suspiciousPatterns) {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
|
"error": "Invalid input detected",
|
|
"message": "Request contains potentially malicious content",
|
|
})
|
|
return
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// validateInputLength memeriksa panjang input pada query dan form
|
|
func validateInputLength(c *gin.Context, maxLength int) bool {
|
|
log.Printf("DEBUG: Full Raw Query Received: %s", c.Request.URL.RawQuery)
|
|
// Periksa query parameters
|
|
for key, values := range c.Request.URL.Query() {
|
|
for _, value := range values {
|
|
log.Printf("DEBUG: Checking param '%s' with value '%s' (length: %d)", key, value, len(value))
|
|
if len(value) > 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
|
|
}
|