From fa2acca85848769f27407a0ee2dfd773fb49b94d Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Wed, 10 Jun 2026 14:07:34 +0300 Subject: [PATCH] fix(backend): cap upload size to prevent memory exhaustion Upload and Replace buffered the entire request body into memory with no size limit, so a few large uploads could OOM the server. The file handler now wraps the request body in http.MaxBytesReader and rejects any file larger than MAX_UPLOAD_BYTES (default 500 MiB) before it is buffered. Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 ++ backend/cmd/server/main.go | 2 +- backend/internal/config/config.go | 15 +++++++ backend/internal/handler/file_handler.go | 43 +++++++++++++++------ backend/internal/integration/server_test.go | 2 +- 5 files changed, 52 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 45dc87a..dd0edb7 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,9 @@ DATABASE_URL=postgres://tanabata:password@localhost:5432/tanabata?sslmode=disabl FILES_PATH=/data/files THUMBS_CACHE_PATH=/data/thumbs +# Maximum accepted upload size in bytes (default 500 MiB). +MAX_UPLOAD_BYTES=524288000 + # --------------------------------------------------------------------------- # Thumbnails # --------------------------------------------------------------------------- diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 267ed91..7befc13 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -103,7 +103,7 @@ func main() { // Handlers authMiddleware := handler.NewAuthMiddleware(authSvc) authHandler := handler.NewAuthHandler(authSvc) - fileHandler := handler.NewFileHandler(fileSvc, tagSvc) + fileHandler := handler.NewFileHandler(fileSvc, tagSvc, cfg.MaxUploadBytes) tagHandler := handler.NewTagHandler(tagSvc, fileSvc) categoryHandler := handler.NewCategoryHandler(categorySvc) poolHandler := handler.NewPoolHandler(poolSvc) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index cf990ac..acdeab5 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -28,6 +28,7 @@ type Config struct { // Storage FilesPath string ThumbsCachePath string + MaxUploadBytes int64 // reject uploads larger than this (bytes) // Thumbnails ThumbWidth int @@ -85,6 +86,19 @@ func Load() (*Config, error) { return n } + parseInt64 := func(key string, def int64) int64 { + raw := os.Getenv(key) + if raw == "" { + return def + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + errs = append(errs, fmt.Errorf("%s: invalid integer %q: %w", key, raw, err)) + return def + } + return n + } + cfg := &Config{ ListenAddr: defaultStr("LISTEN_ADDR", ":8080"), JWTSecret: requireStr("JWT_SECRET"), @@ -98,6 +112,7 @@ func Load() (*Config, error) { FilesPath: requireStr("FILES_PATH"), ThumbsCachePath: requireStr("THUMBS_CACHE_PATH"), + MaxUploadBytes: parseInt64("MAX_UPLOAD_BYTES", 500<<20), // 500 MiB ThumbWidth: parseInt("THUMB_WIDTH", 160), ThumbHeight: parseInt("THUMB_HEIGHT", 160), diff --git a/backend/internal/handler/file_handler.go b/backend/internal/handler/file_handler.go index d30d179..547ed0d 100644 --- a/backend/internal/handler/file_handler.go +++ b/backend/internal/handler/file_handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "strconv" "strings" @@ -19,13 +20,35 @@ import ( // FileHandler handles all /files endpoints. type FileHandler struct { - fileSvc *service.FileService - tagSvc *service.TagService + fileSvc *service.FileService + tagSvc *service.TagService + maxUploadBytes int64 } -// NewFileHandler creates a FileHandler. -func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService) *FileHandler { - return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc} +// NewFileHandler creates a FileHandler. maxUploadBytes caps the size of an +// uploaded or replacement file. +func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService, maxUploadBytes int64) *FileHandler { + return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc, maxUploadBytes: maxUploadBytes} +} + +// formFileLimited reads the "file" multipart field while bounding how many bytes +// are read from the request body, then rejects files larger than the configured +// cap. The body limit guards against a dishonest Content-Length; the Size check +// gives a clear rejection for an honestly-declared oversized file. +func (h *FileHandler) formFileLimited(c *gin.Context) (*multipart.FileHeader, bool) { + // Allow a little slack above the file cap for multipart framing overhead. + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxUploadBytes+(1<<20)) + + fh, err := c.FormFile("file") + if err != nil { + respondError(c, domain.ErrValidation) + return nil, false + } + if fh.Size > h.maxUploadBytes { + respondError(c, domain.ErrValidation) + return nil, false + } + return fh, true } // --------------------------------------------------------------------------- @@ -186,9 +209,8 @@ func (h *FileHandler) List(c *gin.Context) { // --------------------------------------------------------------------------- func (h *FileHandler) Upload(c *gin.Context) { - fh, err := c.FormFile("file") - if err != nil { - respondError(c, domain.ErrValidation) + fh, ok := h.formFileLimited(c) + if !ok { return } @@ -378,9 +400,8 @@ func (h *FileHandler) ReplaceContent(c *gin.Context) { return } - fh, err := c.FormFile("file") - if err != nil { - respondError(c, domain.ErrValidation) + fh, ok := h.formFileLimited(c) + if !ok { return } diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index 83283de..a0b1c84 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -140,7 +140,7 @@ func setupSuite(t *testing.T) *harness { // --- Handlers ------------------------------------------------------------ authMiddleware := handler.NewAuthMiddleware(authSvc) authHandler := handler.NewAuthHandler(authSvc) - fileHandler := handler.NewFileHandler(fileSvc, tagSvc) + fileHandler := handler.NewFileHandler(fileSvc, tagSvc, 500<<20) tagHandler := handler.NewTagHandler(tagSvc, fileSvc) categoryHandler := handler.NewCategoryHandler(categorySvc) poolHandler := handler.NewPoolHandler(poolSvc)