package middleware import ( "crypto/rand" "crypto/subtle" "encoding/hex" "log/slog" "strings" "sync" "time" "polar/internal/auth" "polar/internal/i18n" "polar/internal/user" "github.com/gofiber/fiber/v2" ) const ( SessionCookieName = "polar_session" CSRFCookieName = "polar_csrf" ThemeCookieName = "polar_theme" LangCookieName = "polar_lang" ) func SecurityHeaders() fiber.Handler { return func(c *fiber.Ctx) error { c.Set("X-Content-Type-Options", "nosniff") c.Set("X-Frame-Options", "DENY") c.Set("Referrer-Policy", "strict-origin-when-cross-origin") c.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'") return c.Next() } } func RecoverAndLog(logger *slog.Logger) fiber.Handler { return func(c *fiber.Ctx) (err error) { start := time.Now() defer func() { if r := recover(); r != nil { logger.Error("panic recovered", "panic", r, "path", c.Path()) _ = c.Status(fiber.StatusInternalServerError).SendString("Internal Server Error") } }() err = c.Next() dur := time.Since(start) if !strings.HasPrefix(c.Path(), "/static/") && c.Path() != "/healthz" { logger.Info("http request", "method", c.Method(), "path", c.Path(), "status", c.Response().StatusCode(), "duration_ms", dur.Milliseconds(), ) } return err } } func LocaleAndTheme(defaultLang string) fiber.Handler { return func(c *fiber.Ctx) error { lang := c.Query("lang") if lang == "ru" || lang == "en" { c.Cookie(&fiber.Cookie{ Name: LangCookieName, Value: lang, Path: "/", MaxAge: 365 * 24 * 3600, HTTPOnly: true, SameSite: "Lax", }) } else { lang = c.Cookies(LangCookieName, defaultLang) } c.Locals("lang", i18n.Normalize(lang)) theme := c.Cookies(ThemeCookieName, "system") switch theme { case "light", "dark", "system": default: theme = "system" } c.Locals("theme", theme) return c.Next() } } func SessionAuth(authSvc *auth.Service, userSvc *user.Service) fiber.Handler { return func(c *fiber.Ctx) error { sessID := c.Cookies(SessionCookieName) if sessID != "" { if sess, err := authSvc.GetSession(c.UserContext(), sessID); err == nil && sess != nil { if u, err := userSvc.GetByID(c.UserContext(), sess.UserID); err == nil && u != nil { c.Locals("user", u) c.Locals("session", sess) c.Locals("csrf_token", sess.CSRFToken) return c.Next() } } } // Anonymous CSRF token via cookie csrfCookie := c.Cookies(CSRFCookieName) if len(csrfCookie) < 32 { b := make([]byte, 24) _, _ = rand.Read(b) csrfCookie = hex.EncodeToString(b) c.Cookie(&fiber.Cookie{ Name: CSRFCookieName, Value: csrfCookie, Path: "/", MaxAge: 86400, HTTPOnly: true, SameSite: "Lax", }) } c.Locals("csrf_token", csrfCookie) return c.Next() } } func CSRFProtection() fiber.Handler { return func(c *fiber.Ctx) error { method := c.Method() if method == fiber.MethodGet || method == fiber.MethodHead || method == fiber.MethodOptions { return c.Next() } // Git Smart HTTP endpoints use Basic Auth + Token p := c.Path() if strings.HasSuffix(p, "/git-upload-pack") || strings.HasSuffix(p, "/git-receive-pack") { return c.Next() } // Device flow endpoints are unauthenticated machine-to-machine calls. if strings.HasPrefix(p, "/api/v1/device/") { return c.Next() } expected, _ := c.Locals("csrf_token").(string) provided := c.FormValue("_csrf") if provided == "" { provided = c.Get("X-CSRF-Token") } if expected == "" || provided == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) != 1 { return c.Status(fiber.StatusForbidden).SendString("CSRF token mismatch") } return c.Next() } } func RequireAuth() fiber.Handler { return func(c *fiber.Ctx) error { if CurrentUser(c) == nil { return c.Redirect("/login", fiber.StatusSeeOther) } return c.Next() } } func RequireAdmin() fiber.Handler { return func(c *fiber.Ctx) error { u := CurrentUser(c) if u == nil { return c.Redirect("/login", fiber.StatusSeeOther) } if !u.IsAdmin { return c.Status(fiber.StatusForbidden).SendString("Forbidden: administrator access required") } return c.Next() } } func CurrentUser(c *fiber.Ctx) *user.User { u, _ := c.Locals("user").(*user.User) return u } type rateBucket struct { count int resetAt time.Time } type RateLimiter struct { mu sync.Mutex buckets map[string]*rateBucket limit int window time.Duration } func NewRateLimiter(limit int, window time.Duration) *RateLimiter { return &RateLimiter{ buckets: make(map[string]*rateBucket), limit: limit, window: window, } } func (rl *RateLimiter) Handler() fiber.Handler { return func(c *fiber.Ctx) error { ip := c.IP() if ip == "" { ip = "local" } now := time.Now() rl.mu.Lock() b, exists := rl.buckets[ip] if !exists || now.After(b.resetAt) { b = &rateBucket{count: 0, resetAt: now.Add(rl.window)} rl.buckets[ip] = b } b.count++ over := b.count > rl.limit rl.mu.Unlock() if over { return c.Status(fiber.StatusTooManyRequests).SendString("Too many requests, please try again in a minute.") } return c.Next() } }