fix(16.2): WR-02 check the whole upload body before Upload stores it

The route called the type's Upload on the streamed part and only then
checked that no second part followed, so a refused request left a
public, unreferenced object in the bucket. It now reads the multipart
body whole (ReadForm, spooling past 8 MiB to a temporary file), checks
it holds exactly one file part named file, and only then calls Upload.
The remaining window (a failed record transaction after Upload, an
element that stopped waiting) is documented.
This commit is contained in:
2026-10-10 16:43:59 +02:00
parent 3158bb5b57
commit c532529d18
4 changed files with 91 additions and 14 deletions

View File

@@ -118,7 +118,7 @@ A column in `columns.yaml` may name a plugin alias as its `type`. The list then
## Uploads
A type that stores files (images in a document, say) sets `Upload`. The element never uploads anything itself: it asks the admin, which posts the file with the administrator's session to `POST <prefix>/api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` as one multipart part named `file`. cabana checks the backend session, the `X-Requested-With` header, the controller's permissions, that the controller declares the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise; a 403 otherwise, as the save routes answer), that the field has a plugin type with `Upload` and that its `context` allows the operation, then caps the body at `http.body_limits.upload_bytes`. `{id}` 0 is the create form; any other id loads the record through the controller's `pact.FormExtendQuery` scope inside a transaction, and a record outside it is a 404. `Upload` receives a `pact.FormFieldUpload` with the transaction, the record (nil on the create form), the field, its options, the file name and the capped body, and returns the URL the element shows. The route answers 201 `{"data": {"url": "..."}}`; a `pact.FormFieldError` is a 422 on the field, a body over the cap a 413, and any other error the opaque 500. Checking the file (its size, its real type) is the type's job.
A type that stores files (images in a document, say) sets `Upload`. The element never uploads anything itself: it asks the admin, which posts the file with the administrator's session to `POST <prefix>/api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` as one multipart part named `file`. cabana checks the backend session, the `X-Requested-With` header, the controller's permissions, that the controller declares the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise; a 403 otherwise, as the save routes answer), that the field has a plugin type with `Upload` and that its `context` allows the operation, then caps the body at `http.body_limits.upload_bytes`. It reads the whole body and checks that it holds exactly that one part before it calls `Upload` (a large file is spooled to a temporary file), so a malformed or oversized request never stores anything. A stored file can still be left unreferenced when the record's transaction fails after `Upload` returned or the element stops waiting for the answer, so `Upload` should store under a name that is safe to leave behind. `{id}` 0 is the create form; any other id loads the record through the controller's `pact.FormExtendQuery` scope inside a transaction, and a record outside it is a 404. `Upload` receives a `pact.FormFieldUpload` with the transaction, the record (nil on the create form), the field, its options, the file name and the capped body, and returns the URL the element shows. The route answers 201 `{"data": {"url": "..."}}`; a `pact.FormFieldError` is a 422 on the field, a body over the cap a 413, and any other error the opaque 500. Checking the file (its size, its real type) is the type's job.
## The element