rin
Публичный Open-source lightweight git hosting.
middleware.go · 223 строки · 5.3 KB
Raw
1 package middleware
2
3 import (
4 "crypto/rand"
5 "crypto/subtle"
6 "encoding/hex"
7 "log/slog"
8 "strings"
9 "sync"
10 "time"
11
12 "polar/internal/auth"
13 "polar/internal/i18n"
14 "polar/internal/user"
15
16 "github.com/gofiber/fiber/v2"
17 )
18
19 const (
20 SessionCookieName = "polar_session"
21 CSRFCookieName = "polar_csrf"
22 ThemeCookieName = "polar_theme"
23 LangCookieName = "polar_lang"
24 )
25
26 func SecurityHeaders() fiber.Handler {
27 return func(c *fiber.Ctx) error {
28 c.Set("X-Content-Type-Options", "nosniff")
29 c.Set("X-Frame-Options", "DENY")
30 c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
31 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'")
32 return c.Next()
33 }
34 }
35
36 func RecoverAndLog(logger *slog.Logger) fiber.Handler {
37 return func(c *fiber.Ctx) (err error) {
38 start := time.Now()
39 defer func() {
40 if r := recover(); r != nil {
41 logger.Error("panic recovered", "panic", r, "path", c.Path())
42 _ = c.Status(fiber.StatusInternalServerError).SendString("Internal Server Error")
43 }
44 }()
45
46 err = c.Next()
47 dur := time.Since(start)
48 if !strings.HasPrefix(c.Path(), "/static/") && c.Path() != "/healthz" {
49 logger.Info("http request",
50 "method", c.Method(),
51 "path", c.Path(),
52 "status", c.Response().StatusCode(),
53 "duration_ms", dur.Milliseconds(),
54 )
55 }
56 return err
57 }
58 }
59
60 func LocaleAndTheme(defaultLang string) fiber.Handler {
61 return func(c *fiber.Ctx) error {
62 lang := c.Query("lang")
63 if lang == "ru" || lang == "en" {
64 c.Cookie(&fiber.Cookie{
65 Name: LangCookieName,
66 Value: lang,
67 Path: "/",
68 MaxAge: 365 * 24 * 3600,
69 HTTPOnly: true,
70 SameSite: "Lax",
71 })
72 } else {
73 lang = c.Cookies(LangCookieName, defaultLang)
74 }
75 c.Locals("lang", i18n.Normalize(lang))
76
77 theme := c.Cookies(ThemeCookieName, "system")
78 switch theme {
79 case "light", "dark", "system":
80 default:
81 theme = "system"
82 }
83 c.Locals("theme", theme)
84 return c.Next()
85 }
86 }
87
88 func SessionAuth(authSvc *auth.Service, userSvc *user.Service) fiber.Handler {
89 return func(c *fiber.Ctx) error {
90 sessID := c.Cookies(SessionCookieName)
91 if sessID != "" {
92 if sess, err := authSvc.GetSession(c.UserContext(), sessID); err == nil && sess != nil {
93 if u, err := userSvc.GetByID(c.UserContext(), sess.UserID); err == nil && u != nil && u.IsApproved {
94 c.Locals("user", u)
95 c.Locals("session", sess)
96 c.Locals("csrf_token", sess.CSRFToken)
97 return c.Next()
98 }
99 }
100 }
101
102 // Anonymous CSRF token via cookie
103 csrfCookie := c.Cookies(CSRFCookieName)
104 if len(csrfCookie) < 32 {
105 b := make([]byte, 24)
106 _, _ = rand.Read(b)
107 csrfCookie = hex.EncodeToString(b)
108 c.Cookie(&fiber.Cookie{
109 Name: CSRFCookieName,
110 Value: csrfCookie,
111 Path: "/",
112 MaxAge: 86400,
113 HTTPOnly: true,
114 SameSite: "Lax",
115 })
116 }
117 c.Locals("csrf_token", csrfCookie)
118 return c.Next()
119 }
120 }
121
122 func CSRFProtection() fiber.Handler {
123 return func(c *fiber.Ctx) error {
124 method := c.Method()
125 if method == fiber.MethodGet || method == fiber.MethodHead || method == fiber.MethodOptions {
126 return c.Next()
127 }
128
129 // Git Smart HTTP endpoints use Basic Auth + Token
130 p := c.Path()
131 if strings.HasSuffix(p, "/git-upload-pack") || strings.HasSuffix(p, "/git-receive-pack") {
132 return c.Next()
133 }
134
135 // Device flow endpoints are unauthenticated machine-to-machine calls.
136 if strings.HasPrefix(p, "/api/v1/device/") {
137 return c.Next()
138 }
139
140 expected, _ := c.Locals("csrf_token").(string)
141 provided := c.FormValue("_csrf")
142 if provided == "" {
143 provided = c.Get("X-CSRF-Token")
144 }
145
146 if expected == "" || provided == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) != 1 {
147 return c.Status(fiber.StatusForbidden).SendString("CSRF token mismatch")
148 }
149 return c.Next()
150 }
151 }
152
153 func RequireAuth() fiber.Handler {
154 return func(c *fiber.Ctx) error {
155 if CurrentUser(c) == nil {
156 return c.Redirect("/login", fiber.StatusSeeOther)
157 }
158 return c.Next()
159 }
160 }
161
162 func RequireAdmin() fiber.Handler {
163 return func(c *fiber.Ctx) error {
164 u := CurrentUser(c)
165 if u == nil {
166 return c.Redirect("/login", fiber.StatusSeeOther)
167 }
168 if !u.IsAdmin {
169 return c.Status(fiber.StatusForbidden).SendString("Forbidden: administrator access required")
170 }
171 return c.Next()
172 }
173 }
174
175 func CurrentUser(c *fiber.Ctx) *user.User {
176 u, _ := c.Locals("user").(*user.User)
177 return u
178 }
179
180 type rateBucket struct {
181 count int
182 resetAt time.Time
183 }
184
185 type RateLimiter struct {
186 mu sync.Mutex
187 buckets map[string]*rateBucket
188 limit int
189 window time.Duration
190 }
191
192 func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
193 return &RateLimiter{
194 buckets: make(map[string]*rateBucket),
195 limit: limit,
196 window: window,
197 }
198 }
199
200 func (rl *RateLimiter) Handler() fiber.Handler {
201 return func(c *fiber.Ctx) error {
202 ip := c.IP()
203 if ip == "" {
204 ip = "local"
205 }
206 now := time.Now()
207
208 rl.mu.Lock()
209 b, exists := rl.buckets[ip]
210 if !exists || now.After(b.resetAt) {
211 b = &rateBucket{count: 0, resetAt: now.Add(rl.window)}
212 rl.buckets[ip] = b
213 }
214 b.count++
215 over := b.count > rl.limit
216 rl.mu.Unlock()
217
218 if over {
219 return c.Status(fiber.StatusTooManyRequests).SendString("Too many requests, please try again in a minute.")
220 }
221 return c.Next()
222 }
223 }
224