From 77d9abb69138f210bebe617aaabb872648836769 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 10 Nov 2024 18:12:25 +0100 Subject: [PATCH 1/4] Implement rate limiting --- backend/cmd/server/main.go | 2 ++ backend/go.mod | 2 ++ backend/go.sum | 8 ++++-- backend/internal/config/config.go | 43 ++++++++++++++++++++++--------- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 671fc54..a9394b3 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httprate" "novamd/internal/api" "novamd/internal/auth" @@ -71,6 +72,7 @@ func main() { // Set up routes r.Route("/api/v1", func(r chi.Router) { + r.Use(httprate.LimitByIP(cfg.RateLimitRequests, cfg.RateLimitWindow)) api.SetupRoutes(r, database, fs, authMiddleware, sessionService) }) diff --git a/backend/go.mod b/backend/go.mod index d3e5499..7b034ed 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.23.1 require ( github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/httprate v0.14.1 github.com/go-git/go-git/v5 v5.12.0 github.com/go-playground/validator/v10 v10.22.1 github.com/golang-jwt/jwt/v5 v5.2.1 @@ -16,6 +17,7 @@ require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/emirpasic/gods v1.18.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index b2b586e..fa718fd 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -10,6 +10,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= @@ -28,6 +30,8 @@ github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= +github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= @@ -113,8 +117,8 @@ golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b81e28e..3b9c862 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -4,27 +4,33 @@ import ( "fmt" "os" "path/filepath" + "strconv" + "time" "novamd/internal/crypto" ) type Config struct { - DBPath string - WorkDir string - StaticPath string - Port string - AdminEmail string - AdminPassword string - EncryptionKey string - JWTSigningKey string + DBPath string + WorkDir string + StaticPath string + Port string + AdminEmail string + AdminPassword string + EncryptionKey string + JWTSigningKey string + RateLimitRequests int + RateLimitWindow time.Duration } func DefaultConfig() *Config { return &Config{ - DBPath: "./novamd.db", - WorkDir: "./data", - StaticPath: "../frontend/dist", - Port: "8080", + DBPath: "./novamd.db", + WorkDir: "./data", + StaticPath: "../frontend/dist", + Port: "8080", + RateLimitRequests: int(10), + RateLimitWindow: time.Minute, } } @@ -72,6 +78,19 @@ func Load() (*Config, error) { config.EncryptionKey = os.Getenv("NOVAMD_ENCRYPTION_KEY") config.JWTSigningKey = os.Getenv("NOVAMD_JWT_SIGNING_KEY") + // Configure rate limiting + if reqStr := os.Getenv("NOVAMD_RATE_LIMIT_REQUESTS"); reqStr != "" { + if parsed, err := strconv.Atoi(reqStr); err == nil { + config.RateLimitRequests = parsed + } + } + + if windowStr := os.Getenv("NOVAMD_RATE_LIMIT_WINDOW"); windowStr != "" { + if parsed, err := time.ParseDuration(windowStr); err == nil { + config.RateLimitWindow = parsed + } + } + // Validate all settings if err := config.Validate(); err != nil { return nil, err From e275b45c86694ae543578f7ed82a1422d3e06eb5 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 10 Nov 2024 20:43:24 +0100 Subject: [PATCH 2/4] Add secure headers and cors middlewares --- backend/cmd/server/main.go | 22 ++++++++++++++++++++++ backend/go.mod | 2 ++ backend/go.sum | 4 ++++ backend/internal/config/config.go | 17 +++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a9394b3..96cdda5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -8,8 +8,11 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" "github.com/go-chi/httprate" + "github.com/unrolled/secure" + "novamd/internal/api" "novamd/internal/auth" "novamd/internal/config" @@ -68,6 +71,25 @@ func main() { r.Use(middleware.Recoverer) r.Use(middleware.RequestID) r.Use(middleware.RealIP) + + // Security headers + r.Use(secure.New(secure.Options{ + SSLRedirect: false, // Let proxy handle HTTPS + SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"}, + IsDevelopment: cfg.IsDevelopment, + }).Handler) + + // CORS if origins are configured + if len(cfg.CORSOrigins) > 0 { + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: cfg.CORSOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"}, + AllowCredentials: true, + MaxAge: 300, // Maximum value not ignored by any major browser + })) + } + r.Use(middleware.Timeout(30 * time.Second)) // Set up routes diff --git a/backend/go.mod b/backend/go.mod index 7b034ed..0af2cf4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,12 +4,14 @@ go 1.23.1 require ( github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/cors v1.2.1 github.com/go-chi/httprate v0.14.1 github.com/go-git/go-git/v5 v5.12.0 github.com/go-playground/validator/v10 v10.22.1 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/mattn/go-sqlite3 v1.14.23 + github.com/unrolled/secure v1.17.0 golang.org/x/crypto v0.21.0 ) diff --git a/backend/go.sum b/backend/go.sum index fa718fd..426c423 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -30,6 +30,8 @@ github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -91,6 +93,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= +github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3b9c862..834d2ca 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" "novamd/internal/crypto" @@ -15,12 +16,15 @@ type Config struct { WorkDir string StaticPath string Port string + AppURL string + CORSOrigins []string AdminEmail string AdminPassword string EncryptionKey string JWTSigningKey string RateLimitRequests int RateLimitWindow time.Duration + IsDevelopment bool } func DefaultConfig() *Config { @@ -31,6 +35,7 @@ func DefaultConfig() *Config { Port: "8080", RateLimitRequests: int(10), RateLimitWindow: time.Minute, + IsDevelopment: false, } } @@ -51,6 +56,10 @@ func (c *Config) Validate() error { func Load() (*Config, error) { config := DefaultConfig() + if env := os.Getenv("NOVAMD_ENV"); env != "" { + config.IsDevelopment = env == "development" + } + if dbPath := os.Getenv("NOVAMD_DB_PATH"); dbPath != "" { config.DBPath = dbPath } @@ -73,6 +82,14 @@ func Load() (*Config, error) { config.Port = port } + if appURL := os.Getenv("NOVAMD_APP_URL"); appURL != "" { + config.AppURL = appURL + } + + if corsOrigins := os.Getenv("NOVAMD_CORS_ORIGINS"); corsOrigins != "" { + config.CORSOrigins = strings.Split(corsOrigins, ",") + } + config.AdminEmail = os.Getenv("NOVAMD_ADMIN_EMAIL") config.AdminPassword = os.Getenv("NOVAMD_ADMIN_PASSWORD") config.EncryptionKey = os.Getenv("NOVAMD_ENCRYPTION_KEY") From 29b35f6b919606ba28f92c8a9d3d0cbb94f76977 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 10 Nov 2024 20:49:07 +0100 Subject: [PATCH 3/4] Add password length check --- backend/internal/handlers/admin_handlers.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/backend/internal/handlers/admin_handlers.go b/backend/internal/handlers/admin_handlers.go index dc9ab0c..3a212bc 100644 --- a/backend/internal/handlers/admin_handlers.go +++ b/backend/internal/handlers/admin_handlers.go @@ -14,14 +14,14 @@ import ( "golang.org/x/crypto/bcrypt" ) -type CreateUserRequest struct { +type createUserRequest struct { Email string `json:"email"` DisplayName string `json:"displayName"` Password string `json:"password"` Role models.UserRole `json:"role"` } -type UpdateUserRequest struct { +type updateUserRequest struct { Email string `json:"email,omitempty"` DisplayName string `json:"displayName,omitempty"` Password string `json:"password,omitempty"` @@ -44,7 +44,7 @@ func (h *Handler) AdminListUsers() http.HandlerFunc { // AdminCreateUser creates a new user func (h *Handler) AdminCreateUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req CreateUserRequest + var req createUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return @@ -63,6 +63,12 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc { return } + // Check if password is long enough + if len(req.Password) < 8 { + http.Error(w, "Password must be at least 8 characters", http.StatusBadRequest) + return + } + // Hash password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { @@ -129,7 +135,7 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc { return } - var req UpdateUserRequest + var req updateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return From d4c671caa78bb45dbed2f84489988289cda29764 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 10 Nov 2024 20:56:04 +0100 Subject: [PATCH 4/4] Increase default rate limit --- backend/internal/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 834d2ca..56485d0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -33,8 +33,8 @@ func DefaultConfig() *Config { WorkDir: "./data", StaticPath: "../frontend/dist", Port: "8080", - RateLimitRequests: int(10), - RateLimitWindow: time.Minute, + RateLimitRequests: 100, + RateLimitWindow: time.Minute * 15, IsDevelopment: false, } }