Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/fix-blob-handle-upload-callback-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@vercel/blob": patch
---

fix(blob): infer handleUpload callback urls from node request headers

When `handleUpload()` is used with `onUploadCompleted()` and no explicit
`callbackUrl` is returned from `onBeforeGenerateToken()`, the SDK now falls back
to Node request headers such as `x-forwarded-host`, `x-forwarded-proto`, and
`host` to infer the callback URL.
98 changes: 98 additions & 0 deletions packages/blob/src/client.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,104 @@ describe('client uploads', () => {
process.env = originalEnv;
});

it('should infer callbackUrl from forwarded host headers for node requests', async () => {
const originalEnv = process.env;
process.env = {
NODE_ENV: 'test',
};

const token =
'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678';

const jsonResponse = await handleUpload({
token,
request: {
url: '/api/upload?via=node',
headers: {
host: 'internal.local:3000',
'x-forwarded-host': 'uploads.example.com',
'x-forwarded-proto': 'https',
},
} as unknown as IncomingMessage,
body: {
type: 'blob.generate-client-token',
payload: {
pathname: 'newfile.txt',
multipart: false,
clientPayload: null,
},
},
onBeforeGenerateToken: async () => {
return Promise.resolve({
addRandomSuffix: false,
});
},
onUploadCompleted: async () => {
await Promise.resolve();
},
});

const decodedPayload = getPayloadFromClientToken(
(jsonResponse.type === 'blob.generate-client-token' &&
jsonResponse.clientToken) ||
'',
);

expect(decodedPayload.onUploadCompleted?.callbackUrl).toBe(
'https://uploads.example.com/api/upload?via=node',
);

process.env = originalEnv;
});

it('should infer http callbackUrl for localhost node requests', async () => {
const originalEnv = process.env;
process.env = {
NODE_ENV: 'test',
};

const token =
'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678';

const jsonResponse = await handleUpload({
token,
request: {
url: '/api/upload',
headers: {
host: 'localhost:3000',
},
} as unknown as IncomingMessage,
body: {
type: 'blob.generate-client-token',
payload: {
pathname: 'newfile.txt',
multipart: false,
clientPayload: null,
},
},
onBeforeGenerateToken: async () => {
return Promise.resolve({
addRandomSuffix: false,
});
},
onUploadCompleted: async () => {
await Promise.resolve();
},
});

const decodedPayload = getPayloadFromClientToken(
(jsonResponse.type === 'blob.generate-client-token' &&
jsonResponse.clientToken) ||
'',
);

expect(decodedPayload.onUploadCompleted?.callbackUrl).toBe(
'http://localhost:3000/api/upload',
);

process.env = originalEnv;
});

it('should warn when callbackUrl provided but onUploadCompleted not defined', async () => {
const originalConsoleWarn = console.warn;
const mockConsoleWarn = jest.fn();
Expand Down
57 changes: 57 additions & 0 deletions packages/blob/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,11 @@ function getCallbackUrl(request: RequestType): string | undefined {
return `${process.env.VERCEL_BLOB_CALLBACK_URL}${reqPath}`;
}

const callbackUrlFromHeaders = getCallbackUrlFromHeaders(request, reqPath);
if (callbackUrlFromHeaders) {
return callbackUrlFromHeaders;
}

// Not hosted on Vercel and no VERCEL_BLOB_CALLBACK_URL
if (process.env.VERCEL !== '1') {
console.warn(
Expand Down Expand Up @@ -910,6 +915,58 @@ function getCallbackUrl(request: RequestType): string | undefined {
return undefined;
}

function getCallbackUrlFromHeaders(
request: RequestType,
reqPath: string,
): string | undefined {
if (!request.headers) {
return undefined;
}

const rawHost = hasHeadersGet(request.headers)
? request.headers.get('x-forwarded-host') ??
request.headers.get('host') ??
undefined
: getFirstHeaderValue(
request.headers['x-forwarded-host'] ?? request.headers.host,
);

if (!rawHost) {
return undefined;
}

const host = rawHost.split(',')[0]?.trim();
if (!host) {
return undefined;
}

const rawProtocol = hasHeadersGet(request.headers)
? request.headers.get('x-forwarded-proto') ?? undefined
: getFirstHeaderValue(request.headers['x-forwarded-proto']);

const protocol = rawProtocol?.split(',')[0]?.trim() || inferProtocol(host);

return `${protocol}://${host}${reqPath}`;
}

function getFirstHeaderValue(
headerValue: string | string[] | undefined,
): string | undefined {
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
}

function hasHeadersGet(
headers: IncomingMessage['headers'] | Headers | undefined,
): headers is Headers {
return typeof headers === 'object' && headers !== null && 'get' in headers;
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

hasHeadersGet() can return a false positive when the Node IncomingMessage.headers object contains a header named get (header names are lowercased), because 'get' in headers will then be true but headers.get will be a string/string[]. This would cause a runtime TypeError when calling request.headers.get(...). Consider tightening the guard to check that get is actually a function (e.g., typeof (headers as any).get === 'function') before treating it as a Headers instance.

Suggested change
return typeof headers === 'object' && headers !== null && 'get' in headers;
return (
typeof headers === 'object' &&
headers !== null &&
typeof (headers as { get?: unknown }).get === 'function'
);

Copilot uses AI. Check for mistakes.
}

function inferProtocol(host: string): 'http' | 'https' {
return host.startsWith('localhost') || host.startsWith('127.0.0.1')
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

inferProtocol() uses startsWith('localhost') / startsWith('127.0.0.1'), which will misclassify non-local hosts like localhost.example.com as http, and it doesn’t cover common local hosts like [::1] (IPv6 loopback). Consider parsing out the hostname (strip port / brackets) and doing exact matches against known loopback names/addresses (e.g., localhost, 127.0.0.1, ::1, maybe 0.0.0.0) before defaulting to https.

Suggested change
return host.startsWith('localhost') || host.startsWith('127.0.0.1')
const normalizedHost = host.trim().toLowerCase();
let hostname: string;
if (normalizedHost.startsWith('[')) {
const closingBracketIndex = normalizedHost.indexOf(']');
hostname =
closingBracketIndex === -1
? normalizedHost.slice(1)
: normalizedHost.slice(1, closingBracketIndex);
} else {
hostname = normalizedHost.split(':', 1)[0] ?? normalizedHost;
}
return hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0'

Copilot uses AI. Check for mistakes.
? 'http'
: 'https';
}

/**
* @internal Helper function to safely extract pathname and query string from request URL
* Handles both full URLs (http://localhost:3000/api/upload?test=1) and relative paths (/api/upload?test=1)
Expand Down
Loading