Skip to content

fix(blob): decode delegation token payload as UTF 8 in presignUrl - #1103

Open
Om-singhaI wants to merge 1 commit into
vercel:mainfrom
Om-singhaI:fix/blob-presign-utf8-pathname
Open

fix(blob): decode delegation token payload as UTF 8 in presignUrl#1103
Om-singhaI wants to merge 1 commit into
vercel:mainfrom
Om-singhaI:fix/blob-presign-utf8-pathname

Conversation

@Om-singhaI

Copy link
Copy Markdown

fix(blob): decode delegation token payload as UTF 8 in presignUrl

Fixes #1101

What is broken and why

presignUrl reads the pathname scope out of the delegation token payload and compares it with the pathname the caller passed in. Any pathname holding a non ASCII character (Danish, German, Cyrillic, Japanese, emoji, and so on) is rejected with:

Vercel Blob: Blob path does not match the signed token scope; expected `uploads/Skærmbillede.png`, got `uploads/Skærmbillede.png`.

The two strings are the same. The token is simply being read back wrong.

Root cause is base64UrlDecodeToString in packages/blob/src/signed-token.ts (line 168 on main). It prefers atob whenever that global exists:

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 a UTF 8 payload comes back as mojibake. Node 18 and later define atob globally, so the Buffer branch that would have decoded UTF 8 correctly never runs on the server, which is exactly where handleUploadPresigned and presignUrl are called. tryDecodePayload then parses the mojibake JSON and the scope check in presign (line 364 on main) throws.

The same decoder is duplicated as base64UrlDecodeDelegationSegment in packages/blob/src/helpers.ts (line 182 on main). That copy only reads storeId, which is ASCII, so it does not misbehave today, but it has the same latent bug.

The decoder was introduced in #1056 and has not changed since, so @vercel/blob 2.6.1 through 2.8.0 are all affected.

The fix

In both helpers, the atob branch now converts the binary string into bytes and decodes those bytes as UTF 8 with TextDecoder when that global exists. When it does not, the raw atob string is returned exactly as before:

const binary = atob(base64);
if (typeof TextDecoder === 'function') {
  return new TextDecoder().decode(
    Uint8Array.from(binary, (c) => c.charCodeAt(0)),
  );
}
// React Native (Hermes) has `atob` but not `TextDecoder`
return binary;

Why the guard matters: this package supports React Native and already feature detects TextEncoder for it (computeBodyLength in helpers.ts). Hermes added atob and TextEncoder in React Native 0.74, but native TextDecoder only arrives with the new Hermes stable release, so apps on React Native 0.74 through 0.83 without a polyfill have atob and no TextDecoder. On those apps parseStoreIdFromDelegationToken runs inside resolveBlobAuth for every presigned upload, and an unguarded new TextDecoder() there would have turned every presigned upload, ASCII pathnames included, into Invalid delegation token payload.. With the guard, that runtime keeps the behaviour it has today (ASCII payloads decode, non ASCII payloads are still mojibake there until TextDecoder exists), while browsers, the Edge runtime and Node, which all ship TextDecoder, get the correct UTF 8 decode. No other behaviour changes.

Files changed:

File Change
packages/blob/src/signed-token.ts UTF 8 decode in the atob branch of base64UrlDecodeToString, guarded on TextDecoder
packages/blob/src/helpers.ts same change in base64UrlDecodeDelegationSegment
packages/blob/src/signed-token.presignurl.shared-spec.ts new tests accepts non-ASCII pathnames that match the token scope and falls back to the raw atob string when TextDecoder is unavailable
.changeset/blob-presign-utf8-pathname.md patch changeset for @vercel/blob

Testing

Reproduction on pristine main (31245dc, Node 25.6.1)

Built the package and ran the script from the issue against dist/index.js:

uploads/plain-ascii.png          accepted
uploads/Skærmbillede.png         THROWS: Vercel Blob: Blob path does not match the signed token scope; expected `uploads/Skærmbillede.png`, got `uploads/Skærmbillede.png`.
uploads/Снимок.png               THROWS: Vercel Blob: Blob path does not match the signed token scope; expected `uploads/Снимок.png`, got `uploads/Снимок.png`.
uploads/スクリーンショット.png            THROWS: ... expected `uploads/ã¹ã¯ãªã¼ã³ã·ã§ãã.png`, got `uploads/スクリーンショット.png`.
uploads/😀.png                   THROWS: ... expected `uploads/ð.png`, got `uploads/😀.png`.

After the fix, rebuilt and reran: all five pathnames are accepted.

New tests

