Skip to content

@vercel/blob: presign() decodes the delegation token with atob, so any non-ASCII pathname throws a false scope mismatch #1101

Description

@batbattur

Uploading a file whose name holds any non-ASCII character fails with a scope
mismatch that isn't real. The path passed in and the path in the token are
the same string. The token is only being read back wrong.

Danish, German, Spanish, French, Greek, Cyrillic, Japanese and emoji names
all hit it, so those users cannot upload at all.

Reproducing

No account, no store, no network. Against 2.8.0 on Node 22:

import { presignUrl } from '@vercel/blob';

const pathname = 'uploads/Skærmbillede.png';
const claims = { pathname, operations: ['put'], validUntil: Date.now() + 600_000 };
const delegationToken =
   Buffer.from(JSON.stringify(claims), 'utf8').toString('base64url') + '.sig';

await presignUrl({ delegationToken, clientSigningToken: 'cst' }, { pathname, operation: 'put' });
uploads/plain-ascii.png       accepted
uploads/Skærmbillede.png      expected `uploads/Skærmbillede.png`, got `uploads/Skærmbillede.png`
uploads/Снимок.png            expected `uploads/Снимок.png`, got `uploads/Снимок.png`
uploads/スクリーンショット.png    expected `uploads/ã¹ã¯ãªã¼ã³ã·ã§ãã.png`, got `uploads/スクリーンショット.png`

Cause

presign() compares options.pathname against the pathname it decodes out
of the delegation token, and the decoder prefers atob:

// dist/chunk-YYMLUMXS.js, base64UrlDecodeToString
if (typeof atob === "function") {
   return atob(base64);
}
if (typeof Buffer !== "undefined") {
   return Buffer.from(base64, "base64").toString("utf8");
}

atob returns one character per byte, so UTF-8 comes back as mojibake. Node
18 and later define atob globally, so the correct Buffer branch never
runs on the server, which is where handleUploadPresigned calls this.

The store is fine. POST a non-ASCII pathname to /signed-token and the
delegation token comes back holding it exactly.

Suggested fix

Decode as UTF-8 in both branches, by preferring Buffer where it exists or
by running the atob result through TextDecoder:

new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)))

Affects 2.6.1 through 2.8.0, the current latest; base64UrlDecodeToString is
unchanged across them. The older handleUpload client token flow is fine,
since it never decodes a delegation token.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions