Skip to content

Commit a9a733a

Browse files
vvoclaude
andauthored
fix(blob): validate URL domain in get() to prevent token leakage (#997)
* fix(blob): validate URL domain in get() to prevent token leakage When get() receives a full URL, it now validates that the hostname ends with .blob.vercel-storage.com before sending the Bearer token. This prevents leaking the blob token to arbitrary domains via user input. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * changeset Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6c1046f commit a9a733a

3 files changed

Lines changed: 78 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@vercel/blob': patch
3+
---
4+
5+
fix: validate URL domain in `get()` to prevent sending the token to arbitrary hosts

packages/blob/src/get.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,18 @@ export async function get(
173173
if (isUrl(urlOrPathname)) {
174174
blobUrl = urlOrPathname;
175175
pathname = extractPathnameFromUrl(urlOrPathname);
176+
177+
try {
178+
const { hostname } = new URL(blobUrl);
179+
if (!hostname.endsWith('.blob.vercel-storage.com')) {
180+
throw new BlobError(
181+
'Invalid URL: the URL does not point to a Vercel Blob store. Use a pathname instead, see https://vercel.com/docs/vercel-blob',
182+
);
183+
}
184+
} catch (error) {
185+
if (error instanceof BlobError) throw error;
186+
throw new BlobError('Invalid URL: unable to parse the provided URL');
187+
}
176188
} else {
177189
// Construct the URL from the token's storeId and the pathname
178190
const storeId = getStoreIdFromToken(token);

packages/blob/src/index.node.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,6 +1334,67 @@ describe('blob client', () => {
13341334
);
13351335
});
13361336

1337+
it('should throw when URL points to an external domain', async () => {
1338+
await expect(
1339+
get('https://evil.com/xss.html', { access: 'private' }),
1340+
).rejects.toThrow(
1341+
new Error(
1342+
'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',
1343+
),
1344+
);
1345+
});
1346+
1347+
it('should throw when URL points to an external domain with public access', async () => {
1348+
await expect(
1349+
get('https://evil.com/xss.html', { access: 'public' }),
1350+
).rejects.toThrow(
1351+
new Error(
1352+
'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',
1353+
),
1354+
);
1355+
});
1356+
1357+
it('should throw when URL uses subdomain spoofing', async () => {
1358+
await expect(
1359+
get('https://blob.vercel-storage.com.evil.com/f.txt', {
1360+
access: 'private',
1361+
}),
1362+
).rejects.toThrow(
1363+
new Error(
1364+
'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',
1365+
),
1366+
);
1367+
});
1368+
1369+
it('should allow valid blob store URL', async () => {
1370+
const mockAgent = new MockAgent();
1371+
mockAgent.disableNetConnect();
1372+
setGlobalDispatcher(mockAgent);
1373+
const blobStoreMock = mockAgent.get(
1374+
'https://storeid.public.blob.vercel-storage.com',
1375+
);
1376+
1377+
blobStoreMock
1378+
.intercept({
1379+
path: '/file.txt',
1380+
method: 'GET',
1381+
})
1382+
.reply(200, 'blob content', {
1383+
headers: {
1384+
'content-type': 'text/plain',
1385+
'content-length': '12',
1386+
},
1387+
});
1388+
1389+
const result = await get(
1390+
'https://storeId.public.blob.vercel-storage.com/file.txt',
1391+
{ access: 'public' },
1392+
);
1393+
1394+
expect(result).not.toBeNull();
1395+
expect(result?.blob.pathname).toEqual('file.txt');
1396+
});
1397+
13371398
describe('useCache option', () => {
13381399
// undici normalizes hostnames to lowercase
13391400
const BLOB_STORE_BASE_URL_LOWERCASE =

0 commit comments

Comments
 (0)