mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 23:44:22 +00:00
Merge pull request #17 from LordMathis/feat/security-hardening
Implement Rate Limit, Secure Headers and CORS
This commit is contained in:
@@ -8,6 +8,10 @@ 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"
|
||||
@@ -67,10 +71,30 @@ 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
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(httprate.LimitByIP(cfg.RateLimitRequests, cfg.RateLimitWindow))
|
||||
api.SetupRoutes(r, database, fs, authMiddleware, sessionService)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,11 +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
|
||||
)
|
||||
|
||||
@@ -16,6 +19,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
|
||||
|
||||
@@ -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,10 @@ 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=
|
||||
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=
|
||||
@@ -87,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=
|
||||
@@ -113,8 +121,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=
|
||||
|
||||
@@ -4,27 +4,38 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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
|
||||
AppURL string
|
||||
CORSOrigins []string
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
EncryptionKey string
|
||||
JWTSigningKey string
|
||||
RateLimitRequests int
|
||||
RateLimitWindow time.Duration
|
||||
IsDevelopment bool
|
||||
}
|
||||
|
||||
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: 100,
|
||||
RateLimitWindow: time.Minute * 15,
|
||||
IsDevelopment: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,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
|
||||
}
|
||||
@@ -67,11 +82,32 @@ 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")
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user