Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/get-allow-emulator-origin.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 11 additions & 4 deletions packages/blob/src/get.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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',
);
Expand Down
41 changes: 34 additions & 7 deletions packages/blob/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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.
*/
Expand Down
54 changes: 54 additions & 0 deletions packages/blob/src/index.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading