diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index a102a9f7..2f2d4852 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -65,18 +65,18 @@ type authSuccessResponse struct { func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, trustedProxies []string) { registerLimiter := limiter loginLimiter := limiter - partialStore := auth.NewPartialAuthStore(10 * time.Minute) - pendingTOTPStore := auth.NewPendingTOTPStore(10 * time.Minute) + partialStore := auth.NewPartialAuthStore(partialAuthStoreTTL) + pendingTOTPStore := auth.NewPendingTOTPStore(pendingTOTPStoreTTL) usedTOTPCodes := auth.NewUsedTOTPCodeStore() r.Route("/api/v1/auth", func(r chi.Router) { - r.With(RateLimitMiddleware(registerLimiter, 3, time.Minute, trustedProxies)). + r.With(RateLimitMiddleware(registerLimiter, registerRateLimitPerMinute, time.Minute, trustedProxies)). Post("/register", handleRegister(database)) - r.With(RateLimitMiddleware(loginLimiter, 60, time.Minute, trustedProxies)). + r.With(RateLimitMiddleware(loginLimiter, loginRateLimitPerMinute, time.Minute, trustedProxies)). Post("/login", handleLogin(database, limiter, partialStore, trustedProxies)) - r.With(RateLimitMiddleware(limiter, 10, time.Minute, trustedProxies)). + r.With(RateLimitMiddleware(limiter, verifyTOTPRateLimitPerMinute, time.Minute, trustedProxies)). Post("/verify-totp", handleVerifyTOTP(database, partialStore, limiter, usedTOTPCodes)) r.With(AuthMiddleware(database)). @@ -86,20 +86,20 @@ func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, t Get("/me", handleMe()) r.With(AuthMiddleware(database), - RateLimitMiddleware(limiter, 5, time.Minute, trustedProxies)). + RateLimitMiddleware(limiter, sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). Delete("/account", handleDeleteAccount(database, limiter)) }) r.With(AuthMiddleware(database), - RateLimitMiddleware(limiter, 5, time.Minute, trustedProxies)). + RateLimitMiddleware(limiter, sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). Post("/api/v1/users/me/totp/enable", handleEnableTOTP(pendingTOTPStore)) r.With(AuthMiddleware(database), - RateLimitMiddleware(limiter, 5, time.Minute, trustedProxies)). + RateLimitMiddleware(limiter, sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). Post("/api/v1/users/me/totp/confirm", handleConfirmTOTP(database, pendingTOTPStore, usedTOTPCodes)) r.With(AuthMiddleware(database), - RateLimitMiddleware(limiter, 5, time.Minute, trustedProxies)). + RateLimitMiddleware(limiter, sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). Delete("/api/v1/users/me/totp", handleDisableTOTP(database, pendingTOTPStore)) } @@ -193,11 +193,12 @@ func handleRegister(database *db.DB) http.HandlerFunc { if err != nil { // UNIQUE constraint violation → duplicate username → 400. // Any other DB error → 500. - if db.IsUniqueConstraintError(err) { + switch { + case db.IsUniqueConstraintError(err): writeJSON(w, http.StatusBadRequest, genericAuthError) - } else if errors.Is(err, db.ErrNotFound) { + case errors.Is(err, db.ErrNotFound): writeJSON(w, http.StatusBadRequest, genericAuthError) - } else { + default: slog.Error("CreateUserWithInvite failed", "err", err, "username", req.Username) writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -306,8 +307,8 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. failKey := "login_fail:" + ip if user == nil || !auth.CheckPassword(user.PasswordHash, req.Password) { // Track failures; lockout on the 10th failure. - if !limiter.Allow(failKey, 9, 15*time.Minute) { - limiter.Lockout(lockKey, 15*time.Minute) + if !limiter.Allow(failKey, loginFailureThreshold, loginFailureWindow) { + limiter.Lockout(lockKey, loginLockoutDuration) } slog.Info("login failed", "ip", ip, "username_len", len(req.Username)) writeJSON(w, http.StatusUnauthorized, errorResponse{ @@ -478,8 +479,8 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.Handle // Verify the supplied password matches the stored hash. failKey := fmt.Sprintf("delete_fail:%d", user.ID) if !auth.CheckPassword(user.PasswordHash, req.Password) { - if !limiter.Allow(failKey, 3, 15*time.Minute) { - limiter.Lockout(lockKey, 15*time.Minute) + if !limiter.Allow(failKey, deleteAccountFailureThreshold, deleteAccountFailureWindow) { + limiter.Lockout(lockKey, deleteAccountLockoutDuration) } writeJSON(w, http.StatusBadRequest, errorResponse{ Error: "INVALID_INPUT", diff --git a/Server/api/channel_handler.go b/Server/api/channel_handler.go index 8173c7ac..b0b49d5c 100644 --- a/Server/api/channel_handler.go +++ b/Server/api/channel_handler.go @@ -61,7 +61,7 @@ func MountChannelRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter }) r.With( AuthMiddleware(database), - searchRateLimitMiddleware(limiter, 30, time.Minute, trustedProxies), + searchRateLimitMiddleware(limiter, searchRateLimitPerMinute, time.Minute, trustedProxies), ).Get("/api/v1/search", handleSearch(database)) } @@ -128,9 +128,9 @@ func handleListChannels(database *db.DB) http.HandlerFunc { // Filter channels by READ_MESSAGES permission. var visible []db.Channel - for _, ch := range channels { - if hasChannelPermBatch(role, overrides, ch.ID, permissions.ReadMessages) { - visible = append(visible, ch) + for i := range channels { + if hasChannelPermBatch(role, overrides, channels[i].ID, permissions.ReadMessages) { + visible = append(visible, channels[i]) } } if visible == nil { @@ -386,12 +386,12 @@ func handleSearch(database *db.DB) http.HandlerFunc { } var accessibleIDs []int64 - for _, ch := range allChannels { - if ch.Type == "dm" { + for i := range allChannels { + if allChannels[i].Type == "dm" { continue // DM channels handled separately below. } - if hasChannelPermBatch(role, overrides, ch.ID, permissions.ReadMessages) { - accessibleIDs = append(accessibleIDs, ch.ID) + if hasChannelPermBatch(role, overrides, allChannels[i].ID, permissions.ReadMessages) { + accessibleIDs = append(accessibleIDs, allChannels[i].ID) } } diff --git a/Server/api/constants.go b/Server/api/constants.go new file mode 100644 index 00000000..fe55850e --- /dev/null +++ b/Server/api/constants.go @@ -0,0 +1,108 @@ +package api + +import "time" + +// ─── Rate limits ──────────────────────────────────────────────────────────── +// +// Each constant defines either a request cap or a sliding-window duration used +// by the per-endpoint rate limiters. + +const ( + // registerRateLimitPerMinute is the maximum registration attempts per IP per minute. + registerRateLimitPerMinute = 3 + + // loginRateLimitPerMinute is the maximum login attempts per IP per minute. + loginRateLimitPerMinute = 60 + + // verifyTOTPRateLimitPerMinute is the maximum TOTP verification attempts per IP per minute. + verifyTOTPRateLimitPerMinute = 10 + + // sensitiveEndpointRateLimitPerMinute is the rate limit applied to destructive + // or sensitive endpoints (account deletion, TOTP enable/confirm/disable). + sensitiveEndpointRateLimitPerMinute = 5 + + // searchRateLimitPerMinute is the maximum full-text search requests per IP per minute. + searchRateLimitPerMinute = 30 + + // livekitProxyRateLimitPerMinute is the maximum LiveKit proxy requests per IP per minute. + livekitProxyRateLimitPerMinute = 30 + + // loginFailureThreshold is the number of failed login attempts (within + // loginFailureWindow) before the IP is locked out. + loginFailureThreshold = 9 + + // loginFailureWindow is the sliding window for counting login failures. + loginFailureWindow = 15 * time.Minute + + // loginLockoutDuration is how long an IP is locked out after exceeding + // loginFailureThreshold. + loginLockoutDuration = 15 * time.Minute + + // deleteAccountFailureThreshold is the number of wrong-password attempts + // before the per-user lockout kicks in. + deleteAccountFailureThreshold = 3 + + // deleteAccountFailureWindow is the sliding window for counting + // delete-account password failures. + deleteAccountFailureWindow = 15 * time.Minute + + // deleteAccountLockoutDuration is how long the account-deletion endpoint + // is locked after exceeding deleteAccountFailureThreshold. + deleteAccountLockoutDuration = 15 * time.Minute + + // totpFailureRateLimit is the maximum TOTP verification failures per user + // within totpFailureWindow before the user is rate-limited. + totpFailureRateLimit = 10 + + // totpFailureWindow is the sliding window for counting per-user TOTP failures. + totpFailureWindow = 15 * time.Minute + + // partialAuthMaxFailures is the number of failed TOTP attempts on a single + // partial-auth challenge before it is revoked. + partialAuthMaxFailures = 5 + + // profilePasswordRateLimitPerMinute is the maximum password change attempts + // per IP per minute. + profilePasswordRateLimitPerMinute = 5 +) + +// ─── Timeouts & TTLs ──────────────────────────────────────────────────────── + +const ( + // partialAuthStoreTTL is the lifetime of a partial-auth (2FA) challenge token. + partialAuthStoreTTL = 10 * time.Minute + + // pendingTOTPStoreTTL is the lifetime of a pending TOTP enrollment secret. + pendingTOTPStoreTTL = 10 * time.Minute + + // rateLimiterCleanupInterval is how often stale rate-limiter entries are reaped. + rateLimiterCleanupInterval = 5 * time.Minute + + // rateLimiterCleanupMaxWindow is the maximum window considered when pruning + // stale rate-limiter entries. + rateLimiterCleanupMaxWindow = 15 * time.Minute + + // hstsMaxAgeSeconds is the max-age value for the Strict-Transport-Security header. + hstsMaxAgeSeconds = 31536000 + + // fileCacheMaxAgeSeconds is the max-age value for the Cache-Control header on served files. + fileCacheMaxAgeSeconds = 31536000 +) + +// ─── Size limits ──────────────────────────────────────────────────────────── + +const ( + // defaultMaxBodySize is the default request body size limit (1 MiB). + defaultMaxBodySize = 1 << 20 + + // uploadMaxBodySize is the request body size limit for file uploads (100 MiB). + uploadMaxBodySize = 100 << 20 + + // multipartMemoryLimit is the in-memory limit for multipart form parsing; + // data beyond this is spilled to disk. + multipartMemoryLimit = 10 << 20 + + // maxUploadFilenameLength is the maximum length of an upload filename + // (filesystem-safe limit). + maxUploadFilenameLength = 255 +) diff --git a/Server/api/middleware.go b/Server/api/middleware.go index b1bcb336..4bea65b8 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -296,7 +296,7 @@ func SecurityHeadersWithTLS(tlsMode string) func(http.Handler) http.Handler { h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") h.Set("Cache-Control", "no-store") if tlsMode != "" { - h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + h.Set("Strict-Transport-Security", fmt.Sprintf("max-age=%d; includeSubDomains", hstsMaxAgeSeconds)) } next.ServeHTTP(w, r) }) diff --git a/Server/api/router.go b/Server/api/router.go index ba1fe6a8..bfefa76d 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -34,7 +34,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri r.Use(middleware.Recoverer) r.Use(requestLogger) // structured request/response logging r.Use(SecurityHeadersWithTLS(cfg.TLS.Mode)) - r.Use(MaxBodySizeUnless(1<<20, "/api/v1/uploads")) // 1 MiB default; upload route exempt + r.Use(MaxBodySizeUnless(defaultMaxBodySize, "/api/v1/uploads")) // upload route exempt // Health check — unauthenticated, no versioning prefix. // The online user count callback is set after hub creation below. @@ -52,7 +52,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Start background cleanup of stale rate-limiter entries to prevent // unbounded memory growth. The goroutine exits when stopCh is closed. limiterStopCh := make(chan struct{}) - go limiter.StartCleanup(5*time.Minute, 15*time.Minute, limiterStopCh) + go limiter.StartCleanup(rateLimiterCleanupInterval, rateLimiterCleanupMaxWindow, limiterStopCh) // Versioned API routes. r.Route("/api/v1", func(r chi.Router) { @@ -139,7 +139,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // is handled by the LiveKit JWT (access_token query param) which the // LiveKit server validates. Users can only obtain a valid JWT through // the authenticated voice_join WS flow. Rate limiting prevents abuse. - r.With(rateLimitMiddlewareWithPrefix(limiter, "livekit_proxy:", 30, time.Minute, cfg.Server.TrustedProxies)). + r.With(rateLimitMiddlewareWithPrefix(limiter, "livekit_proxy:", livekitProxyRateLimitPerMinute, time.Minute, cfg.Server.TrustedProxies)). Handle("/livekit/*", http.StripPrefix("/livekit", NewLiveKitProxy(cfg.Voice.LiveKitURL, cfg.Server.AllowedOrigins))) } @@ -235,7 +235,7 @@ type livekitHealthResponse struct { func handleLiveKitHealth(hub *ws.Hub) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - ok, err := hub.LiveKitHealthCheck() + ok, err := hub.LiveKitHealthCheck() //nolint:contextcheck // TODO: propagate context through this call path if ok { writeJSON(w, http.StatusOK, livekitHealthResponse{ Status: "ok", diff --git a/Server/api/totp_handler.go b/Server/api/totp_handler.go index 1d5b3668..d702bf86 100644 --- a/Server/api/totp_handler.go +++ b/Server/api/totp_handler.go @@ -3,14 +3,13 @@ package api import ( "encoding/json" "errors" + "fmt" "io" "log/slog" "net/http" "strings" "time" - "fmt" - "github.com/owncord/server/auth" "github.com/owncord/server/db" ) @@ -67,7 +66,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi } totpKey := fmt.Sprintf("totp_fail:%d", challenge.UserID) - if !limiter.Check(totpKey, 10, 15*time.Minute) { + if !limiter.Check(totpKey, totpFailureRateLimit, totpFailureWindow) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", Message: "too many failed attempts, try again later", @@ -85,8 +84,8 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi } if !auth.VerifyTOTPCodeOnce(*user.TOTPSecret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) { - limiter.Allow(totpKey, 10, 15*time.Minute) - partialStore.RegisterFailure(partialToken, 5) + limiter.Allow(totpKey, totpFailureRateLimit, totpFailureWindow) + partialStore.RegisterFailure(partialToken, partialAuthMaxFailures) writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", Message: "invalid two-factor code", diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index 8c192bab..72ddbdab 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -46,8 +46,8 @@ func sanitizeUploadFilename(name string) string { } name = strings.TrimSpace(sb.String()) // Truncate to 255 characters (filesystem limit). - if len(name) > 255 { - name = name[:255] + if len(name) > maxUploadFilenameLength { + name = name[:maxUploadFilenameLength] } if name == "" || name == "." || name == ".." { name = "unnamed" @@ -61,7 +61,7 @@ func MountUploadRoutes(r chi.Router, database *db.DB, store *storage.Storage, al // Upload requires authentication and a higher body size limit (100 MB). r.With( AuthMiddleware(database), - MaxBodySize(100<<20), + MaxBodySize(uploadMaxBodySize), ).Post("/api/v1/uploads", handleUpload(database, store)) // File serving is public (URLs are unguessable UUIDs). r.Get("/api/v1/files/{id}", handleServeFile(database, store, allowedOrigins)) @@ -69,8 +69,11 @@ func MountUploadRoutes(r chi.Router, database *db.DB, store *storage.Storage, al func handleUpload(database *db.DB, store *storage.Storage) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + // Limit request body size to prevent abuse. + r.Body = http.MaxBytesReader(w, r.Body, uploadMaxBodySize) + // Parse multipart form — 10 MB in memory, rest on disk. - if err := r.ParseMultipartForm(10 << 20); err != nil { + if err := r.ParseMultipartForm(multipartMemoryLimit); err != nil { writeJSON(w, http.StatusBadRequest, errorResponse{ Error: "BAD_REQUEST", Message: "invalid multipart form", @@ -200,7 +203,7 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s // Set headers before ServeContent to ensure correct MIME type. w.Header().Set("Content-Type", att.MimeType) w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": att.Filename})) - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", fileCacheMaxAgeSeconds)) // CORS: allow webview to read the response body using configured origins. if origin := r.Header.Get("Origin"); origin != "" { for _, allowed := range allowedOrigins { diff --git a/Server/auth/constants.go b/Server/auth/constants.go new file mode 100644 index 00000000..406d5af4 --- /dev/null +++ b/Server/auth/constants.go @@ -0,0 +1,30 @@ +package auth + +import "time" + +const ( + // ─── Username validation ──────────────────────────────────────────────── + + // minUsernameLength is the minimum number of runes in a username. + minUsernameLength = 2 + + // maxUsernameLength is the maximum number of runes in a username. + maxUsernameLength = 32 + + // ─── Token generation ─────────────────────────────────────────────────── + + // sessionTokenBytes is the number of random bytes in a session token (256 bits). + sessionTokenBytes = 32 + + // opaqueTokenBytes is the number of random bytes in an opaque token (256 bits). + opaqueTokenBytes = 32 + + // totpSecretBytes is the number of random bytes used to generate a TOTP secret. + totpSecretBytes = 20 + + // ─── TOTP replay prevention ───────────────────────────────────────────── + + // usedTOTPCodeTTL is how long a verified TOTP code is remembered to prevent + // replay attacks (covers the current period +/- 1, ~90 seconds). + usedTOTPCodeTTL = 90 * time.Second +) diff --git a/Server/auth/helpers.go b/Server/auth/helpers.go index 78bf3df8..ff1379e0 100644 --- a/Server/auth/helpers.go +++ b/Server/auth/helpers.go @@ -18,11 +18,11 @@ import ( func ValidateUsername(username string) error { username = strings.TrimSpace(username) n := len([]rune(username)) - if n < 2 { - return fmt.Errorf("username must be at least 2 characters") + if n < minUsernameLength { + return fmt.Errorf("username must be at least %d characters", minUsernameLength) } - if n > 32 { - return fmt.Errorf("username must be at most 32 characters") + if n > maxUsernameLength { + return fmt.Errorf("username must be at most %d characters", maxUsernameLength) } for _, r := range username { if unicode.IsControl(r) { diff --git a/Server/auth/session.go b/Server/auth/session.go index 19e3cedb..3496e6ae 100644 --- a/Server/auth/session.go +++ b/Server/auth/session.go @@ -9,7 +9,7 @@ import ( // GenerateToken returns a cryptographically random 256-bit token encoded as a // 64-character lowercase hex string. func GenerateToken() (string, error) { - raw := make([]byte, 32) // 256 bits + raw := make([]byte, sessionTokenBytes) // 256 bits if _, err := rand.Read(raw); err != nil { return "", err } diff --git a/Server/auth/totp.go b/Server/auth/totp.go index c789823d..2a867d4d 100644 --- a/Server/auth/totp.go +++ b/Server/auth/totp.go @@ -3,7 +3,7 @@ package auth import ( "crypto/hmac" "crypto/rand" - "crypto/sha1" + "crypto/sha1" //nolint:gosec // G505: SHA1 required by TOTP/HOTP RFC 6238/4226 "crypto/subtle" "encoding/base32" "encoding/binary" @@ -185,7 +185,7 @@ func (s *UsedTOTPCodeStore) MarkUsed(userID int64, code string) bool { return false // replay detected } // Codes are valid for at most 90 seconds (current period ± 1). - s.entries[key] = time.Now().Add(90 * time.Second) + s.entries[key] = time.Now().Add(usedTOTPCodeTTL) return true } @@ -211,7 +211,7 @@ func VerifyTOTPCodeOnce(secret, code string, at time.Time, userID int64, usedSto } func GenerateTOTPSecret() (string, error) { - bytes := make([]byte, 20) + bytes := make([]byte, totpSecretBytes) if _, err := rand.Read(bytes); err != nil { return "", fmt.Errorf("GenerateTOTPSecret: %w", err) } @@ -236,7 +236,7 @@ func GenerateTOTPCode(secret string, at time.Time) (string, error) { return "", fmt.Errorf("GenerateTOTPCode: %w", err) } - counter := uint64(at.UTC().Unix() / int64(totpPeriod.Seconds())) + counter := uint64(at.UTC().Unix() / int64(totpPeriod.Seconds())) //nolint:gosec // G115: TOTP counter is always positive buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, counter) @@ -262,7 +262,7 @@ func VerifyTOTPCode(secret, code string, at time.Time) bool { } func generateOpaqueToken() (string, error) { - bytes := make([]byte, 32) + bytes := make([]byte, opaqueTokenBytes) if _, err := rand.Read(bytes); err != nil { return "", fmt.Errorf("generateOpaqueToken: %w", err) }