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:
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,13 +20,35 @@ import (
|
|||||||
|
|
||||||
// FileHandler handles all /files endpoints.
|
// FileHandler handles all /files endpoints.
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user