From 5bbc9fadfe6ffa0cc7e308d1411ed03707b50f1c Mon Sep 17 00:00:00 2001 From: soldosluka857 Date: Sat, 30 May 2026 01:43:04 +0000 Subject: [PATCH] security: fix SSRF, restrict CORS, add rate limiting on auth endpoints - Add isPrivateHost() to block image proxy requests to loopback/private/ link-local IPs (SSRF mitigation) - Add isAllowedLocalPath() to restrict local file reads to configured data/cache/media directories only - CORS middleware now takes debug flag; wildcard only when debug=true, production omits headers (same-origin enforced) - Add per-IP sliding-window rate limiter (10 req/min) on login/register and Emby AuthenticateByName endpoints Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- cmd/server/main.go | 5 +- internal/handler/emby.go | 4 +- internal/handler/handler.go | 6 +++ internal/middleware/middleware.go | 90 +++++++++++++++++++++++++++++-- internal/service/image_proxy.go | 57 ++++++++++++++++++-- 5 files changed, 153 insertions(+), 9 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 70dee06..33bf6ce 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -132,7 +132,10 @@ func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) r := gin.New() r.Use(gin.Recovery()) r.Use(middleware.RequestLogger(logger)) - r.Use(middleware.CORS(cfg.App.CORSOrigins)) + if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 { + logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.") + } + r.Use(middleware.CORS(cfg.App.CORSOrigins, cfg.App.Debug)) handler.Register(r, cfg, logger, svc) diff --git a/internal/handler/emby.go b/internal/handler/emby.go index c55d062..d3547b7 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -11,6 +11,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/gin-gonic/gin" @@ -728,8 +729,9 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) grp.HEAD(path, embyPingHandler(svc)) grp.POST(path, embyPingHandler(svc)) } + embyLoginLimiter := middleware.NewRateLimiter(10, 1*time.Minute) for _, path := range []string{"/Users/AuthenticateByName", "/users/authenticatebyname"} { - grp.POST(path, embyAuthByNameHandler(svc)) + grp.POST(path, middleware.RateLimit(embyLoginLimiter), embyAuthByNameHandler(svc)) } for _, path := range []string{"/Users/Public", "/users/public"} { grp.GET(path, embyPublicUsersHandler(svc)) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index ac3a997..5e0befd 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -5,6 +5,8 @@ package handler import ( + "time" + "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -23,8 +25,12 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C // Telegram Bot webhook — called by Telegram servers, no auth. api.POST("/telegram/webhook", telegramWebhookHandler(svc)) + // Rate limiter for auth endpoints: 10 attempts per minute per IP. + authLimiter := middleware.NewRateLimiter(10, 1*time.Minute) + // Public auth. auth := api.Group("/auth") + auth.Use(middleware.RateLimit(authLimiter)) { auth.POST("/login", loginHandler(svc)) auth.POST("/register", registerHandler(svc)) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 7256572..99cbb45 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "strings" + "sync" "time" "github.com/gin-gonic/gin" @@ -35,10 +36,12 @@ func RequestLogger(log *zap.Logger) gin.HandlerFunc { } } -// CORS implements a permissive cross-origin policy when origins is empty -// (development convenience) and a strict allow-list otherwise. -func CORS(origins []string) gin.HandlerFunc { - allowAll := len(origins) == 0 +// CORS implements a cross-origin policy. When debug is true and origins is +// empty, all origins are allowed (dev convenience). In production +// (debug=false) with an empty origins list, CORS headers are omitted +// entirely so the browser enforces same-origin by default. +func CORS(origins []string, debug bool) gin.HandlerFunc { + allowAll := len(origins) == 0 && debug allowed := make(map[string]struct{}, len(origins)) for _, o := range origins { allowed[strings.TrimSpace(o)] = struct{}{} @@ -62,6 +65,85 @@ func CORS(origins []string) gin.HandlerFunc { } } +// RateLimiter is a simple per-IP sliding-window rate limiter that runs +// entirely in-process. It is intended for auth endpoints (login, register) +// where brute-force protection is critical. +type RateLimiter struct { + mu sync.Mutex + window time.Duration + max int + requests map[string][]time.Time +} + +// NewRateLimiter creates a rate limiter allowing max requests per window +// per client IP. +func NewRateLimiter(max int, window time.Duration) *RateLimiter { + rl := &RateLimiter{ + window: window, + max: max, + requests: make(map[string][]time.Time), + } + go rl.cleanup() + return rl +} + +func (rl *RateLimiter) cleanup() { + for { + time.Sleep(5 * time.Minute) + rl.mu.Lock() + now := time.Now() + for ip, times := range rl.requests { + var valid []time.Time + for _, t := range times { + if now.Sub(t) <= rl.window { + valid = append(valid, t) + } + } + if len(valid) == 0 { + delete(rl.requests, ip) + } else { + rl.requests[ip] = valid + } + } + rl.mu.Unlock() + } +} + +// Allow returns true if the request from ip is within the rate limit. +func (rl *RateLimiter) Allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + now := time.Now() + times := rl.requests[ip] + var valid []time.Time + for _, t := range times { + if now.Sub(t) <= rl.window { + valid = append(valid, t) + } + } + if len(valid) >= rl.max { + rl.requests[ip] = valid + return false + } + rl.requests[ip] = append(valid, now) + return true +} + +// RateLimit returns a Gin middleware that rejects requests exceeding the +// per-IP rate limit with 429 Too Many Requests. +func RateLimit(limiter *RateLimiter) gin.HandlerFunc { + return func(c *gin.Context) { + if !limiter.Allow(c.ClientIP()) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "code": 42901, + "message": "too many requests, please try again later", + }) + return + } + c.Next() + } +} + // Claims is the JWT payload we issue. type Claims struct { UserID string `json:"uid"` diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index 79fd4f1..5f0cef0 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -19,6 +19,7 @@ import ( "encoding/hex" "errors" "io" + "net" "net/http" "net/url" "os" @@ -88,9 +89,8 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy { } } -// validateURL parses raw and ensures the scheme is http/https. The host -// allow-list is now advisory — every reachable URL is accepted so users -// can freely configure mirror domains via tmdb_image_proxy. +// validateURL parses raw and ensures the scheme is http/https and the +// target host is not a private/loopback/link-local address (SSRF guard). func (p *ImageProxy) validateURL(raw string) (*url.URL, error) { if raw == "" { return nil, errors.New("missing url") @@ -103,9 +103,54 @@ func (p *ImageProxy) validateURL(raw string) (*url.URL, error) { if scheme != "http" && scheme != "https" { return nil, errors.New("unsupported scheme") } + if isPrivateHost(u.Hostname()) { + return nil, errors.New("requests to private/internal hosts are not allowed") + } return u, nil } +// isPrivateHost returns true if host resolves to a loopback, private, or +// link-local address. This blocks SSRF attacks that try to reach internal +// services (e.g. cloud metadata at 169.254.169.254). +func isPrivateHost(host string) bool { + if host == "" { + return true + } + ip := net.ParseIP(host) + if ip != nil { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() + } + addrs, err := net.LookupHost(host) + if err != nil { + return false + } + for _, addr := range addrs { + resolved := net.ParseIP(addr) + if resolved != nil && (resolved.IsLoopback() || resolved.IsPrivate() || resolved.IsLinkLocalUnicast() || resolved.IsLinkLocalMulticast() || resolved.IsUnspecified()) { + return true + } + } + return false +} + +// isAllowedLocalPath restricts local file reads to the data directory and +// cache directory to prevent arbitrary file read via path traversal. +func (p *ImageProxy) isAllowedLocalPath(abs string) bool { + for _, root := range []string{p.cfg.App.DataDir, p.cfg.Cache.CacheDir, p.cfg.Media.MoviesDir, p.cfg.Media.TVDir, p.cfg.Media.AnimeDir} { + if root == "" { + continue + } + rootAbs, err := filepath.Abs(root) + if err != nil { + continue + } + if strings.HasPrefix(abs, rootAbs+string(filepath.Separator)) || abs == rootAbs { + return true + } + } + return false +} + func isLocalImagePath(raw string) bool { raw = strings.TrimSpace(raw) if raw == "" || isHTTPish(raw) { @@ -146,6 +191,12 @@ func servePlaceholder(w http.ResponseWriter) { func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error { if isLocalImagePath(raw) { path := filepath.Clean(raw) + abs, err := filepath.Abs(path) + if err != nil || !p.isAllowedLocalPath(abs) { + servePlaceholder(w) + return nil + } + path = abs data, err := os.ReadFile(path) if err != nil || len(data) == 0 { servePlaceholder(w)