fix(api/v2): accept real image content-types on avatar upload

Browsers set a real image Content-Type (image/png, image/jpeg, ...) on
the multipart avatar part, while programmatic clients often send
application/octet-stream. The part contentType tag is an allow-list for
Huma's MimeTypeValidator, which runs before the handler; broaden it so
both cases are accepted instead of being rejected with a 422.

The byte-level mimetype.DetectReader check in the handler remains the
real security gate and is unchanged.

Extend the webtest with a case that sends a part declared as image/png
and asserts it reaches the handler successfully.
This commit is contained in:
kolaente 2026-05-31 21:06:53 +02:00 committed by kolaente
parent 782c17c01d
commit cfac0773d7
2 changed files with 52 additions and 6 deletions

View File

@ -37,13 +37,16 @@ import (
// Huma's MultipartFormFiles renders the "avatar" field as a binary file in the
// generated OpenAPI spec; the file bytes are read from in.RawBody.Data().Avatar.
type avatarUploadInput struct {
// contentType is intentionally permissive: the real, byte-level image
// check is done in the handler via mimetype.DetectReader (the same
// allow-list v1 uses), not via the client-declared part Content-Type.
// "image/*" here would make Huma reject on the declared header before the
// handler runs and trust a value the client controls.
// contentType lists the part Content-Types Huma's MimeTypeValidator accepts
// before our handler runs. Browsers set a real image Content-Type on the
// part (image/png, image/jpeg, ...) while programmatic clients often send
// application/octet-stream, so both must be allowed or a legitimate upload
// would be rejected with a 422 before reaching the handler. This is NOT the
// security gate: the real, byte-level image check is done in the handler via
// mimetype.DetectReader (the same allow-list v1 uses); the part Content-Type
// is client-controlled and must never be trusted on its own.
RawBody huma.MultipartFormFiles[struct {
Avatar huma.FormFile `form:"avatar" contentType:"application/octet-stream" required:"true" doc:"The avatar image to upload. Must be an image; it is resized server-side and re-encoded as PNG."`
Avatar huma.FormFile `form:"avatar" contentType:"image/png,image/jpeg,image/gif,image/webp,image/svg+xml,application/octet-stream" required:"true" doc:"The avatar image to upload. Must be an image; it is resized server-side and re-encoded as PNG."`
}]
}

View File

@ -19,12 +19,14 @@ package webtests
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"testing"
"code.vikunja.io/api/pkg/db"
@ -54,6 +56,8 @@ func pngBytes(t *testing.T) []byte {
// multipartAvatarBody returns a multipart/form-data body with a single
// "avatar" file field plus the matching Content-Type header (with boundary).
// CreateFormFile sets the part's Content-Type to application/octet-stream, which
// mirrors how many programmatic clients upload.
func multipartAvatarBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
t.Helper()
buf := &bytes.Buffer{}
@ -66,6 +70,24 @@ func multipartAvatarBody(t *testing.T, fieldName, filename string, content []byt
return buf, w.FormDataContentType()
}
// multipartAvatarBodyWithPartType is like multipartAvatarBody but lets the
// caller set the part's Content-Type header, mirroring how a browser declares a
// real image type (e.g. image/png) on the file part.
func multipartAvatarBodyWithPartType(t *testing.T, fieldName, filename, partContentType string, content []byte) (*bytes.Buffer, string) {
t.Helper()
buf := &bytes.Buffer{}
w := multipart.NewWriter(buf)
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, fieldName, filename))
h.Set("Content-Type", partContentType)
fw, err := w.CreatePart(h)
require.NoError(t, err)
_, err = fw.Write(content)
require.NoError(t, err)
require.NoError(t, w.Close())
return buf, w.FormDataContentType()
}
// uploadAvatarRequest dispatches a multipart avatar upload against a prepared echo.Echo.
func uploadAvatarRequest(t *testing.T, e *echo.Echo, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder {
t.Helper()
@ -99,6 +121,27 @@ func TestAvatarUpload(t *testing.T) {
assert.NotZero(t, u.AvatarFileID)
})
t.Run("Real image content-type on the part", func(t *testing.T) {
// Browsers declare a real image Content-Type (e.g. image/png) on the
// file part. Huma's MimeTypeValidator must accept it so the request
// reaches the handler instead of being rejected with a 422.
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
body, contentType := multipartAvatarBodyWithPartType(t, "avatar", "avatar.png", "image/png", pngBytes(t))
rec := uploadAvatarRequest(t, e, body, contentType, token)
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Contains(t, rec.Body.String(), "uploaded successfully")
s := db.NewSession()
defer s.Close()
u, err := user.GetUserByID(s, testuser1.ID)
require.NoError(t, err)
assert.Equal(t, "upload", u.AvatarProvider)
assert.NotZero(t, u.AvatarFileID)
})
t.Run("Non-image rejected", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)