The standard client-upload example authenticates the user in
onBeforeGenerateToken and returns a token. It never looks at pathname.
That is a hole. The browser chose that pathname.
What goes wrong
onBeforeGenerateToken: async (pathname) => {
const user = await requireUser(); // authenticated, so...
return { allowedContentTypes: ["image/png"] };
},
A signed-in attacker calls upload("victim-id/avatar.png", evil, ...). They are
authenticated, so they get a token for victim-id/avatar.png.
- With
allowOverwrite: true, they replace the victim's file. - With the default
false, they can still squat any pathname before the victim does, or fill someone else's folder. - If your code later trusts "files under
<userId>/" as that user's files, they have planted a file in someone else's account.
Why you cannot just rewrite it
handleUpload() signs the token for the pathname the client sent. Your
callback can accept or refuse it. It cannot change it. A "sanitised" pathname
you compute and return does not change which pathname the token allows.
The fix: the server mints, then verifies
Reserve. The browser posts
{ filename, contentType, size }. The server authenticates and builds the key from the session:`${user.id}/${folder}/${crypto.randomUUID()}-${safeName}`.Upload. The browser calls
upload(key, file, ...)with that key.Verify in
onBeforeGenerateToken. Refuse unless the pathname:- starts with the caller's own id, compared as a full segment
(
key.split("/")[0] === userId, neverstartsWith, oralice-2passes foralice); - has the exact shape the server mints (folder, uuid, safe name);
- ends in the extension for the content type the token will allow.
- starts with the caller's own id, compared as a full segment
(
onBeforeGenerateToken: async (pathname, clientPayload) => {
const user = await requireUser();
const { contentType, size } = JSON.parse(clientPayload ?? "{}");
assertAllowed(contentType, size);
assertOwnedKey(pathname, user.id, contentType);
return {
allowedContentTypes: [contentType],
maximumSizeInBytes: size,
allowOverwrite: false,
addRandomSuffix: false,
validUntil: Date.now() + 15 * 60 * 1000,
tokenPayload: JSON.stringify({ userId: user.id }),
};
},
Treat clientPayload as hostile too
clientPayload is whatever the browser sent. Validate it like a request body.
The safe move is to use it only to narrow the token: one content type, the
claimed size. If the browser lied, the store rejects the upload.
tokenPayload is different: your server wrote it and it is signed into the
token. That is what onUploadCompleted should trust for the owner id.
Also check on the way out
Any route that reads or deletes a key from a request needs the same owner check. Authentication proves who is asking. It does not prove the file is theirs.