diff --git a/Server/api/constants.go b/Server/api/constants.go index 78ae9561..e3d9be43 100644 --- a/Server/api/constants.go +++ b/Server/api/constants.go @@ -87,6 +87,9 @@ const ( // pwConfirmLockoutDuration is how long password-confirmation endpoints are // locked after exceeding pwConfirmFailureThreshold. pwConfirmLockoutDuration = 15 * time.Minute + + // uploadRateLimitPerMinute is the maximum file uploads per user per minute. + uploadRateLimitPerMinute = 10 ) // ─── Timeouts & TTLs ──────────────────────────────────────────────────────── diff --git a/Server/api/router.go b/Server/api/router.go index 4e60570b..04ef8dbf 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -87,7 +87,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri if storeErr != nil { slog.Error("failed to create file storage", "error", storeErr) } else { - MountUploadRoutes(r, database, store, cfg.Server.AllowedOrigins) + MountUploadRoutes(r, database, store, limiter, cfg.Server.AllowedOrigins) } // WebSocket hub — WS does its own in-band auth, so no AuthMiddleware here. diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index 3c792ad7..12eff50b 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -17,6 +17,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/permissions" "github.com/owncord/server/storage" @@ -58,18 +59,31 @@ func sanitizeUploadFilename(name string) string { // MountUploadRoutes registers upload and file-serving endpoints. // allowedOrigins controls the Access-Control-Allow-Origin header on served files. -func MountUploadRoutes(r chi.Router, database *db.DB, store *storage.Storage, allowedOrigins []string) { +func MountUploadRoutes(r chi.Router, database *db.DB, store *storage.Storage, limiter *auth.RateLimiter, allowedOrigins []string) { // Upload requires authentication and a higher body size limit (100 MB). r.With( AuthMiddleware(database), MaxBodySize(uploadMaxBodySize), - ).Post("/api/v1/uploads", handleUpload(database, store)) + ).Post("/api/v1/uploads", handleUpload(database, store, limiter)) // File serving requires authentication for channel-level access control. r.With(AuthMiddleware(database)).Get("/api/v1/files/{id}", handleServeFile(database, store, allowedOrigins)) } -func handleUpload(database *db.DB, store *storage.Storage) http.HandlerFunc { +func handleUpload(database *db.DB, store *storage.Storage, limiter *auth.RateLimiter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + // BUG-131: Per-user upload rate limit to prevent disk exhaustion. + user, ok := r.Context().Value(UserKey).(*db.User) + if ok && user != nil { + uploadKey := fmt.Sprintf("upload:%d", user.ID) + if !limiter.Allow(uploadKey, uploadRateLimitPerMinute, time.Minute) { + writeJSON(w, http.StatusTooManyRequests, errorResponse{ + Error: "RATE_LIMITED", + Message: "upload rate limit exceeded, try again later", + }) + return + } + } + // Limit request body size to prevent abuse. r.Body = http.MaxBytesReader(w, r.Body, uploadMaxBodySize) @@ -144,7 +158,7 @@ func handleUpload(database *db.DB, store *storage.Storage) http.HandlerFunc { } // Insert attachment record in DB (unlinked — message_id is NULL). - user := r.Context().Value(UserKey).(*db.User) + user, _ = r.Context().Value(UserKey).(*db.User) safeFilename := sanitizeUploadFilename(header.Filename) if err := database.CreateAttachment(fileID, user.ID, safeFilename, fileID, mime, header.Size, width, height); err != nil { // Clean up stored file on DB failure. diff --git a/Server/api/upload_handler_test.go b/Server/api/upload_handler_test.go index 00ac3954..9f359817 100644 --- a/Server/api/upload_handler_test.go +++ b/Server/api/upload_handler_test.go @@ -148,7 +148,8 @@ func newUploadTestStorage(t *testing.T) *storage.Storage { func buildUploadRouter(database *db.DB, store *storage.Storage, allowedOrigins []string) http.Handler { r := chi.NewRouter() - api.MountUploadRoutes(r, database, store, allowedOrigins) + limiter := auth.NewRateLimiter() + api.MountUploadRoutes(r, database, store, limiter, allowedOrigins) return r }