From b56f8253d950d2c63cd3a402b19b88527a0bc332 Mon Sep 17 00:00:00 2001 From: Adam French Date: Tue, 14 Apr 2026 13:27:19 +0100 Subject: [PATCH] Harden backend against critical and high security vulnerabilities - Fix WebSocket CheckOrigin to use proper url.Parse instead of string stripping - Add admin auth checks to Users/User GraphQL queries - Remove GraphQL GET transport to prevent CSRF via cross-site links - Add application-level IP-based login rate limiting (5 attempts/min) - Add path traversal bounds check on radio file upload - Require DEV_MODE for GraphQL introspection and playground - Move notes backend endpoint behind admin middleware - Add dedicated Nginx rate limit zone for GraphQL (10r/s) Co-Authored-By: Claude Sonnet 4.6 --- backend/graph/schema.resolvers.go | 10 +++++++ backend/handlers/handle_auth.go | 5 ++++ backend/handlers/handle_radio.go | 7 +++++ backend/handlers/store.go | 1 + backend/main.go | 12 +++++---- backend/services/ratelimit.go | 45 +++++++++++++++++++++++++++++++ backend/services/websocket.go | 13 ++++----- nginx/nginx.conf.template | 11 ++++++++ 8 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 backend/services/ratelimit.go diff --git a/backend/graph/schema.resolvers.go b/backend/graph/schema.resolvers.go index 67152b7..ec5ce02 100644 --- a/backend/graph/schema.resolvers.go +++ b/backend/graph/schema.resolvers.go @@ -25,6 +25,10 @@ func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (* return nil, fmt.Errorf("could not get gin context") } + if !r.Store.LoginLimiter.Allow(gc.ClientIP()) { + return nil, fmt.Errorf("too many login attempts, please try again later") + } + var user models.User if err := r.Store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil { return nil, fmt.Errorf("invalid credentials") @@ -446,6 +450,9 @@ func (r *mutationResolver) DeleteJobAppReference(ctx context.Context, id int) (b // Users is the resolver for the users field. func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) { + if !IsAdminFromCtx(ctx) { + return nil, fmt.Errorf("admin access required") + } var users []models.User if err := r.Store.DB.Find(&users).Error; err != nil { return nil, err @@ -459,6 +466,9 @@ func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) { // User is the resolver for the user field. func (r *queryResolver) User(ctx context.Context, id int) (*models.User, error) { + if !IsAdminFromCtx(ctx) { + return nil, fmt.Errorf("admin access required") + } var user models.User if err := r.Store.DB.First(&user, id).Error; err != nil { return nil, fmt.Errorf("user not found") diff --git a/backend/handlers/handle_auth.go b/backend/handlers/handle_auth.go index 7b140d4..bfe77ab 100644 --- a/backend/handlers/handle_auth.go +++ b/backend/handlers/handle_auth.go @@ -226,6 +226,11 @@ func (store *Store) RefreshToken(ctx *gin.Context) { } func (store *Store) Login(ctx *gin.Context) { + if !store.LoginLimiter.Allow(ctx.ClientIP()) { + ctx.JSON(http.StatusTooManyRequests, gin.H{"error": "too many login attempts, please try again later"}) + return + } + var input UserCredentials if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) diff --git a/backend/handlers/handle_radio.go b/backend/handlers/handle_radio.go index 6b0209c..c7b860a 100644 --- a/backend/handlers/handle_radio.go +++ b/backend/handlers/handle_radio.go @@ -37,6 +37,13 @@ func (store *Store) UploadRadioSong(ctx *gin.Context) { filename := filepath.Base(file.Filename) dest := filepath.Join(fallbackMusicDir, filename) + // Verify the resolved path stays within the music directory + absDest, err := filepath.Abs(dest) + if err != nil || !strings.HasPrefix(absDest, fallbackMusicDir+string(os.PathSeparator)) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"}) + return + } + if _, err := os.Stat(dest); err == nil { ctx.JSON(http.StatusConflict, gin.H{"error": "file already exists"}) return diff --git a/backend/handlers/store.go b/backend/handlers/store.go index 0216ae1..1183a99 100644 --- a/backend/handlers/store.go +++ b/backend/handlers/store.go @@ -17,6 +17,7 @@ type Store struct { ClaudeClient *anthropic.Client Auth *services.Auth Notes *services.Notes + LoginLimiter *services.RateLimiter RecentSongs *[]spotify.RecentlyPlayedItem RecentSongsFetchedAt time.Time diff --git a/backend/main.go b/backend/main.go index 6f1be27..f943a81 100644 --- a/backend/main.go +++ b/backend/main.go @@ -85,7 +85,9 @@ func main() { steamAPIKey := os.Getenv("STEAM_API_KEY") steamID := os.Getenv("STEAM_ID") - store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes, GiteaHost: giteaHost, GiteaPort: giteaPort, SteamAPIKey: steamAPIKey, SteamID: steamID} + loginLimiter := services.NewRateLimiter(5, time.Minute) + + store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes, LoginLimiter: loginLimiter, GiteaHost: giteaHost, GiteaPort: giteaPort, SteamAPIKey: steamAPIKey, SteamID: steamID} protected := r.Group("/", store.AuthMiddlewear) admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware) @@ -119,7 +121,7 @@ func main() { protected.POST("/messages/upload", store.UploadMessageFile) // NOTES - r.GET("/notes/*path", store.GetNoteFile) + admin.GET("/notes/*path", store.GetNoteFile) // GRAPHQL gqlSrv := handler.New(graph.NewExecutableSchema(graph.Config{ @@ -127,18 +129,18 @@ func main() { })) gqlSrv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second}) gqlSrv.AddTransport(transport.Options{}) - gqlSrv.AddTransport(transport.GET{}) gqlSrv.AddTransport(transport.POST{}) gqlSrv.AddTransport(transport.MultipartForm{}) gqlSrv.SetQueryCache(lru.New[*ast.QueryDocument](1000)) gqlSrv.Use(extension.FixedComplexityLimit(200)) - if os.Getenv("GQL_INTROSPECTION") == "true" { + devMode := os.Getenv("DEV_MODE") == "true" + if devMode && os.Getenv("GQL_INTROSPECTION") == "true" { gqlSrv.Use(extension.Introspection{}) } r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) { gqlSrv.ServeHTTP(c.Writer, c.Request) }) - if os.Getenv("GQL_PLAYGROUND") == "true" { + if devMode && os.Getenv("GQL_PLAYGROUND") == "true" { r.GET("/graphql", func(c *gin.Context) { playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request) }) diff --git a/backend/services/ratelimit.go b/backend/services/ratelimit.go new file mode 100644 index 0000000..70675a6 --- /dev/null +++ b/backend/services/ratelimit.go @@ -0,0 +1,45 @@ +package services + +import ( + "sync" + "time" +) + +type RateLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time + max int + window time.Duration +} + +func NewRateLimiter(max int, window time.Duration) *RateLimiter { + return &RateLimiter{ + attempts: make(map[string][]time.Time), + max: max, + window: window, + } +} + +func (rl *RateLimiter) Allow(key string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + cutoff := now.Add(-rl.window) + + // Remove expired entries + valid := rl.attempts[key][:0] + for _, t := range rl.attempts[key] { + if t.After(cutoff) { + valid = append(valid, t) + } + } + + if len(valid) >= rl.max { + rl.attempts[key] = valid + return false + } + + rl.attempts[key] = append(valid, now) + return true +} diff --git a/backend/services/websocket.go b/backend/services/websocket.go index 0d31f61..0f779f0 100644 --- a/backend/services/websocket.go +++ b/backend/services/websocket.go @@ -2,7 +2,7 @@ package services import ( "net/http" - "strings" + "net/url" "sync" "time" @@ -24,11 +24,12 @@ var Upgrader = websocket.Upgrader{ if origin == "" { return false } - origin = strings.TrimPrefix(origin, "https://") - origin = strings.TrimPrefix(origin, "http://") - // Strip port for localhost comparisons (e.g. "localhost:80") - host := strings.Split(origin, ":")[0] - return origin == allowedDomain || origin == "www."+allowedDomain || host == "localhost" + u, err := url.Parse(origin) + if err != nil { + return false + } + host := u.Hostname() + return host == allowedDomain || host == "www."+allowedDomain || host == "localhost" }, } diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index daa7ff9..76ba84e 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -13,6 +13,7 @@ http { limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; + limit_req_zone $binary_remote_addr zone=graphql:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m; log_format compact @@ -168,6 +169,16 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location $BACKEND_ENDPOINT/graphql { + limit_req zone=graphql burst=10 nodelay; + rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; + proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location $BACKEND_ENDPOINT/ { limit_req zone=api burst=20 nodelay; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;