Harden backend against critical and high security vulnerabilities
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m51s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m51s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,10 @@ func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*
|
|||||||
return nil, fmt.Errorf("could not get gin context")
|
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
|
var user models.User
|
||||||
if err := r.Store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
if err := r.Store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
||||||
return nil, fmt.Errorf("invalid credentials")
|
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.
|
// Users is the resolver for the users field.
|
||||||
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
|
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
var users []models.User
|
var users []models.User
|
||||||
if err := r.Store.DB.Find(&users).Error; err != nil {
|
if err := r.Store.DB.Find(&users).Error; err != nil {
|
||||||
return nil, err
|
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.
|
// User is the resolver for the user field.
|
||||||
func (r *queryResolver) User(ctx context.Context, id int) (*models.User, error) {
|
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
|
var user models.User
|
||||||
if err := r.Store.DB.First(&user, id).Error; err != nil {
|
if err := r.Store.DB.First(&user, id).Error; err != nil {
|
||||||
return nil, fmt.Errorf("user not found")
|
return nil, fmt.Errorf("user not found")
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) Login(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
|
var input UserCredentials
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ func (store *Store) UploadRadioSong(ctx *gin.Context) {
|
|||||||
filename := filepath.Base(file.Filename)
|
filename := filepath.Base(file.Filename)
|
||||||
dest := filepath.Join(fallbackMusicDir, 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 {
|
if _, err := os.Stat(dest); err == nil {
|
||||||
ctx.JSON(http.StatusConflict, gin.H{"error": "file already exists"})
|
ctx.JSON(http.StatusConflict, gin.H{"error": "file already exists"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Store struct {
|
|||||||
ClaudeClient *anthropic.Client
|
ClaudeClient *anthropic.Client
|
||||||
Auth *services.Auth
|
Auth *services.Auth
|
||||||
Notes *services.Notes
|
Notes *services.Notes
|
||||||
|
LoginLimiter *services.RateLimiter
|
||||||
|
|
||||||
RecentSongs *[]spotify.RecentlyPlayedItem
|
RecentSongs *[]spotify.RecentlyPlayedItem
|
||||||
RecentSongsFetchedAt time.Time
|
RecentSongsFetchedAt time.Time
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ func main() {
|
|||||||
steamAPIKey := os.Getenv("STEAM_API_KEY")
|
steamAPIKey := os.Getenv("STEAM_API_KEY")
|
||||||
steamID := os.Getenv("STEAM_ID")
|
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)
|
protected := r.Group("/", store.AuthMiddlewear)
|
||||||
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)
|
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)
|
||||||
@@ -119,7 +121,7 @@ func main() {
|
|||||||
protected.POST("/messages/upload", store.UploadMessageFile)
|
protected.POST("/messages/upload", store.UploadMessageFile)
|
||||||
|
|
||||||
// NOTES
|
// NOTES
|
||||||
r.GET("/notes/*path", store.GetNoteFile)
|
admin.GET("/notes/*path", store.GetNoteFile)
|
||||||
|
|
||||||
// GRAPHQL
|
// GRAPHQL
|
||||||
gqlSrv := handler.New(graph.NewExecutableSchema(graph.Config{
|
gqlSrv := handler.New(graph.NewExecutableSchema(graph.Config{
|
||||||
@@ -127,18 +129,18 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
gqlSrv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second})
|
gqlSrv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second})
|
||||||
gqlSrv.AddTransport(transport.Options{})
|
gqlSrv.AddTransport(transport.Options{})
|
||||||
gqlSrv.AddTransport(transport.GET{})
|
|
||||||
gqlSrv.AddTransport(transport.POST{})
|
gqlSrv.AddTransport(transport.POST{})
|
||||||
gqlSrv.AddTransport(transport.MultipartForm{})
|
gqlSrv.AddTransport(transport.MultipartForm{})
|
||||||
gqlSrv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
|
gqlSrv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
|
||||||
gqlSrv.Use(extension.FixedComplexityLimit(200))
|
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{})
|
gqlSrv.Use(extension.Introspection{})
|
||||||
}
|
}
|
||||||
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
|
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
|
||||||
gqlSrv.ServeHTTP(c.Writer, c.Request)
|
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) {
|
r.GET("/graphql", func(c *gin.Context) {
|
||||||
playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request)
|
playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request)
|
||||||
})
|
})
|
||||||
|
|||||||
45
backend/services/ratelimit.go
Normal file
45
backend/services/ratelimit.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,11 +24,12 @@ var Upgrader = websocket.Upgrader{
|
|||||||
if origin == "" {
|
if origin == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
origin = strings.TrimPrefix(origin, "https://")
|
u, err := url.Parse(origin)
|
||||||
origin = strings.TrimPrefix(origin, "http://")
|
if err != nil {
|
||||||
// Strip port for localhost comparisons (e.g. "localhost:80")
|
return false
|
||||||
host := strings.Split(origin, ":")[0]
|
}
|
||||||
return origin == allowedDomain || origin == "www."+allowedDomain || host == "localhost"
|
host := u.Hostname()
|
||||||
|
return host == allowedDomain || host == "www."+allowedDomain || host == "localhost"
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ http {
|
|||||||
|
|
||||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
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=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;
|
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||||
|
|
||||||
log_format compact
|
log_format compact
|
||||||
@@ -168,6 +169,16 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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/ {
|
location $BACKEND_ENDPOINT/ {
|
||||||
limit_req zone=api burst=20 nodelay;
|
limit_req zone=api burst=20 nodelay;
|
||||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
|||||||
Reference in New Issue
Block a user