@@ -144,6 +144,12 @@ func main() {
144144 // Initialize Gin
145145 gin .SetMode (gin .ReleaseMode )
146146 r := gin .Default ()
147+ trustedProxies := parseCommaListEnv ("TRUSTED_PROXIES" )
148+ if err := r .SetTrustedProxies (trustedProxies ); err != nil {
149+ log .Fatalf ("Invalid TRUSTED_PROXIES: %v" , err )
150+ }
151+ r .Use (apiSecurityHeadersMiddleware ())
152+ r .Use (apiRequestBodyLimitMiddleware (int64 (readIntEnv ("API_MAX_BODY_BYTES" , 1 << 20 ))))
147153 r .Use (apiCorsMiddlewareFromEnv ())
148154
149155 // Initialize handler
@@ -185,6 +191,7 @@ func main() {
185191 // Settings
186192 authorized .GET ("/settings" , handler .GetSettings )
187193 authorized .PUT ("/settings" , handler .UpdateSettings )
194+ authorized .POST ("/logout" , handler .Logout )
188195 }
189196
190197 // Serve frontend static files
@@ -258,6 +265,30 @@ func registerFrontendRoutes(r *gin.Engine, frontendDistDir string, appVersion st
258265 r .GET ("/assets/*filepath" , withAssetsHeaders , assetsHandler )
259266 r .HEAD ("/assets/*filepath" , withAssetsHeaders , assetsHandler )
260267
268+ serveStaticFile := func (route string , fileName string ) {
269+ handler := func (c * gin.Context ) {
270+ filePath := filepath .Join (frontendDistDir , fileName )
271+ if _ , err := os .Stat (filePath ); err != nil {
272+ c .JSON (http .StatusNotFound , gin.H {"error" : "not found" })
273+ return
274+ }
275+ c .Header ("Cache-Control" , assetsCacheControlHeader )
276+ c .Header ("X-App-Version" , appVersion )
277+ c .Header ("X-Frontend-Fingerprint" , indexFingerprint )
278+ c .File (filePath )
279+ }
280+ r .GET (route , handler )
281+ r .HEAD (route , handler )
282+ }
283+
284+ serveStaticFile ("/logo.svg" , "logo.svg" )
285+ r .GET ("/favicon.ico" , func (c * gin.Context ) {
286+ c .Redirect (http .StatusMovedPermanently , "/logo.svg" )
287+ })
288+ r .HEAD ("/favicon.ico" , func (c * gin.Context ) {
289+ c .Redirect (http .StatusMovedPermanently , "/logo.svg" )
290+ })
291+
261292 r .GET ("/" , serveIndex )
262293 r .HEAD ("/" , serveIndex )
263294 r .NoRoute (func (c * gin.Context ) {
@@ -280,7 +311,7 @@ func apiCorsMiddlewareFromEnv() gin.HandlerFunc {
280311 allowed := parseCommaListEnv ("CORS_ALLOWED_ORIGINS" )
281312 if len (allowed ) == 0 {
282313 return func (c * gin.Context ) {
283- if strings . HasPrefix (c .Request .URL .Path , "/api" ) && c .Request .Method == http .MethodOptions {
314+ if isAPIRequest (c .Request .URL .Path ) && c .Request .Method == http .MethodOptions {
284315 c .AbortWithStatus (http .StatusNoContent )
285316 return
286317 }
@@ -298,7 +329,7 @@ func apiCorsMiddlewareFromEnv() gin.HandlerFunc {
298329 }
299330
300331 return func (c * gin.Context ) {
301- if ! strings . HasPrefix (c .Request .URL .Path , "/api" ) {
332+ if ! isAPIRequest (c .Request .URL .Path ) {
302333 c .Next ()
303334 return
304335 }
@@ -322,6 +353,46 @@ func apiCorsMiddlewareFromEnv() gin.HandlerFunc {
322353 }
323354}
324355
356+ func apiSecurityHeadersMiddleware () gin.HandlerFunc {
357+ return func (c * gin.Context ) {
358+ c .Header ("X-Frame-Options" , "DENY" )
359+ c .Header ("X-Content-Type-Options" , "nosniff" )
360+ c .Header ("Referrer-Policy" , "strict-origin-when-cross-origin" )
361+ c .Header ("X-XSS-Protection" , "0" )
362+ c .Header ("Permissions-Policy" , "camera=(), microphone=(), geolocation=()" )
363+ c .Header ("Cross-Origin-Opener-Policy" , "same-origin" )
364+ c .Header ("Cross-Origin-Resource-Policy" , "same-origin" )
365+ c .Header ("Content-Security-Policy" , "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" )
366+
367+ if c .Request .TLS != nil || strings .EqualFold (c .GetHeader ("X-Forwarded-Proto" ), "https" ) {
368+ c .Header ("Strict-Transport-Security" , "max-age=31536000; includeSubDomains" )
369+ }
370+
371+ c .Next ()
372+ }
373+ }
374+
375+ func apiRequestBodyLimitMiddleware (maxBytes int64 ) gin.HandlerFunc {
376+ if maxBytes <= 0 {
377+ return func (c * gin.Context ) { c .Next () }
378+ }
379+
380+ return func (c * gin.Context ) {
381+ if isAPIRequest (c .Request .URL .Path ) {
382+ if c .Request .ContentLength > maxBytes {
383+ c .AbortWithStatusJSON (http .StatusRequestEntityTooLarge , gin.H {"error" : "request body too large" })
384+ return
385+ }
386+ c .Request .Body = http .MaxBytesReader (c .Writer , c .Request .Body , maxBytes )
387+ }
388+ c .Next ()
389+ }
390+ }
391+
392+ func isAPIRequest (path string ) bool {
393+ return path == "/api" || strings .HasPrefix (path , "/api/" )
394+ }
395+
325396func parseCommaListEnv (key string ) []string {
326397 raw := strings .TrimSpace (os .Getenv (key ))
327398 if raw == "" {
0 commit comments