From af7486be1d64b468a97fa2f8d8600e80c6132490 Mon Sep 17 00:00:00 2001 From: Agustin Falco Date: Mon, 5 Oct 2026 17:23:30 -0300 Subject: [PATCH 1/2] [@vercel/blob] Allow get() to fetch from a VERCEL_BLOB_API_URL origin get() only accepted URLs on *.blob.vercel-storage.com, which blocked local emulators that serve blob content from their own host. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/get-allow-emulator-origin.md | 5 +++ packages/blob/src/get.ts | 14 +++++-- packages/blob/src/helpers.ts | 41 +++++++++++++++---- packages/blob/src/index.node.test.ts | 53 +++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 .changeset/get-allow-emulator-origin.md diff --git a/.changeset/get-allow-emulator-origin.md b/.changeset/get-allow-emulator-origin.md new file mode 100644 index 000000000..c6750e377 --- /dev/null +++ b/.changeset/get-allow-emulator-origin.md @@ -0,0 +1,5 @@ +--- +'@vercel/blob': patch +--- + +Allow `get()` to fetch blob URLs from a `VERCEL_BLOB_API_URL` origin. `get()` only accepted URLs on `*.blob.vercel-storage.com`, which blocked local emulators like [`emulate`](https://npmx.dev/package/emulate), since they serve blob content from their own host. When `VERCEL_BLOB_API_URL` (or `NEXT_PUBLIC_VERCEL_BLOB_API_URL`) is set, URLs on that same origin are now accepted as well. With no override set the behavior is unchanged, so arbitrary hosts are still rejected in production. diff --git a/packages/blob/src/get.ts b/packages/blob/src/get.ts index bb078787d..5eea1a7b3 100644 --- a/packages/blob/src/get.ts +++ b/packages/blob/src/get.ts @@ -1,6 +1,12 @@ import { fetch, type Headers } from 'undici'; import type { BlobAccessType, BlobCommandOptions } from './helpers'; -import { BlobError, constructBlobUrl, isUrl, resolveBlobAuth } from './helpers'; +import { + BlobError, + constructBlobUrl, + isAllowedBlobUrl, + isUrl, + resolveBlobAuth, +} from './helpers'; /** * Options for the get method. @@ -91,7 +97,8 @@ function extractPathnameFromUrl(url: string): string { /** * Fetches blob content by URL or pathname. - * - If a URL is provided, fetches the blob directly. + * - If a URL is provided, fetches the blob directly. It must point at a Vercel Blob + * store, or at the origin of a `VERCEL_BLOB_API_URL` override (local emulators). * - If a pathname is provided, constructs the URL from the resolved store ID (from the read-write token or `BLOB_STORE_ID`). * * Returns a stream (no automatic buffering) and blob metadata. @@ -152,8 +159,7 @@ export async function get( pathname = extractPathnameFromUrl(urlOrPathname); try { - const { hostname } = new URL(blobUrl); - if (!hostname.endsWith('.blob.vercel-storage.com')) { + if (!isAllowedBlobUrl(new URL(blobUrl))) { throw new BlobError( 'Invalid URL: the URL does not point to a Vercel Blob store. Use a pathname instead, see https://vercel.com/docs/vercel-blob', ); diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index 8986a3aed..266a2b349 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -407,19 +407,25 @@ export const supportsRequestStreams = (() => { return duplexAccessed && !hasContentType; })(); -export function getApiUrl(pathname = ''): string { - let baseUrl = null; +/** + * The API base URL set by the user, or null when talking to production. + * Only set when pointing the SDK at an emulator or a staging environment. + */ +export function getApiUrlOverride(): string | null { try { // wrapping this code in a try/catch as this function is used in the browser and Vite doesn't define the process.env. - // As this varaible is NOT used in production, it will always default to production endpoint - baseUrl = + return ( process.env.VERCEL_BLOB_API_URL || - process.env.NEXT_PUBLIC_VERCEL_BLOB_API_URL; + process.env.NEXT_PUBLIC_VERCEL_BLOB_API_URL || + null + ); } catch { - // noop + return null; } +} - return `${baseUrl || defaultVercelBlobApiUrl}${pathname}`; +export function getApiUrl(pathname = ''): string { + return `${getApiUrlOverride() ?? defaultVercelBlobApiUrl}${pathname}`; } const TEXT_ENCODER = @@ -532,6 +538,27 @@ export function isUrl(urlOrPathname: string): boolean { ); } +/** + * Blob content lives on `*.blob.vercel-storage.com`. Local emulators serve it + * from the host in `VERCEL_BLOB_API_URL`, so that origin is allowed too. + */ +export function isAllowedBlobUrl(url: URL): boolean { + if (url.hostname.endsWith('.blob.vercel-storage.com')) { + return true; + } + + const apiUrlOverride = getApiUrlOverride(); + if (!apiUrlOverride) { + return false; + } + + try { + return new URL(apiUrlOverride).origin === url.origin; + } catch { + return false; + } +} + /** * Constructs the blob URL from storeId and pathname. */ diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index ee8a35b5d..bf7281ae0 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -38,6 +38,7 @@ describe('blob client', () => { beforeEach(() => { delete process.env.BLOB_STORE_ID; delete process.env.VERCEL_OIDC_TOKEN; + delete process.env.VERCEL_BLOB_API_URL; process.env.BLOB_READ_WRITE_TOKEN = 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678'; const mockAgent = new MockAgent(); @@ -1619,6 +1620,58 @@ describe('blob client', () => { ); }); + it('should allow a URL on the VERCEL_BLOB_API_URL origin (local emulator)', async () => { + process.env.VERCEL_BLOB_API_URL = 'http://localhost:3001/vercel/blob'; + const mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + setGlobalDispatcher(mockAgent); + mockAgent + .get('http://localhost:3001') + .intercept({ + path: '/vercel/blob/store_123/foo.txt', + method: 'GET', + }) + .reply(200, 'emulated content', { + headers: { + 'content-type': 'text/plain', + 'content-length': '16', + }, + }); + + const result = await get( + 'http://localhost:3001/vercel/blob/store_123/foo.txt', + { access: 'public' }, + ); + + expect(result).not.toBeNull(); + expect(result?.blob.url).toEqual( + 'http://localhost:3001/vercel/blob/store_123/foo.txt', + ); + expect(result?.blob.pathname).toEqual('vercel/blob/store_123/foo.txt'); + }); + + it('should throw when the URL origin differs from the VERCEL_BLOB_API_URL origin', async () => { + process.env.VERCEL_BLOB_API_URL = 'http://localhost:3001/vercel/blob'; + + await expect( + get('http://localhost:4000/foo.txt', { access: 'public' }), + ).rejects.toThrow( + new Error( + 'Vercel Blob: Invalid URL: the URL does not point to a Vercel Blob store. Use a pathname instead, see https://vercel.com/docs/vercel-blob', + ), + ); + }); + + it('should throw for a localhost URL when no API URL override is set', async () => { + await expect( + get('http://localhost:3001/foo.txt', { access: 'public' }), + ).rejects.toThrow( + new Error( + 'Vercel Blob: Invalid URL: the URL does not point to a Vercel Blob store. Use a pathname instead, see https://vercel.com/docs/vercel-blob', + ), + ); + }); + it('should allow valid blob store URL', async () => { const mockAgent = new MockAgent(); mockAgent.disableNetConnect(); From d1a1576c9272c1a12e05e053b55cbdb3d7a2044a Mon Sep 17 00:00:00 2001 From: Agustin Falco Date: Mon, 5 Oct 2026 17:25:29 -0300 Subject: [PATCH 2/2] [@vercel/blob] Address review: unexport getApiUrlOverride, clarify docs Co-Authored-By: Claude Opus 5 (1M context) --- packages/blob/src/get.ts | 3 ++- packages/blob/src/helpers.ts | 2 +- packages/blob/src/index.node.test.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/blob/src/get.ts b/packages/blob/src/get.ts index 5eea1a7b3..d02ca5639 100644 --- a/packages/blob/src/get.ts +++ b/packages/blob/src/get.ts @@ -98,7 +98,8 @@ function extractPathnameFromUrl(url: string): string { /** * Fetches blob content by URL or pathname. * - If a URL is provided, fetches the blob directly. It must point at a Vercel Blob - * store, or at the origin of a `VERCEL_BLOB_API_URL` override (local emulators). + * store, or at the origin of a `VERCEL_BLOB_API_URL` / `NEXT_PUBLIC_VERCEL_BLOB_API_URL` + * override (local emulators). * - If a pathname is provided, constructs the URL from the resolved store ID (from the read-write token or `BLOB_STORE_ID`). * * Returns a stream (no automatic buffering) and blob metadata. diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index 266a2b349..0a0e4855a 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -411,7 +411,7 @@ export const supportsRequestStreams = (() => { * The API base URL set by the user, or null when talking to production. * Only set when pointing the SDK at an emulator or a staging environment. */ -export function getApiUrlOverride(): string | null { +function getApiUrlOverride(): string | null { try { // wrapping this code in a try/catch as this function is used in the browser and Vite doesn't define the process.env. return ( diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index bf7281ae0..a54fbdf7e 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -1647,6 +1647,7 @@ describe('blob client', () => { expect(result?.blob.url).toEqual( 'http://localhost:3001/vercel/blob/store_123/foo.txt', ); + // pathname comes from the URL, so it keeps the emulator's route prefix expect(result?.blob.pathname).toEqual('vercel/blob/store_123/foo.txt'); });