Skip to content

Commit

Permalink
fix(blob): enforce content-type on fetch requests (#491)
Browse files Browse the repository at this point in the history
* fix(blob): enforce content-type on fetch requests

Before this commit, we would not specify the type of body we were sending to the
customer routes handling token generation. On the app router example this is not
an issue because you have to express await req.json(). But on the pages router
situations, this is an issue because req.body is automatically filled/parsed
using the content-type.

We're fixing another place where we have this issue in our internal API
triggering upload completed.

* Create tender-planes-fail.md

* chore(blob): bump internal API version to 5 to enforce JSON content type
callbacks
  • Loading branch information
vvo committed Nov 14, 2023
1 parent 3e3e0d4 commit f9c4061
Show file tree
Hide file tree
Showing 8 changed files with 102 additions and 4 deletions.
9 changes: 9 additions & 0 deletions .changeset/tender-planes-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@vercel/blob": patch
---

fix(blob): Enforce content-type on fetch requests during token generation

Before this change, we would not send the content-type header on fetch requests sent to your server during client uploads. We consider this a bugfix as it should have been sent before.

⚠️ If you upgrade to this version, and you're using any smart request body parser (like Next.js Pages API routes) then: You need to remove any `JSON.parse(request.body)` at the `handleUpload` step, as the body will be JSON by default now. This is valid for the `onBeforeGenerateToken` and `onUploadCompleted` steps.
3 changes: 3 additions & 0 deletions packages/blob/jest/setup.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// TextEncoder and TextDecoder are not defined in Jest dom environment,
// but they are available everywhere else.
// See https://stackoverflow.com/questions/68468203/why-am-i-getting-textencoder-is-not-defined-in-jest
const { TextEncoder, TextDecoder } = require('node:util');

Object.assign(global, { TextDecoder, TextEncoder });
5 changes: 4 additions & 1 deletion packages/blob/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node"
"testEnvironment": "node",
"testEnvironmentOptions": {
"url": "http://localhost:3000"
}
},
"dependencies": {
"jest-environment-jsdom": "29.7.0",
Expand Down
77 changes: 77 additions & 0 deletions packages/blob/src/client.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import undici from 'undici';
import { upload } from './client';

// can't use undici mocking utilities because jsdom does not support performance.markResourceTiming
jest.mock('undici', () => ({
fetch: jest
.fn()
.mockResolvedValueOnce({
status: 200,
ok: true,
json: () =>
Promise.resolve({
type: 'blob.generate-client-token',
clientToken: 'fake-token-for-test',
}),
})
.mockResolvedValueOnce({
status: 200,
ok: true,
json: () =>
Promise.resolve({
url: `https://storeId.public.blob.vercel-storage.com/superfoo.txt`,
pathname: 'foo.txt',
contentType: 'text/plain',
contentDisposition: 'attachment; filename="foo.txt"',
}),
}),
}));

describe('upload()', () => {
beforeEach(() => {
process.env.BLOB_READ_WRITE_TOKEN =
'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678';
jest.clearAllMocks();
});

it('should upload a file from the client', async () => {
await expect(
upload('foo.txt', 'Test file data', {
access: 'public',
handleUploadUrl: '/api/upload',
}),
).resolves.toMatchInlineSnapshot(`
{
"contentDisposition": "attachment; filename="foo.txt"",
"contentType": "text/plain",
"pathname": "foo.txt",
"url": "https://storeId.public.blob.vercel-storage.com/superfoo.txt",
}
`);

const fetchMock = undici.fetch as jest.Mock;
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'http://localhost:3000/api/upload',
{
body: '{"type":"blob.generate-client-token","payload":{"pathname":"foo.txt","callbackUrl":"http://localhost:3000/api/upload"}}',
headers: { 'content-type': 'application/json' },
method: 'POST',
},
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'https://blob.vercel-storage.com/foo.txt',
{
body: 'Test file data',
duplex: 'half',
headers: {
authorization: 'Bearer fake-token-for-test',
'x-api-version': '5',
},
method: 'PUT',
},
);
});
});
5 changes: 5 additions & 0 deletions packages/blob/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,10 +328,15 @@ async function retrieveClientToken(options: {
const res = await fetch(url, {
method: 'POST',
body: JSON.stringify(event),
headers: {
'content-type': 'application/json',
},
});

if (!res.ok) {
throw new BlobError('Failed to retrieve the client token');
}

try {
const { clientToken } = (await res.json()) as { clientToken: string };
return clientToken;
Expand Down
2 changes: 1 addition & 1 deletion packages/blob/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export async function validateBlobApiResponse(
// This version is used to ensure that the client and server are compatible
// The server (Vercel Blob API) uses this information to change its behavior like the
// response format
const BLOB_API_VERSION = 4;
const BLOB_API_VERSION = 5;

export function getApiVersionHeader(): { 'x-api-version'?: string } {
let versionOverride = null;
Expand Down
1 change: 1 addition & 0 deletions packages/blob/src/index.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { put } from './index';

const BLOB_STORE_BASE_URL = 'https://storeId.public.blob.vercel-storage.com';

// Can't use the usual undici mocking utilities because they don't work with jsdom environment
jest.mock('undici', () => ({
fetch: (): unknown =>
Promise.resolve({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ export default async function handleBody(
return;
}

const body = request.body as string;
const body = request.body as HandleUploadBody;
try {
const jsonResponse = await handleUpload({
body: JSON.parse(body) as HandleUploadBody,
body,
request,
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/require-await -- [@vercel/style-guide@5 migration]
onBeforeGenerateToken: async (pathname) => {
Expand Down

1 comment on commit f9c4061

@vercel
Copy link

@vercel vercel bot commented on f9c4061 Nov 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.