From 9219d14d3fa84d2fcfbd60bf7aa5ecba82b4f2d6 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Sun, 8 Mar 2026 13:49:50 +0100 Subject: [PATCH] API: Export request limit helpers and align test guidance Signed-off-by: Michael Mayer --- AGENTS.md | 1 + internal/api/README.md | 3 ++ internal/api/albums.go | 30 ++++++++--------- internal/api/batch_albums.go | 7 ++++ internal/api/batch_labels.go | 7 ++++ internal/api/batch_photos.go | 35 ++++++++++++++++++++ internal/api/batch_photos_edit.go | 6 ++-- internal/api/batch_photos_test.go | 28 ++++++++++++++++ internal/api/cluster_nodes.go | 8 +++++ internal/api/cluster_nodes_register.go | 6 ++-- internal/api/cluster_nodes_test.go | 19 +++++++++++ internal/api/config_options.go | 8 +++++ internal/api/config_options_test.go | 14 ++++++++ internal/api/config_settings.go | 12 +++---- internal/api/connect.go | 8 +++++ internal/api/connect_test.go | 11 ++++++ internal/api/faces.go | 7 ++++ internal/api/file_orientation.go | 7 ++++ internal/api/import.go | 7 ++++ internal/api/index.go | 7 ++++ internal/api/labels.go | 7 ++++ internal/api/links.go | 15 +++++++++ internal/api/markers.go | 18 +++++++++- internal/api/photo_label.go | 15 +++++++++ internal/api/photos.go | 7 ++++ internal/api/request_limits.go | 46 +++++++++++++++++--------- internal/api/services.go | 14 ++++++++ internal/api/services_upload.go | 7 ++++ internal/api/session_create.go | 6 ++-- internal/api/subjects.go | 11 +++++- internal/api/users_avatar.go | 6 ++-- internal/api/users_passcode.go | 7 ++++ internal/api/users_passcode_test.go | 13 ++++++++ internal/api/users_password.go | 7 ++++ internal/api/users_password_test.go | 13 ++++++++ internal/api/users_update.go | 7 ++++ internal/api/users_update_test.go | 13 ++++++++ internal/api/users_upload.go | 12 +++---- internal/api/vision_caption.go | 7 ++++ internal/api/vision_caption_test.go | 21 ++++++++++++ internal/api/vision_face.go | 7 ++++ internal/api/vision_face_test.go | 10 ++++++ internal/api/vision_labels.go | 7 ++++ internal/api/vision_labels_test.go | 10 ++++++ internal/api/vision_nsfw.go | 7 ++++ internal/api/vision_nsfw_test.go | 10 ++++++ internal/api/zip.go | 7 ++++ 47 files changed, 485 insertions(+), 56 deletions(-) create mode 100644 internal/api/vision_caption_test.go diff --git a/AGENTS.md b/AGENTS.md index 05ab34749..48b1b714c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,6 +225,7 @@ Note: Across our public documentation, official images, and in production, the c - Test frontend/backend: `make test-js` and `make test-go` - Linting: `make lint` (all), `make lint-go` (golangci-lint with `.golangci.yml`, prints findings without failing due to `--issues-exit-code 0`), `make lint-js` (ESLint/Prettier) - Go packages: `go test` (all tests) or `go test -run ` (specific tests only) +- Do not run multiple test commands in parallel. Suites share fixture files, temporary assets, and database state, so concurrent runs can trigger false failures, readonly database errors, or fixture conflicts. - Need to inspect the MariaDB data while iterating? Connect directly inside the dev shell with `mariadb -D photoprism` and run SQL without rebuilding Go code. - Go tests live beside sources: for `path/to/pkg/.go`, add tests in `path/to/pkg/_test.go` (create if missing). For the same function, group related cases as `t.Run(...)` sub-tests (table-driven where helpful) and use **PascalCase** for subtest names (for example, `t.Run("Success", ...)`). - Frontend unit tests use **Vitest**; see scripts in `frontend/package.json`. diff --git a/internal/api/README.md b/internal/api/README.md index 59858d9ba..00a34c504 100644 --- a/internal/api/README.md +++ b/internal/api/README.md @@ -24,6 +24,7 @@ The API package exposes PhotoPrism’s HTTP endpoints via Gin handlers. Each fil ### Security & Middleware - Authenticate requests using the standard middleware (`AuthRequired`) and check roles via helpers in `internal/auth/acl` (`acl.ParseRole`, `acl.ScopePermits`, `acl.ScopeAttrPermits`). +- Bound request bodies before parsing JSON or multipart payloads. Use `LimitRequestBodyBytes(...)` with a route-appropriate cap before `BindJSON(...)` / `ShouldBindJSON(...)`, detect `IsRequestBodyTooLarge(err)`, and return `413 Request Entity Too Large` via `AbortRequestTooLarge(...)`. - Never log secrets or tokens. Prefer structured logging through `event.Log` and redact sensitive values before logging. - Enforce rate limiting with the shared limiters (`limiter.Auth`, `limiter.Login`) and respond with `limiter.AbortJSON` to maintain consistent 429 JSON payloads. - Derive client IPs through `api.ClientIP` and extract bearer tokens with `header.BearerToken` or the helper setters. Use constant-time comparison for tokens and secrets. @@ -70,6 +71,7 @@ The API package exposes PhotoPrism’s HTTP endpoints via Gin handlers. Each fil - When handlers interact with the database, initialize fixtures through config helpers such as `config.NewTestConfig("api")` or `config.NewMinimalTestConfigWithDb("api", t.TempDir())` depending on fixture needs. - Stub external dependencies (`httptest.Server`) for remote calls and set `AllowPrivate=true` explicitly when the test server binds to loopback addresses. - Structure tests with table-driven subtests (`t.Run("CaseName", ...)`) and use PascalCase names. Provide cleanup functions (`t.Cleanup`) to remove temporary files or databases created during tests. +- Do not run `internal/api` tests in parallel. These suites share fixture files, temporary assets, and database state, so parallel `go test` invocations can cause false failures and readonly/fixture-conflict errors. ### Focused Test Runs @@ -77,6 +79,7 @@ The API package exposes PhotoPrism’s HTTP endpoints via Gin handlers. Each fil - Cluster endpoints: `go test ./internal/api -run 'Cluster' -count=1` - Downloads and zip streaming: `go test ./internal/api -run 'Download|Archive' -count=1` - Combined CLI and API validation: pair `go test ./internal/commands -run 'Cluster' -count=1` with the matching API suite to ensure DTOs remain compatible. +- Keep focused `internal/api` runs sequential. Do not launch multiple `go test ./internal/api ...` commands at the same time. ### Preflight Checklist diff --git a/internal/api/albums.go b/internal/api/albums.go index 639bdb6dd..4bc383ac3 100644 --- a/internal/api/albums.go +++ b/internal/api/albums.go @@ -138,11 +138,11 @@ func CreateAlbum(router *gin.RouterGroup) { var frm form.Album // Assign and validate request form values. - limitRequestBodyBytes(c, maxAlbumRequestBytes) + LimitRequestBodyBytes(c, MaxAlbumRequestBytes) if err := c.BindJSON(&frm); err != nil { - if isRequestBodyTooLarge(err) { - abortRequestTooLarge(c, i18n.ErrBadRequest) + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) return } @@ -245,11 +245,11 @@ func UpdateAlbum(router *gin.RouterGroup) { } // Assign and validate request form values. - limitRequestBodyBytes(c, maxAlbumRequestBytes) + LimitRequestBodyBytes(c, MaxAlbumRequestBytes) if err = c.BindJSON(frm); err != nil { - if isRequestBodyTooLarge(err) { - abortRequestTooLarge(c, i18n.ErrBadRequest) + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) return } @@ -501,11 +501,11 @@ func CloneAlbums(router *gin.RouterGroup) { var frm form.Selection // Assign and validate request form values. - limitRequestBodyBytes(c, maxAlbumRequestBytes) + LimitRequestBodyBytes(c, MaxAlbumRequestBytes) if err = c.BindJSON(&frm); err != nil { - if isRequestBodyTooLarge(err) { - abortRequestTooLarge(c, i18n.ErrBadRequest) + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) return } @@ -569,11 +569,11 @@ func AddPhotosToAlbum(router *gin.RouterGroup) { var frm form.Selection // Assign and validate request form values. - limitRequestBodyBytes(c, maxAlbumRequestBytes) + LimitRequestBodyBytes(c, MaxAlbumRequestBytes) if err := c.BindJSON(&frm); err != nil { - if isRequestBodyTooLarge(err) { - abortRequestTooLarge(c, i18n.ErrBadRequest) + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) return } @@ -684,11 +684,11 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup) { var frm form.Selection // Assign and validate request form values. - limitRequestBodyBytes(c, maxAlbumRequestBytes) + LimitRequestBodyBytes(c, MaxAlbumRequestBytes) if err := c.BindJSON(&frm); err != nil { - if isRequestBodyTooLarge(err) { - abortRequestTooLarge(c, i18n.ErrBadRequest) + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) return } diff --git a/internal/api/batch_albums.go b/internal/api/batch_albums.go index 5ff59879f..2337eb874 100644 --- a/internal/api/batch_albums.go +++ b/internal/api/batch_albums.go @@ -34,7 +34,14 @@ func BatchAlbumsDelete(router *gin.RouterGroup) { var frm form.Selection + LimitRequestBodyBytes(c, MaxSelectionRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/batch_labels.go b/internal/api/batch_labels.go index a98ac915c..d0a68b893 100644 --- a/internal/api/batch_labels.go +++ b/internal/api/batch_labels.go @@ -34,7 +34,14 @@ func BatchLabelsDelete(router *gin.RouterGroup) { var frm form.Selection + LimitRequestBodyBytes(c, MaxSelectionRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/batch_photos.go b/internal/api/batch_photos.go index 65bab6f25..8c9ba40dc 100644 --- a/internal/api/batch_photos.go +++ b/internal/api/batch_photos.go @@ -44,7 +44,14 @@ func BatchPhotosArchive(router *gin.RouterGroup) { var frm form.Selection // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxSelectionRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } @@ -115,7 +122,14 @@ func BatchPhotosRestore(router *gin.RouterGroup) { var frm form.Selection + LimitRequestBodyBytes(c, MaxSelectionRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } @@ -185,7 +199,14 @@ func BatchPhotosApprove(router *gin.RouterGroup) { var frm form.Selection + LimitRequestBodyBytes(c, MaxSelectionRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } @@ -245,7 +266,14 @@ func BatchPhotosPrivate(router *gin.RouterGroup) { var frm form.Selection + LimitRequestBodyBytes(c, MaxSelectionRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } @@ -312,7 +340,14 @@ func BatchPhotosDelete(router *gin.RouterGroup) { var frm form.Selection + LimitRequestBodyBytes(c, MaxSelectionRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/batch_photos_edit.go b/internal/api/batch_photos_edit.go index 7ee7394ef..d2f219789 100644 --- a/internal/api/batch_photos_edit.go +++ b/internal/api/batch_photos_edit.go @@ -54,11 +54,11 @@ func BatchPhotosEdit(router *gin.RouterGroup) { var frm batch.PhotosRequest // Assign and validate request form values. - limitRequestBodyBytes(c, maxBatchPhotosEditBytes) + LimitRequestBodyBytes(c, MaxBatchPhotosEditBytes) if err := c.BindJSON(&frm); err != nil { - if isRequestBodyTooLarge(err) { - abortRequestTooLarge(c, i18n.ErrBadRequest) + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) return } diff --git a/internal/api/batch_photos_test.go b/internal/api/batch_photos_test.go index 42619021c..a263cbe2a 100644 --- a/internal/api/batch_photos_test.go +++ b/internal/api/batch_photos_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -46,6 +47,15 @@ func TestBatchPhotosArchive(t *testing.T) { r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": 123}`) assert.Equal(t, http.StatusBadRequest, r.Code) }) + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, _ := NewApiTest() + BatchPhotosArchive(router) + + body := `{"photos":["` + strings.Repeat("p", int(MaxSelectionRequestBytes)) + `"]}` + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/batch/photos/archive", body) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) } func TestBatchPhotosRestore(t *testing.T) { @@ -135,6 +145,15 @@ func TestBatchAlbumsDelete(t *testing.T) { r := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", `{"albums": 123}`) assert.Equal(t, http.StatusBadRequest, r.Code) }) + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, _ := NewApiTest() + BatchAlbumsDelete(router) + + body := `{"albums":["` + strings.Repeat("a", int(MaxSelectionRequestBytes)) + `"]}` + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/batch/albums/delete", body) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) } func TestBatchPhotosPrivate(t *testing.T) { @@ -220,6 +239,15 @@ func TestBatchLabelsDelete(t *testing.T) { r := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": 123}`) assert.Equal(t, http.StatusBadRequest, r.Code) }) + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, _ := NewApiTest() + BatchLabelsDelete(router) + + body := `{"labels":["` + strings.Repeat("l", int(MaxSelectionRequestBytes)) + `"]}` + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/batch/labels/delete", body) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) } func TestBatchPhotosApprove(t *testing.T) { diff --git a/internal/api/cluster_nodes.go b/internal/api/cluster_nodes.go index 54ee05553..ab9272aa4 100644 --- a/internal/api/cluster_nodes.go +++ b/internal/api/cluster_nodes.go @@ -13,6 +13,7 @@ import ( "github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/internal/service/cluster" reg "github.com/photoprism/photoprism/internal/service/cluster/registry" + "github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/log/status" "github.com/photoprism/photoprism/pkg/txt" ) @@ -222,7 +223,14 @@ func ClusterUpdateNode(router *gin.RouterGroup) { SiteUrl *string `json:"SiteUrl"` } + LimitRequestBodyBytes(c, MaxClusterRegisterBytes) + if err := c.ShouldBindJSON(&req); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/cluster_nodes_register.go b/internal/api/cluster_nodes_register.go index 0e013cfe4..7d7042b48 100644 --- a/internal/api/cluster_nodes_register.go +++ b/internal/api/cluster_nodes_register.go @@ -79,12 +79,12 @@ func ClusterNodesRegister(router *gin.RouterGroup) { // Parse request. var req cluster.RegisterRequest - limitRequestBodyBytes(c, maxClusterRegisterBytes) + LimitRequestBodyBytes(c, MaxClusterRegisterBytes) if err := c.ShouldBindJSON(&req); err != nil { - if isRequestBodyTooLarge(err) { + if IsRequestBodyTooLarge(err) { event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "request too large", status.Error(err)}) - abortRequestTooLarge(c, i18n.ErrBadRequest) + AbortRequestTooLarge(c, i18n.ErrBadRequest) return } diff --git a/internal/api/cluster_nodes_test.go b/internal/api/cluster_nodes_test.go index bcca5b869..ecc248dea 100644 --- a/internal/api/cluster_nodes_test.go +++ b/internal/api/cluster_nodes_test.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -140,3 +141,21 @@ func TestClusterUpdateNode_UUIDValidation(t *testing.T) { r = PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/BadID", `{"SiteUrl":"https://photos.example.com"}`) assert.Equal(t, http.StatusNotFound, r.Code) } + +func TestClusterUpdateNode_RequestTooLarge(t *testing.T) { + app, router, conf := NewApiTest() + enablePortalAPIs(t, conf) + + ClusterUpdateNode(router) + + regy, err := reg.NewClientRegistryWithConfig(conf) + assert.NoError(t, err) + + n := ®.Node{Node: cluster.Node{Name: "pp-node-request-limit", Role: cluster.RoleInstance, UUID: rnd.UUIDv7()}} + assert.NoError(t, regy.Put(n)) + + body := `{"Labels":{"oversized":"` + strings.Repeat("a", int(MaxClusterRegisterBytes)) + `"}}` + r := PerformRequestWithBody(app, http.MethodPatch, "/api/v1/cluster/nodes/"+n.UUID, body) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) +} diff --git a/internal/api/config_options.go b/internal/api/config_options.go index ac5783ec8..f07be28ce 100644 --- a/internal/api/config_options.go +++ b/internal/api/config_options.go @@ -9,6 +9,7 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/photoprism/get" + "github.com/photoprism/photoprism/pkg/i18n" ) // GetConfigOptions returns backend config options. @@ -58,7 +59,14 @@ func SaveConfigOptions(router *gin.RouterGroup) { v := make(entity.Values) + LimitRequestBodyBytes(c, MaxSettingsRequestBytes) + if err := c.BindJSON(&v); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/config_options_test.go b/internal/api/config_options_test.go index c57262bf4..fa9e56974 100644 --- a/internal/api/config_options_test.go +++ b/internal/api/config_options_test.go @@ -4,6 +4,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "testing" "github.com/tidwall/gjson" @@ -101,6 +102,19 @@ func TestSaveConfigOptions(t *testing.T) { assert.Equal(t, "https://photos.example.com/", merged["SiteUrl"]) assert.Equal(t, true, merged["HttpCachePublic"]) }) + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, conf := NewApiTest() + + SaveConfigOptions(router) + + prepareConfigOptionsSuccessTest(t, conf) + + authToken := AuthenticateAdmin(app, router) + body := `{"SiteUrl":"https://photos.example.com/","LogLevel":"` + strings.Repeat("a", int(MaxSettingsRequestBytes)) + `"}` + r := AuthenticatedRequestWithBody(app, "POST", "/api/v1/config/options", body, authToken) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) } // prepareConfigOptionsSuccessTest normalizes shared config state so the success diff --git a/internal/api/config_settings.go b/internal/api/config_settings.go index 295dd6465..fb154be47 100644 --- a/internal/api/config_settings.go +++ b/internal/api/config_settings.go @@ -85,11 +85,11 @@ func SaveSettings(router *gin.RouterGroup) { settings = conf.Settings() // Set values from request. - limitRequestBodyBytes(c, maxSettingsRequestBytes) + LimitRequestBodyBytes(c, MaxSettingsRequestBytes) if err := c.BindJSON(settings); err != nil { - if isRequestBodyTooLarge(err) { - abortRequestTooLarge(c, i18n.ErrBadRequest) + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) return } @@ -119,11 +119,11 @@ func SaveSettings(router *gin.RouterGroup) { settings = &customize.Settings{} // Set values from request. - limitRequestBodyBytes(c, maxSettingsRequestBytes) + LimitRequestBodyBytes(c, MaxSettingsRequestBytes) if err := c.BindJSON(settings); err != nil { - if isRequestBodyTooLarge(err) { - abortRequestTooLarge(c, i18n.ErrBadRequest) + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) return } diff --git a/internal/api/connect.go b/internal/api/connect.go index b11465356..874dcf7a2 100644 --- a/internal/api/connect.go +++ b/internal/api/connect.go @@ -38,7 +38,15 @@ func Connect(router *gin.RouterGroup) { var frm form.Connect // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxAuthRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + log.Warnf("connect: request too large (%s)", clean.Log(name)) + AbortRequestTooLarge(c, i18n.ErrAccountConnect) + return + } + log.Warnf("connect: invalid form values (%s)", clean.Log(name)) Abort(c, http.StatusBadRequest, i18n.ErrAccountConnect) return diff --git a/internal/api/connect_test.go b/internal/api/connect_test.go index fb24bfc6c..120b666c2 100644 --- a/internal/api/connect_test.go +++ b/internal/api/connect_test.go @@ -1,6 +1,8 @@ package api import ( + "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -43,4 +45,13 @@ func TestConnect(t *testing.T) { r := PerformRequestWithBody(app, "PUT", "/api/v1/connect/hub/", `{"Token": "foobar123"}`) assert.NotEqual(t, 200, r.Code) }) + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, _ := NewApiTest() + Connect(router) + + body := `{"Token":"` + strings.Repeat("a", int(MaxAuthRequestBytes)) + `"}` + r := PerformRequestWithBody(app, http.MethodPut, "/api/v1/connect/hub", body) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) } diff --git a/internal/api/faces.go b/internal/api/faces.go index bd3832148..b068de669 100644 --- a/internal/api/faces.go +++ b/internal/api/faces.go @@ -69,7 +69,14 @@ func UpdateFace(router *gin.RouterGroup) { var frm form.Face // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/file_orientation.go b/internal/api/file_orientation.go index d0316478a..f9b70f418 100644 --- a/internal/api/file_orientation.go +++ b/internal/api/file_orientation.go @@ -66,7 +66,14 @@ func ChangeFileOrientation(router *gin.RouterGroup) { } // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err = c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + Abort(c, http.StatusBadRequest, i18n.ErrBadRequest) return } diff --git a/internal/api/import.go b/internal/api/import.go index 73801fb0b..c5a7e9f69 100644 --- a/internal/api/import.go +++ b/internal/api/import.go @@ -68,7 +68,14 @@ func StartImport(router *gin.RouterGroup) { var frm form.ImportOptions // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/index.go b/internal/api/index.go index 53a01bb75..92a04f881 100644 --- a/internal/api/index.go +++ b/internal/api/index.go @@ -51,7 +51,14 @@ func StartIndexing(router *gin.RouterGroup) { var frm form.IndexOptions // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/labels.go b/internal/api/labels.go index ea13f8e03..06d2d7b3c 100644 --- a/internal/api/labels.go +++ b/internal/api/labels.go @@ -54,7 +54,14 @@ func UpdateLabel(router *gin.RouterGroup) { } // Set form values from request. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if frmErr = c.BindJSON(frm); frmErr != nil { + if IsRequestBodyTooLarge(frmErr) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, frmErr) return } else if frmErr = frm.Validate(); frmErr != nil { diff --git a/internal/api/links.go b/internal/api/links.go index 8407f8371..0f5e2725c 100644 --- a/internal/api/links.go +++ b/internal/api/links.go @@ -11,6 +11,7 @@ import ( "github.com/photoprism/photoprism/internal/entity/query" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/txt" ) @@ -28,7 +29,14 @@ func UpdateLink(c *gin.Context) { var frm form.Link // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } @@ -107,7 +115,14 @@ func CreateLink(c *gin.Context) { var frm form.Link + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/markers.go b/internal/api/markers.go index c0666d55d..fabbbb3f0 100644 --- a/internal/api/markers.go +++ b/internal/api/markers.go @@ -99,7 +99,14 @@ func CreateMarker(router *gin.RouterGroup) { } // Initialize form. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } @@ -214,7 +221,16 @@ func UpdateMarker(router *gin.RouterGroup) { log.Errorf("faces: %s (create marker form)", err) AbortSaveFailed(c) return - } else if err = c.BindJSON(&frm); err != nil { + } + + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + + if err = c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + log.Errorf("faces: %s (bind marker form)", err) AbortBadRequest(c, err) return diff --git a/internal/api/photo_label.go b/internal/api/photo_label.go index c59057254..0e9f38376 100644 --- a/internal/api/photo_label.go +++ b/internal/api/photo_label.go @@ -14,6 +14,7 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/txt" ) @@ -47,7 +48,14 @@ func AddPhotoLabel(router *gin.RouterGroup) { frm := &form.Label{} // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err = c.BindJSON(frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } else if err = frm.Validate(); err != nil { @@ -231,7 +239,14 @@ func UpdatePhotoLabel(router *gin.RouterGroup) { return } + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err = c.BindJSON(label); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/photos.go b/internal/api/photos.go index 840c13ad2..69b0da7e9 100644 --- a/internal/api/photos.go +++ b/internal/api/photos.go @@ -104,7 +104,14 @@ func UpdatePhoto(router *gin.RouterGroup) { } // 2) Assign and validate request form values. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err = c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + Abort(c, http.StatusBadRequest, i18n.ErrBadRequest) return } diff --git a/internal/api/request_limits.go b/internal/api/request_limits.go index 288def3af..905947224 100644 --- a/internal/api/request_limits.go +++ b/internal/api/request_limits.go @@ -11,27 +11,43 @@ import ( ) const ( - maxSessionRequestBytes int64 = 64 * 1024 - maxAlbumRequestBytes int64 = 256 * 1024 - maxSettingsRequestBytes int64 = 256 * 1024 - maxClusterRegisterBytes int64 = 256 * 1024 - maxUploadOptionsRequestBytes int64 = 256 * 1024 - maxBatchPhotosEditBytes int64 = 1024 * 1024 - maxMultipartOverheadBytes int64 = 1024 * 1024 - maxAvatarUploadBytes int64 = 20000000 + maxMultipartOverheadBytes + // MaxAuthRequestBytes bounds small authentication and credential-change payloads. + MaxAuthRequestBytes int64 = 64 * 1024 + // MaxMutationRequestBytes bounds general JSON mutation payloads. + MaxMutationRequestBytes int64 = 256 * 1024 + // MaxSelectionRequestBytes bounds selection-heavy batch mutation payloads. + MaxSelectionRequestBytes int64 = 1024 * 1024 + // MaxVisionRequestBytes bounds Vision API payloads while still allowing supported data URLs. + MaxVisionRequestBytes int64 = 32 * 1024 * 1024 + // MaxSessionRequestBytes bounds login request payloads. + MaxSessionRequestBytes int64 = MaxAuthRequestBytes + // MaxAlbumRequestBytes bounds album create and update payloads. + MaxAlbumRequestBytes int64 = MaxMutationRequestBytes + // MaxSettingsRequestBytes bounds settings and config option payloads. + MaxSettingsRequestBytes int64 = MaxMutationRequestBytes + // MaxClusterRegisterBytes bounds cluster registration and patch payloads. + MaxClusterRegisterBytes int64 = MaxMutationRequestBytes + // MaxUploadOptionsRequestBytes bounds upload-processing payloads. + MaxUploadOptionsRequestBytes int64 = MaxMutationRequestBytes + // MaxBatchPhotosEditBytes bounds batch photo edit payloads. + MaxBatchPhotosEditBytes int64 = MaxSelectionRequestBytes + // MaxMultipartOverheadBytes reserves room for multipart framing overhead. + MaxMultipartOverheadBytes int64 = 1024 * 1024 + // MaxAvatarUploadBytes bounds avatar uploads including multipart overhead. + MaxAvatarUploadBytes int64 = 20000000 + MaxMultipartOverheadBytes ) -// limitRequestBodyBytes caps the readable request body size for the current handler. -func limitRequestBodyBytes(c *gin.Context, limit int64) { - if c == nil || c.Request == nil || limit <= 0 { +// LimitRequestBodyBytes caps the readable request body size for the current handler. +func LimitRequestBodyBytes(c *gin.Context, limit int64) { + if c == nil || c.Request == nil || c.Request.Body == nil || limit <= 0 { return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) } -// isRequestBodyTooLarge reports whether the parsing error was caused by a body-size limit. -func isRequestBodyTooLarge(err error) bool { +// IsRequestBodyTooLarge reports whether the parsing error was caused by a body-size limit. +func IsRequestBodyTooLarge(err error) bool { if err == nil { return false } @@ -41,7 +57,7 @@ func isRequestBodyTooLarge(err error) bool { return errors.As(err, &maxBytesErr) || errors.Is(err, multipart.ErrMessageTooLarge) } -// abortRequestTooLarge writes a localized 413 response and stops the current request. -func abortRequestTooLarge(c *gin.Context, id i18n.Message) { +// AbortRequestTooLarge writes a localized 413 response and stops the current request. +func AbortRequestTooLarge(c *gin.Context, id i18n.Message) { Abort(c, http.StatusRequestEntityTooLarge, id) } diff --git a/internal/api/services.go b/internal/api/services.go index 98817e70c..952b43b0f 100644 --- a/internal/api/services.go +++ b/internal/api/services.go @@ -150,7 +150,14 @@ func AddService(router *gin.RouterGroup) { var frm form.Service // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } @@ -220,7 +227,14 @@ func UpdateService(router *gin.RouterGroup) { } // 2) Update form with values from request + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err = c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/services_upload.go b/internal/api/services_upload.go index c07bcded5..ba7fa7da7 100644 --- a/internal/api/services_upload.go +++ b/internal/api/services_upload.go @@ -48,7 +48,14 @@ func UploadToService(router *gin.RouterGroup) { var frm form.SyncUpload // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxSelectionRequestBytes) + if err = c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/session_create.go b/internal/api/session_create.go index 866ecd129..b00994a14 100644 --- a/internal/api/session_create.go +++ b/internal/api/session_create.go @@ -41,12 +41,12 @@ func CreateSession(router *gin.RouterGroup) { clientIp := ClientIP(c) // Assign and validate request form values. - limitRequestBodyBytes(c, maxSessionRequestBytes) + LimitRequestBodyBytes(c, MaxSessionRequestBytes) if err := c.BindJSON(&frm); err != nil { - if isRequestBodyTooLarge(err) { + if IsRequestBodyTooLarge(err) { event.AuditWarn([]string{clientIp, "create session", "request too large", status.Error(err)}) - abortRequestTooLarge(c, i18n.ErrBadRequest) + AbortRequestTooLarge(c, i18n.ErrBadRequest) return } diff --git a/internal/api/subjects.go b/internal/api/subjects.go index 78ad26562..853abd603 100644 --- a/internal/api/subjects.go +++ b/internal/api/subjects.go @@ -85,7 +85,16 @@ func UpdateSubject(router *gin.RouterGroup) { log.Errorf("subject: %s (new form)", err) AbortSaveFailed(c) return - } else if err = c.BindJSON(frm); err != nil { + } + + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + + if err = c.BindJSON(frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + log.Errorf("subject: %s (update form)", err) AbortBadRequest(c, err) return diff --git a/internal/api/users_avatar.go b/internal/api/users_avatar.go index a19914eae..529327427 100644 --- a/internal/api/users_avatar.go +++ b/internal/api/users_avatar.go @@ -58,14 +58,14 @@ func UploadUserAvatar(router *gin.RouterGroup) { } // Parse upload form. - limitRequestBodyBytes(c, maxAvatarUploadBytes) + LimitRequestBodyBytes(c, MaxAvatarUploadBytes) f, err := c.MultipartForm() if err != nil { - if isRequestBodyTooLarge(err) { + if IsRequestBodyTooLarge(err) { event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "request too large"}, s.RefID) - abortRequestTooLarge(c, i18n.ErrFileTooLarge) + AbortRequestTooLarge(c, i18n.ErrFileTooLarge) return } diff --git a/internal/api/users_passcode.go b/internal/api/users_passcode.go index fe9da2c38..51fe28527 100644 --- a/internal/api/users_passcode.go +++ b/internal/api/users_passcode.go @@ -292,7 +292,14 @@ func checkUserPasscodeAuth(c *gin.Context, action acl.Permission) (*entity.Sessi frm := &form.Passcode{} // Validate request form values. + LimitRequestBodyBytes(c, MaxAuthRequestBytes) + if err := c.BindJSON(frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrInvalidPassword) + return s, nil, nil, authn.ErrInvalidRequest + } + Error(c, http.StatusBadRequest, err, i18n.ErrInvalidPassword) return s, nil, nil, authn.ErrInvalidRequest } else if authn.KeyTOTP.NotEqual(frm.Type) { diff --git a/internal/api/users_passcode_test.go b/internal/api/users_passcode_test.go index 31a3bf7ce..c4f153e79 100644 --- a/internal/api/users_passcode_test.go +++ b/internal/api/users_passcode_test.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "net/http" + "strings" "testing" "time" @@ -87,6 +88,18 @@ func TestCreateUserPasscode(t *testing.T) { assert.Equal(t, http.StatusForbidden, r.Code) } }) + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + CreateUserPasscode(router) + sessId := AuthenticateUser(app, router, "alice", "Alice123!") + + body := `{"Type":"totp","Password":"` + strings.Repeat("a", int(MaxAuthRequestBytes)) + `"}` + r := AuthenticatedRequestWithBody(app, "POST", "/api/v1/users/uqxetse3cy5eo9z2/passcode", body, sessId) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) t.Run("AliceSuccess", func(t *testing.T) { app, router, conf := NewApiTest() conf.SetAuthMode(config.AuthModePasswd) diff --git a/internal/api/users_password.go b/internal/api/users_password.go index 3132621d2..568b87457 100644 --- a/internal/api/users_password.go +++ b/internal/api/users_password.go @@ -79,7 +79,14 @@ func UpdateUserPassword(router *gin.RouterGroup) { f := form.ChangePassword{} // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxAuthRequestBytes) + if err := c.BindJSON(&f); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrInvalidPassword) + return + } + Error(c, http.StatusBadRequest, err, i18n.ErrInvalidPassword) return } diff --git a/internal/api/users_password_test.go b/internal/api/users_password_test.go index 5b34edd3b..6b10f2cb6 100644 --- a/internal/api/users_password_test.go +++ b/internal/api/users_password_test.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -49,6 +50,18 @@ func TestChangePassword(t *testing.T) { "{OldPassword: old}", sessId) assert.Equal(t, http.StatusBadRequest, r.Code) }) + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + UpdateUserPassword(router) + sessId := AuthenticateUser(app, router, "alice", "Alice123!") + + body := `{"OldPassword":"Alice123!","NewPassword":"` + strings.Repeat("a", int(MaxAuthRequestBytes)) + `"}` + r := AuthenticatedRequestWithBody(app, "PUT", "/api/v1/users/uqxetse3cy5eo9z2/password", body, sessId) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) t.Run("AliceProvidesWrongPassword", func(t *testing.T) { app, router, conf := NewApiTest() conf.SetAuthMode(config.AuthModePasswd) diff --git a/internal/api/users_update.go b/internal/api/users_update.go index 376bdec73..d35dc8109 100644 --- a/internal/api/users_update.go +++ b/internal/api/users_update.go @@ -65,7 +65,14 @@ func UpdateUser(router *gin.RouterGroup) { } // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + if err = c.BindJSON(&f); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return } diff --git a/internal/api/users_update_test.go b/internal/api/users_update_test.go index f5558dc25..fd581166f 100644 --- a/internal/api/users_update_test.go +++ b/internal/api/users_update_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -137,4 +138,16 @@ func TestUpdateUser(t *testing.T) { assert.Equal(t, http.StatusNotFound, r.Code) } }) + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + UpdateUser(router) + + sessId := AuthenticateUser(app, router, "alice", "Alice123!") + body := `{"DisplayName":"` + strings.Repeat("a", int(MaxMutationRequestBytes)) + `"}` + r := AuthenticatedRequestWithBody(app, "PUT", "/api/v1/users/uqxetse3cy5eo9z2", body, sessId) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) } diff --git a/internal/api/users_upload.go b/internal/api/users_upload.go index a5df2b5b9..51f3ff9de 100644 --- a/internal/api/users_upload.go +++ b/internal/api/users_upload.go @@ -78,15 +78,15 @@ func UploadUserFiles(router *gin.RouterGroup) { token := clean.Token(c.Param("token")) if totalSizeLimit := conf.UploadLimitBytes(); totalSizeLimit > 0 { - limitRequestBodyBytes(c, totalSizeLimit+maxMultipartOverheadBytes) + LimitRequestBodyBytes(c, totalSizeLimit+MaxMultipartOverheadBytes) } f, err := c.MultipartForm() if err != nil { - if isRequestBodyTooLarge(err) { + if IsRequestBodyTooLarge(err) { log.Errorf("upload: %s", err) - abortRequestTooLarge(c, i18n.ErrFileTooLarge) + AbortRequestTooLarge(c, i18n.ErrFileTooLarge) return } @@ -315,11 +315,11 @@ func ProcessUserUpload(router *gin.RouterGroup) { var frm form.UploadOptions // Assign and validate request form values. - limitRequestBodyBytes(c, maxUploadOptionsRequestBytes) + LimitRequestBodyBytes(c, MaxUploadOptionsRequestBytes) if err := c.BindJSON(&frm); err != nil { - if isRequestBodyTooLarge(err) { - abortRequestTooLarge(c, i18n.ErrBadRequest) + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) return } diff --git a/internal/api/vision_caption.go b/internal/api/vision_caption.go index c8ed0f7fd..d72e2ae12 100644 --- a/internal/api/vision_caption.go +++ b/internal/api/vision_caption.go @@ -40,7 +40,14 @@ func PostVisionCaption(router *gin.RouterGroup) { } // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxVisionRequestBytes) + if err := c.BindJSON(&request); err != nil { + if IsRequestBodyTooLarge(err) { + c.JSON(http.StatusRequestEntityTooLarge, vision.NewApiError(request.GetId(), http.StatusRequestEntityTooLarge)) + return + } + c.JSON(http.StatusBadRequest, vision.NewApiError(request.GetId(), http.StatusBadRequest)) return } diff --git a/internal/api/vision_caption_test.go b/internal/api/vision_caption_test.go new file mode 100644 index 000000000..c2b88faf5 --- /dev/null +++ b/internal/api/vision_caption_test.go @@ -0,0 +1,21 @@ +package api + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPostVisionCaption(t *testing.T) { + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, _ := NewApiTest() + PostVisionCaption(router) + + body := `{"images":["data:image/jpeg;base64,` + strings.Repeat("a", int(MaxVisionRequestBytes)) + `"]}` + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/vision/caption", body) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) +} diff --git a/internal/api/vision_face.go b/internal/api/vision_face.go index 398e67ca6..992f335a4 100644 --- a/internal/api/vision_face.go +++ b/internal/api/vision_face.go @@ -42,7 +42,14 @@ func PostVisionFace(router *gin.RouterGroup) { } // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxVisionRequestBytes) + if err := c.BindJSON(&request); err != nil { + if IsRequestBodyTooLarge(err) { + c.JSON(http.StatusRequestEntityTooLarge, vision.NewApiError(request.GetId(), http.StatusRequestEntityTooLarge)) + return + } + c.JSON(http.StatusBadRequest, vision.NewApiError(request.GetId(), http.StatusBadRequest)) return } diff --git a/internal/api/vision_face_test.go b/internal/api/vision_face_test.go index f6114718a..30915ff6a 100644 --- a/internal/api/vision_face_test.go +++ b/internal/api/vision_face_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -178,4 +179,13 @@ func TestPostVisionFace(t *testing.T) { r := PerformRequest(app, http.MethodPost, "/api/v1/vision/face") assert.Equal(t, http.StatusBadRequest, r.Code) }) + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, _ := NewApiTest() + PostVisionFace(router) + + body := `{"images":["data:image/jpeg;base64,` + strings.Repeat("a", int(MaxVisionRequestBytes)) + `"]}` + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/vision/face", body) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) } diff --git a/internal/api/vision_labels.go b/internal/api/vision_labels.go index 0701016b0..a7ff8e731 100644 --- a/internal/api/vision_labels.go +++ b/internal/api/vision_labels.go @@ -42,7 +42,14 @@ func PostVisionLabels(router *gin.RouterGroup) { } // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxVisionRequestBytes) + if err := c.BindJSON(&request); err != nil { + if IsRequestBodyTooLarge(err) { + c.JSON(http.StatusRequestEntityTooLarge, vision.NewApiError(request.GetId(), http.StatusRequestEntityTooLarge)) + return + } + c.JSON(http.StatusBadRequest, vision.NewApiError(request.GetId(), http.StatusBadRequest)) return } diff --git a/internal/api/vision_labels_test.go b/internal/api/vision_labels_test.go index 5d403ce59..ee2392ab7 100644 --- a/internal/api/vision_labels_test.go +++ b/internal/api/vision_labels_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -129,4 +130,13 @@ func TestPostVisionLabels(t *testing.T) { r := PerformRequest(app, http.MethodPost, "/api/v1/vision/labels") assert.Equal(t, http.StatusBadRequest, r.Code) }) + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, _ := NewApiTest() + PostVisionLabels(router) + + body := `{"images":["data:image/jpeg;base64,` + strings.Repeat("a", int(MaxVisionRequestBytes)) + `"]}` + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/vision/labels", body) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) } diff --git a/internal/api/vision_nsfw.go b/internal/api/vision_nsfw.go index 563ca6f4f..57ce1cd42 100644 --- a/internal/api/vision_nsfw.go +++ b/internal/api/vision_nsfw.go @@ -41,7 +41,14 @@ func PostVisionNsfw(router *gin.RouterGroup) { } // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxVisionRequestBytes) + if err := c.BindJSON(&request); err != nil { + if IsRequestBodyTooLarge(err) { + c.JSON(http.StatusRequestEntityTooLarge, vision.NewApiError(request.GetId(), http.StatusRequestEntityTooLarge)) + return + } + c.JSON(http.StatusBadRequest, vision.NewApiError(request.GetId(), http.StatusBadRequest)) return } diff --git a/internal/api/vision_nsfw_test.go b/internal/api/vision_nsfw_test.go index fd46a8118..9aeabe787 100644 --- a/internal/api/vision_nsfw_test.go +++ b/internal/api/vision_nsfw_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -144,4 +145,13 @@ func TestPostVisionNsfw(t *testing.T) { r := PerformRequest(app, http.MethodPost, "/api/v1/vision/nsfw") assert.Equal(t, http.StatusBadRequest, r.Code) }) + t.Run("RequestTooLarge", func(t *testing.T) { + app, router, _ := NewApiTest() + PostVisionNsfw(router) + + body := `{"images":["data:image/jpeg;base64,` + strings.Repeat("a", int(MaxVisionRequestBytes)) + `"]}` + r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/vision/nsfw", body) + + assert.Equal(t, http.StatusRequestEntityTooLarge, r.Code) + }) } diff --git a/internal/api/zip.go b/internal/api/zip.go index 4c6e3685e..01013b8c9 100644 --- a/internal/api/zip.go +++ b/internal/api/zip.go @@ -50,7 +50,14 @@ func ZipCreate(router *gin.RouterGroup) { start := time.Now() // Assign and validate request form values. + LimitRequestBodyBytes(c, MaxSelectionRequestBytes) + if err := c.BindJSON(&frm); err != nil { + if IsRequestBodyTooLarge(err) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + AbortBadRequest(c, err) return }