Both are added next to the existing path scope tests in signed-token.presignurl.shared-spec.ts, so they run under both the Node and the jsdom (browser) suites.

  1. accepts non-ASCII pathnames that match the token scope: issues a delegation token scoped to each of uploads/plain-ascii.png, uploads/Skærmbillede.png, uploads/Снимок.png, uploads/スクリーンショット.png and uploads/😀.png (the test helper builds the payload with Buffer.from(json, 'utf8'), the same way the API does), then asserts presign accepts the matching pathname and produces the expected HMAC.
  2. falls back to the raw atob string when TextDecoder is unavailable: removes the TextDecoder global for the duration of the test (restored in finally), then asserts presign still accepts an ASCII pathname and that parseStoreIdFromDelegationToken still returns the store id. This covers the fallback branch in both decoders.

Commands (from packages/blob):

npx jest --env node src/signed-token.node.test.ts
npx jest --env jsdom src/signed-token.browser.test.ts --setupFilesAfterEnv ./jest/setup.js
main with fix
signed-token.node.test.ts 8 passed 10 passed
signed-token.browser.test.ts 8 passed 10 passed

Proof the tests catch what they are meant to catch (source files swapped, spec kept, both environments gave the same result):

  • Source at main: test 1 fails, test 2 passes.
    ● presignUrl › accepts non-ASCII pathnames that match the token scope
      Vercel Blob: Blob path does not match the signed token scope; expected `uploads/Skærmbillede.png`, got `uploads/Skærmbillede.png`.
    Tests: 1 failed, 9 passed, 10 total
    
  • Source with the UTF 8 decode but without the TextDecoder guard: test 1 passes, test 2 fails, reproducing exactly the React Native regression the guard prevents.
    ● presignUrl › falls back to the raw atob string when TextDecoder is unavailable
      Vercel Blob: Invalid or unreadable `delegationToken` payload.
    Tests: 1 failed, 9 passed, 10 total
    
  • Final source: 10 passed, 10 total in both environments.

Full @vercel/blob suite (pnpm run test, which runs node, edge and browser)

Environment main with fix
node 202 passed 204 passed
edge 1 passed 1 passed
browser (jsdom) 19 passed 21 passed

Lint and types

  • biome check . in packages/blob: exit 0. It prints two warnings, both already present on main and untouched here: lint/correctness/noUnusedImports for the BlobError import in src/create-folder.ts, and lint/correctness/noUnusedVariables for normalizeStoreId in src/signed-token.ts.
  • tsc --noEmit in packages/blob: exit 0.
  • The husky lint-staged pre commit hook ran biome on the staged files and made no changes.

Notes for reviewers

  • Not verified on a real React Native device. The fallback is exercised by deleting the TextDecoder global under Node and jsdom, which reproduces the condition (atob present, TextDecoder absent) rather than the runtime itself.
  • Open PR disallow control chars in signed parameters #1071 (control chars in signed parameters) also edits signed-token.ts and the shared spec, but it does not touch the decoder, and its spec hunk starts after the rejects path mismatch test. The new tests here are placed before that test so the two should not conflict.
  • The helpers.ts change is purely for consistency; storeId is ASCII so that path was not failing in practice.
  • TextDecoder with its default options substitutes U+FFFD for invalid UTF 8 and drops a leading BOM, whereas Buffer.toString('utf8') keeps the BOM. Neither case can occur for the JSON payloads the API issues, so the atob and Buffer branches agree for real tokens.
  • I did not dedupe the two helpers into one to keep the diff small. With the guard they are now three identical branches in two places, so a single shared helper would be a reasonable follow up if preferred.

presignUrl reads the pathname scope out of the delegation token payload and
compares it against the pathname the caller passed in. The payload segment
was decoded with atob whenever that global exists, and atob returns one
character per byte, so any pathname containing non ASCII characters came back
as mojibake. Node 18 and later define atob globally, which means the Buffer
branch that would have decoded UTF 8 correctly never ran on the server. As a
result uploads such as uploads/Skærmbillede.png failed with a false "Blob path
does not match the signed token scope" error even though the two strings were
identical.

The atob branch now turns the binary string into bytes and decodes them with
TextDecoder when that global exists, so browsers, edge and Node all produce
the same UTF 8 string. React Native on Hermes ships atob but, before the new
Hermes stable release, no TextDecoder; in that case the raw atob string is
returned exactly as before, so ASCII payloads keep working there and the
client side presigned upload flow is not affected. The same change is applied
to the duplicate helper in helpers.ts that reads storeId from a delegation
token, so both decoders behave identically.

Adds shared presignUrl tests that run under both the Node and jsdom suites:
one with ASCII, Danish, Cyrillic, Japanese and emoji pathnames, and one that
removes the TextDecoder global to exercise the React Native fallback for both
decoders. Also adds a patch changeset for @vercel/blob.

Fixes vercel#1101
@changeset-bot

changeset-bot Bot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e308443

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@vercel/blob Patch
vercel-storage-integration-test-suite Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Someone is attempting to deploy a commit to the Curated Tests - Permanent E2E Team on Vercel.

A member of the Team first needs to authorize it.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant