Problem
In lib/r2/upload.ts, getPresignedUploadUrl derives the stored object key extension straight from the user supplied filename:
const ext = filename.split(".").pop() ?? "bin";
const key = `${folder}/${randomUUID()}.${ext}`;
The contentType is validated against an allowlist in app/api/upload/presign/route.ts (ALLOWED_TYPES), but the extension is not checked against it, and filename is only constrained to 1 to 255 characters. So a request with contentType: "image/png" and filename: "x.html" produces a key ending in .html, and a filename with no dot produces a key ending in the whole filename.
Why it matters
The key becomes part of a public URL under our R2 domain. The extension should describe the bytes we actually agreed to store. It is also just a correctness bug: two identical PNG uploads can end up with different extensions depending on what the browser called the file.
Suggested approach
Stop trusting the filename. Map the already validated content type to an extension:
const EXT_BY_TYPE = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif" } as const;
and build the key from that. Keep the original filename only if it is useful, for example as object metadata, after sanitizing it.
Done when
- The key extension is derived from content type, never from user input.
- Unit tests cover each allowed content type and confirm a hostile filename cannot influence the key.
Good first issue: the change is small and self contained, but read the presign route first so you understand where contentType was already validated.
If you want to take this on, comment on the issue to claim it and it will be assigned. Please keep to a maximum of 2 open claims per person at a time so other contributors get a chance.
Problem
In
lib/r2/upload.ts,getPresignedUploadUrlderives the stored object key extension straight from the user supplied filename:The
contentTypeis validated against an allowlist inapp/api/upload/presign/route.ts(ALLOWED_TYPES), but the extension is not checked against it, andfilenameis only constrained to 1 to 255 characters. So a request withcontentType: "image/png"andfilename: "x.html"produces a key ending in.html, and a filename with no dot produces a key ending in the whole filename.Why it matters
The key becomes part of a public URL under our R2 domain. The extension should describe the bytes we actually agreed to store. It is also just a correctness bug: two identical PNG uploads can end up with different extensions depending on what the browser called the file.
Suggested approach
Stop trusting the filename. Map the already validated content type to an extension:
and build the key from that. Keep the original filename only if it is useful, for example as object metadata, after sanitizing it.
Done when
Good first issue: the change is small and self contained, but read the presign route first so you understand where
contentTypewas already validated.If you want to take this on, comment on the issue to claim it and it will be assigned. Please keep to a maximum of 2 open claims per person at a time so other contributors get a chance.