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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:07:34 +03:00
parent f069fccd96
commit fa2acca858
5 changed files with 52 additions and 13 deletions
+3
View File
@@ -27,6 +27,9 @@ DATABASE_URL=postgres://tanabata:password@localhost:5432/tanabata?sslmode=disabl
FILES_PATH=/data/files FILES_PATH=/data/files
THUMBS_CACHE_PATH=/data/thumbs THUMBS_CACHE_PATH=/data/thumbs
# Maximum accepted upload size in bytes (default 500 MiB).
MAX_UPLOAD_BYTES=524288000
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Thumbnails # Thumbnails
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -103,7 +103,7 @@ func main() {
// Handlers // Handlers
authMiddleware := handler.NewAuthMiddleware(authSvc) authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc) authHandler := handler.NewAuthHandler(authSvc)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc) fileHandler := handler.NewFileHandler(fileSvc, tagSvc, cfg.MaxUploadBytes)
tagHandler := handler.NewTagHandler(tagSvc, fileSvc) tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
categoryHandler := handler.NewCategoryHandler(categorySvc) categoryHandler := handler.NewCategoryHandler(categorySvc)
poolHandler := handler.NewPoolHandler(poolSvc) poolHandler := handler.NewPoolHandler(poolSvc)
+15
View File
@@ -28,6 +28,7 @@ type Config struct {
// Storage // Storage
FilesPath string FilesPath string
ThumbsCachePath string ThumbsCachePath string
MaxUploadBytes int64 // reject uploads larger than this (bytes)
// Thumbnails // Thumbnails
ThumbWidth int ThumbWidth int
@@ -85,6 +86,19 @@ func Load() (*Config, error) {
return n 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{ cfg := &Config{
ListenAddr: defaultStr("LISTEN_ADDR", ":8080"), ListenAddr: defaultStr("LISTEN_ADDR", ":8080"),
JWTSecret: requireStr("JWT_SECRET"), JWTSecret: requireStr("JWT_SECRET"),
@@ -98,6 +112,7 @@ func Load() (*Config, error) {
FilesPath: requireStr("FILES_PATH"), FilesPath: requireStr("FILES_PATH"),
ThumbsCachePath: requireStr("THUMBS_CACHE_PATH"), ThumbsCachePath: requireStr("THUMBS_CACHE_PATH"),
MaxUploadBytes: parseInt64("MAX_UPLOAD_BYTES", 500<<20), // 500 MiB
ThumbWidth: parseInt("THUMB_WIDTH", 160), ThumbWidth: parseInt("THUMB_WIDTH", 160),
ThumbHeight: parseInt("THUMB_HEIGHT", 160), ThumbHeight: parseInt("THUMB_HEIGHT", 160),
+30 -9
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -21,11 +22,33 @@ import (
type FileHandler struct { type FileHandler struct {
fileSvc *service.FileService fileSvc *service.FileService
tagSvc *service.TagService tagSvc *service.TagService
maxUploadBytes int64
} }
// NewFileHandler creates a FileHandler. // NewFileHandler creates a FileHandler. maxUploadBytes caps the size of an
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService) *FileHandler { // uploaded or replacement file.
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc} 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) { func (h *FileHandler) Upload(c *gin.Context) {
fh, err := c.FormFile("file") fh, ok := h.formFileLimited(c)
if err != nil { if !ok {
respondError(c, domain.ErrValidation)
return return
} }
@@ -378,9 +400,8 @@ func (h *FileHandler) ReplaceContent(c *gin.Context) {
return return
} }
fh, err := c.FormFile("file") fh, ok := h.formFileLimited(c)
if err != nil { if !ok {
respondError(c, domain.ErrValidation)
return return
} }
+1 -1
View File
@@ -140,7 +140,7 @@ func setupSuite(t *testing.T) *harness {
// --- Handlers ------------------------------------------------------------ // --- Handlers ------------------------------------------------------------
authMiddleware := handler.NewAuthMiddleware(authSvc) authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc) authHandler := handler.NewAuthHandler(authSvc)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc) fileHandler := handler.NewFileHandler(fileSvc, tagSvc, 500<<20)
tagHandler := handler.NewTagHandler(tagSvc, fileSvc) tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
categoryHandler := handler.NewCategoryHandler(categorySvc) categoryHandler := handler.NewCategoryHandler(categorySvc)
poolHandler := handler.NewPoolHandler(poolSvc) poolHandler := handler.NewPoolHandler(poolSvc)