From 205379f6a91a8de9ed92477da33d2ad8642ba3bf Mon Sep 17 00:00:00 2001 From: Thanatat Tamtan Date: Mon, 29 Jun 2026 08:40:51 +0700 Subject: [PATCH] fix(upload): compare media type only when enforcing pinned Content-Type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signed direct-upload handler (PUT /uploads/{token}) enforced the grant's pinned Content-Type with an exact string match, so a client that adds a standard parameter — e.g. 'application/pdf; charset=utf-8' against a grant for 'application/pdf' — was wrongly rejected with 'content type mismatch'. Compare only the media type (type/subtype) via mime.ParseMediaType on both sides, per RFC 7231 §3.1.1.1. This is not a weakening: the body is streamed to storage without sniffing, so the pin was always a declared-media-type check — ignoring parameters that don't change the media type preserves the constraint. A real mismatch (text/plain vs application/pdf) is still rejected, and a malformed/empty client Content-Type parses to "" and is rejected (fail-closed). ParseMediaType also lowercases, so the match is correctly case-insensitive. Add TestUploadDirect_ContentTypeAllowsParams (charset param accepted); the existing TestUploadDirect_ContentTypeMismatch (text/plain rejected) still passes. go build ./... ok; gofmt/go vet clean; go test -run TestUploadDirect_ContentType = 2 pass. --- upload_url.go | 16 +++++++++++++--- upload_url_test.go | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/upload_url.go b/upload_url.go index 0519dbb..e2b1df5 100644 --- a/upload_url.go +++ b/upload_url.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "log/slog" + "mime" "net/http" "net/url" "strings" @@ -259,9 +260,18 @@ func (a *App) uploadDirectHandler(w http.ResponseWriter, r *http.Request) { jsonFail(w, "upload url expired", http.StatusOK) return } - if g.ContentType != "" && r.Header.Get("Content-Type") != g.ContentType { - jsonFail(w, "content type mismatch", http.StatusOK) - return + if g.ContentType != "" { + // Compare only the media type (type/subtype), ignoring parameters such as + // charset/boundary per RFC 7231 §3.1.1.1: a client sending + // "application/json; charset=utf-8" against a grant for "application/json" + // is the same media type. ParseMediaType also lowercases, so the match is + // case-insensitive; a malformed/empty header parses to "" and is rejected. + got, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + want, _, _ := mime.ParseMediaType(g.ContentType) + if got != want { + jsonFail(w, "content type mismatch", http.StatusOK) + return + } } if r.Body == nil { jsonFail(w, "body empty", http.StatusOK) diff --git a/upload_url_test.go b/upload_url_test.go index ca8f8ba..3f89533 100644 --- a/upload_url_test.go +++ b/upload_url_test.go @@ -470,6 +470,29 @@ func TestUploadDirect_ContentTypeMismatch(t *testing.T) { } } +// TestUploadDirect_ContentTypeAllowsParams guards that a charset/boundary +// parameter on the granted media type is accepted — the content-type check +// compares media types (type/subtype) only, not the raw header. +func TestUploadDirect_ContentTypeAllowsParams(t *testing.T) { + t.Parallel() + db := newTestDB(t) + bkt := newTestBucket(t) + app := newTestApp(bkt, authorized) + + resp := issueUploadURL(t, app, db, uploadURLRequest{Project: "p", ContentType: "application/pdf", MaxSize: 100}) + token := uploadTokenOf(t, resp.Result.UploadURL) + + w := doPut(t, app, db, token, "application/pdf; charset=utf-8", "data") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body.String()) + } + var pr putResp + json.NewDecoder(w.Body).Decode(&pr) + if !pr.OK { + t.Fatalf("expected ok=true (charset param on granted media type accepted), got %s", w.Body.String()) + } +} + func TestUploadDirect_InvalidToken(t *testing.T) { t.Parallel() db := newTestDB(t)