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
33 changes: 27 additions & 6 deletions packages/blob/src/presign-query-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ function validatePresignUrlOnUploadCompletedWire(
if (opt.callbackUrl.length > MAX_PRESIGN_CALLBACK_URL_CHARS) {
throw new Error(`${label}: onUploadCompleted.callbackUrl is too long.`);
}
assertNoControlChars(opt.callbackUrl, 'onUploadCompleted.callbackUrl', label);
if (!isPlausibleAbsoluteUrl(opt.callbackUrl)) {
throw new Error(
`${label}: onUploadCompleted.callbackUrl must be a valid URL.`,
Expand All @@ -163,6 +164,11 @@ function validatePresignUrlOnUploadCompletedWire(
`${label}: onUploadCompleted.tokenPayload must be a string.`,
);
}
assertNoControlChars(
opt.tokenPayload,
'onUploadCompleted.tokenPayload',
label,
);
if (opt.tokenPayload.length > MAX_PRESIGN_CALLBACK_TOKEN_PAYLOAD_CHARS) {
throw new Error(`${label}: onUploadCompleted.tokenPayload is too long.`);
}
Expand All @@ -173,7 +179,23 @@ export const MAX_PRESIGN_CACHE_CONTROL_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
const MAX_PRESIGN_IF_MATCH_LENGTH = 256;

// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally blocking them
const IF_MATCH_CONTROL_CHARS_RE = /[\x00-\x1f\x7f]/;
const CONTROL_CHARS_RE = /[\x00-\x1f\x7f]/;

export function hasDisallowedControlChars(value: string): boolean {
return CONTROL_CHARS_RE.test(value);
}

function assertNoControlChars(
value: string,
fieldName: string,
label: string,
): void {
if (hasDisallowedControlChars(value)) {
throw new Error(
`${label}: ${fieldName} contains disallowed control characters.`,
);
}
}

type PresignUrlConstraintOptions = {
validUntil?: number;
Expand Down Expand Up @@ -211,11 +233,7 @@ function validateUrlOnlyPresignUploadOptions(
if (im.length > MAX_PRESIGN_IF_MATCH_LENGTH) {
throw new Error(`${label}: ifMatch is too long.`);
}
if (IF_MATCH_CONTROL_CHARS_RE.test(im)) {
throw new Error(
`${label}: ifMatch contains disallowed control characters.`,
);
}
assertNoControlChars(im, 'ifMatch', label);
}
}

Expand Down Expand Up @@ -326,6 +344,9 @@ export function buildPresignCanonicalQueryEntries(args: {
validatePresignUrlOnUploadCompletedWire(urlOptions.onUploadCompleted, label);

if (urlOptions.allowedContentTypes !== undefined) {
for (const ct of urlOptions.allowedContentTypes) {
assertNoControlChars(ct, 'allowedContentTypes', label);
}
const csv = sortedContentTypesCsv(urlOptions.allowedContentTypes);
if (csv.length > 16_384) {
throw new Error(`${label}: allowedContentTypes query value is too long.`);
Expand Down
54 changes: 54 additions & 0 deletions packages/blob/src/signed-token.presignurl.shared-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,60 @@ export function registerPresignUrlTests(suiteName = 'presignUrl'): void {
).rejects.toThrow(BlobError);
});

it('rejects pathnames with control characters, even for wildcard delegation', async () => {
const delegation = createDelegationToken(
{
storeId: `store_${storeId}`,
ownerId: 'o',
pathname: '*',
operations: ['get'],
validUntil: now + 3600_000,
iat: now,
},
blobSigningSecret,
);
const client = deriveClientSigningToken(blobSigningSecret, delegation);
await expect(
presign(
{
delegationToken: delegation,
clientSigningToken: client,
},
{ operation: 'get', pathname: 'a\nb.png' },
),
).rejects.toThrow(
'presignUrl: pathname contains disallowed control characters.',
);
});

it('accepts wildcard delegation pathnames when no control characters exist', async () => {
const delegation = createDelegationToken(
{
storeId: `store_${storeId}`,
ownerId: 'o',
pathname: '*',
operations: ['get'],
validUntil: now + 3600_000,
iat: now,
},
blobSigningSecret,
);
const client = deriveClientSigningToken(blobSigningSecret, delegation);
await expect(
presign(
{
delegationToken: delegation,
clientSigningToken: client,
},
{ operation: 'get', pathname: 'a/b.png' },
),
).resolves.toEqual(
expect.objectContaining({
delegationToken: delegation,
}),
);
});

it('adds signed `vercel-blob-valid-until` when `validUntil` is before delegation ceiling', async () => {
const fixedNow = 1_700_000_000_000;
const pathnameTtl = 'images/a.png';
Expand Down
6 changes: 6 additions & 0 deletions packages/blob/src/signed-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from './helpers';
import {
buildPresignCanonicalQueryEntries,
hasDisallowedControlChars,
PRESIGN_CANONICAL_QUERY_KEYS,
} from './presign-query-params';

Expand Down Expand Up @@ -353,6 +354,11 @@ export async function presign(
if (!scope) {
throw new BlobError('Invalid or unreadable `delegationToken` payload.');
}
if (hasDisallowedControlChars(options.pathname)) {
throw new BlobError(
'presignUrl: pathname contains disallowed control characters.',
);
}

const p = scope.pathname;
if (p && p !== '*') {
Expand Down
Loading