API: Export request limit helpers and align test guidance

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2026-03-08 13:49:50 +01:00
parent cc7064b5a9
commit 9219d14d3f
47 changed files with 485 additions and 56 deletions

View file

@ -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 <name>` (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/<file>.go`, add tests in `path/to/pkg/<file>_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`.

View file

@ -24,6 +24,7 @@ The API package exposes PhotoPrisms 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 PhotoPrisms 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 PhotoPrisms 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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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
}

View file

@ -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 := &reg.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)
}

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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) {

View file

@ -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)

View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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
}