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..d02ca5639 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,9 @@ 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` / `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. @@ -152,8 +160,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..0a0e4855a 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. + */ +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..a54fbdf7e 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,59 @@ 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', + ); + // pathname comes from the URL, so it keeps the emulator's route prefix + 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();