Skip to content

fix(blob): validate and encode blob pathnames - #938

Merged
atinux merged 5 commits into
mainfrom
fix/blob-pathname-validation
Oct 19, 2026
Merged

atinux merged 5 commits into
mainfrom
fix/blob-pathname-validation

Conversation

@atinux

@atinux atinux commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Hardens blob pathname handling, bumps unstorage to the release that fixes unencoded storage keys in URL-based drivers, and upgrades Nuxt to 4.6.2, which that unstorage release requires for prerendering.

Blob

Blob pathnames are often built from user input: route params passed to blob.serve() / blob.get() / blob.del(), and uploaded file names in blob.handleUpload(). They were not validated, and each pathname was URL-decoded twice (once in the storage layer, once in the driver):

  • fs (the default driver for Node hosting): .. segments resolved outside the blob directory. This allowed reading, listing and writing arbitrary files, for example GET /api/blob/..%2F..%2Fpackage.json, or uploading a file named ..%2Fevil.txt. A crafted multipart uploadId could also make abort() recursively delete any directory.
  • s3: keys were inserted into the request URL with encodeURI, so .., ? and # could target another bucket (path-style endpoints), bucket-level APIs (del('?policy') sends DELETE /bucket/?policy), or a different key. Batch delete also inserted keys into the XML body unescaped.

Changes:

  • createBlobStorage decodes each pathname exactly once and rejects empty pathnames, control characters, and ./.. segments (split on / and \) with 400 Invalid blob pathname. The final pathname is checked after prefix and addRandomSuffix are applied, and list() prefixes are checked too.
  • Drivers no longer decode pathnames a second time.
  • fs validates pathnames itself and requires multipart upload IDs to be UUIDs.
  • s3 percent-encodes each key segment (RFC 3986, matching what aws4fetch signs), escapes keys in batch-delete XML, and no longer throws when listing keys that contain a literal %.

unstorage

Bumps unstorage to ^1.17.6, which fixes the same class of issue in the s3, http, cloudflare-kv-http, netlify-blobs and vercel-runtime-cache drivers. hub:kv auto-selects s3 when the S3_* env vars are set, and hub:cache defaults to vercel-runtime-cache on Vercel. The lockfile change is limited to unstorage and its new [email protected] dependency (checked with [email protected] install --frozen-lockfile).

Nuxt 4.6.2

[email protected] rejects keys that are empty after normalization, including "/". Nuxt 4.3 used "/" as the prerender payload cache key for the root page, so prerendering / failed with [unstorage] Invalid key: "/" (this broke the docs deployment). Nuxt 4.6 uses "/.json". Apps still on Nuxt 4.3.x that prerender / will hit the same error once they resolve [email protected].

  • Root, playground and docs move to Nuxt 4.6.2, and vue is deduped to 3.5.44 (two Vue copies broke SSR). @nuxt/kit stays at ^4.3.1, so the module's supported Nuxt range does not change.
  • Docs: Nuxt 4.6 ships unhead 3, which needs nuxt-og-image v7. The template is renamed to OgImageDocs.satori.vue, satori and @resvg/resvg-js are added, box-border keeps the text column at its previous width, and pages pass title/description explicitly because v7 no longer infers them. The changelog post image is set with useSeoMeta({ ogImage }). The prerendered OG images for a docs page, /templates and /changelog match production.
  • @nuxt/ui moves to 4.12.0 (docs and playground) and the docs move to @nuxt/fonts v1, which nuxt-og-image v7 uses to read fonts. @nuxt/ui still depends on @nuxt/fonts 0.x, so the docs register @nuxt/fonts explicitly in modules. OG images now use the site font (Public Sans) instead of the bundled Inter fallback.
  • Docs on Vercel: OG images are prerendered, so the build needs no secret. Runtime (non-prerendered) OG images need NUXT_APP_SECRET (at least 32 characters) in the Vercel project.

Behavior changes

  • Blob pathnames that are empty, contain ./.. segments or control characters, or have invalid percent-encoding now throw a 400.
  • s3: a pathname that still contains %XX after the first decode is now stored under that literal key instead of being decoded again. + is now stored as + (it could previously be read as a space).
  • fs / cloudflare-r2: pathnames containing % now read back the same object they were written to (put and get previously disagreed).

Tests

  • test/blob.test.ts: traversal over HTTP (read and upload) is rejected, and special characters (%, ?, #, spaces) round-trip. These tests fail without the fix; the upload test writes evil.txt outside the blob directory.
  • test/blob.s3.test.ts: S3 request URLs stay inside the bucket's object path, and escaping pathnames are rejected before any request is sent.
  • Checked manually against a local S3 server that verifies SigV4 signatures (moto): put/get/head/list/batch delete/multipart with keys containing spaces, +, %, ?, #, ()!*, unicode and <&>. The old driver returned objects from another bucket there.

ampagent and others added 2 commits October 12, 2026 20:29
Decode pathnames once in the storage layer and reject empty pathnames,
control characters and `.`/`..` segments. Drivers no longer decode a
second time. The fs driver validates paths and multipart upload IDs, and
the s3 driver percent-encodes each key segment and escapes keys in batch
delete requests.

Amp-Thread-ID: https://ampcode.com/threads/T-01a131bf-0030-71a4-88a0-7d9184ccfdd4
Co-authored-by: Sebastien Chopin <[email protected]>
Includes the fix for unencoded storage keys in URL-based drivers (s3,
http, cloudflare-kv-http, netlify-blobs, vercel-runtime-cache), used by
hub:kv and hub:cache.

Amp-Thread-ID: https://ampcode.com/threads/T-01a131bf-0030-71a4-88a0-7d9184ccfdd4
Co-authored-by: Sebastien Chopin <[email protected]>
@vercel

vercel Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nuxthub Ready Ready Preview Oct 19, 2026 7:54pm UTC

@pkg-pr-new

pkg-pr-new Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
npm i https://pkg.pr.new/@nuxthub/core@938

commit: 4112b49

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
playground 4112b49 Commit Preview URL

Branch Preview URL
Oct 19 2026, 07:53 PM

unstorage 1.17.6 rejects empty keys such as "/", which Nuxt 4.3 used for the
prerender payload cache of the root page. Nuxt 4.6 uses "/.json" instead.

Docs: migrate nuxt-og-image to v7 (required by unhead 3): rename the
template with the .satori suffix, add satori and @resvg/resvg-js, use
box-border to keep the text column width, pass title and description
explicitly, and set the changelog post image with useSeoMeta.

Amp-Thread-ID: https://ampcode.com/threads/T-01a131bf-0030-71a4-88a0-7d9184ccfdd4
Co-authored-by: Sebastien Chopin <[email protected]>
Nuxt 4.6 locks the dev server per project. Two test files start a dev
server for the same fixture, and on Windows the first one can still be
alive when the second starts.

Amp-Thread-ID: https://ampcode.com/threads/T-01a131bf-0030-71a4-88a0-7d9184ccfdd4
Co-authored-by: Sebastien Chopin <[email protected]>
nuxt-og-image v7 reads fonts from @nuxt/fonts v1. @nuxt/ui still depends
on @nuxt/fonts 0.x, so the docs register @nuxt/fonts explicitly to keep
@nuxt/ui from installing its own copy.

Amp-Thread-ID: https://ampcode.com/threads/T-01a131bf-0030-71a4-88a0-7d9184ccfdd4
Co-authored-by: Sebastien Chopin <[email protected]>
@atinux
atinux merged commit 4602596 into main Oct 19, 2026
10 checks passed
@atinux
atinux deleted the fix/blob-pathname-validation branch October 19, 2026 20:08
vitehub-bot Bot added a commit to vite-hub/vitehub that referenced this pull request Oct 20, 2026
* fix(blob): reject unsafe pathnames

Port the pathname checks of nuxt-hub/core#938. Blob methods reject empty pathnames, control characters, and dot segments with BLOB_R0034 (statusCode 400). put() and multipart uploads check the final pathname after prefix and suffix, list() checks its prefix, handleUpload() stores file names without URL-decoding, and the native R2 signer rejects dot segments before new URL() can collapse them.

* fix(blob): reject unpaired surrogate pathnames

---------

Co-authored-by: vitehub-bot[bot] <320448255+vitehub-bot[bot]@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview — 4112b498 Deployed Oct 19, 2026 by vercel[bot]
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.

2 participants