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