Repository navigation
Releases: nick-vogel/payload-storage-cloudinary
Release list
v1.3.0
Summary
1.3.0 fixes GHSA-gm3h-g6v2-2cpf, "Private files exposed through signed-URL endpoints, stored URLs and public transformations". In every release from 1.0.0 to 1.2.1, a private file could reach readers your access rules refuse. The signed URL endpoints and server helpers skipped your collection's read access, every read returned a permanent link to the original, and publicTransformation collections uploaded private originals as public Cloudinary assets. 1.3.0 makes one access rule decide who gets the original, the preview or nothing, and applies it everywhere a file URL leaves the plugin.
You are affected if any Cloudinary collection uses privateFiles or publicTransformation. The upgrade has breaking changes and needs a migration step: on Postgres or SQLite, a Payload migration for the new cloudinaryType column, and on every database, a run of migratePrivateAssets to move private files uploaded before 1.3.0. See Upgrade from 1.2.x. If you can't upgrade today, start with the steps below.
Before you upgrade: reduce exposure on 1.2.x
1.3.0 is the fix. If you can't upgrade today, these steps reduce what a 1.2.x site exposes. Each one closes part of the problem and costs you something. None of them replaces the upgrade and the migration.
-
Turn off the signed URL endpoints. On 1.2.x,
GET /api/{collection}/signed-url/:idandPOST /api/{collection}/signed-urlsdon't apply your collection'sreadaccess. Remove both with a small plugin listed aftercloudinaryStorage, or refuse both routes at your proxy.import type { Plugin } from 'payload' const removeSignedURLEndpoints: Plugin = (config) => ({ ...config, collections: config.collections?.map((collection) => ({ ...collection, endpoints: Array.isArray(collection.endpoints) ? collection.endpoints.filter( (endpoint) => endpoint.path !== '/signed-url/:id' && endpoint.path !== '/signed-urls', ) : collection.endpoints, })), }) // plugins: [cloudinaryStorage({ ... }), removeSignedURLEndpoints]
This closes signed URLs handed to callers your access rules would refuse, and URLs signed with transformations the caller chose. The cost:
fetchSignedURL,fetchSignedURLsanduseSignedURLstop working, so a page that loads private files through them shows nothing until you upgrade. -
Make
readaccess the gate. On 1.2.x, every reader of a private document gets a link to the original that doesn't expire. Make eachprivateFilescollection'sreadaccess refuse every reader who must not get the files. Don't rely on anafterReadhook that hides private files, or oncustomAuthCheckalone: write the same rule intoread. In your own server code, read these collections withreqandoverrideAccess: false. This closes the stored links in REST and GraphQL reads, populated relationships and your Local API reads. The cost: a readerreadrefuses can't read the document at all, so they get no preview, and a relationship to it isn't populated for them. -
Check access before you call a server helper. On 1.2.x,
getSignedURL,getSignedURLs,getServerImageURL,getPrivateImageURLandgetPremiumImageURLdon't apply your collection'sreadaccess orcustomAuthCheck. Before you call one for a visitor, read the document yourself withpayload.findByID({ collection, id, req, overrideAccess: false }), run yourcustomAuthCheckrule on it, and call the helper only when both pass. Never pass transformations taken from a request to a helper. This closes server-rendered pages that hand a visitor a URL of a file they may not have. The cost: one more query per call, and a second copy of your rule to keep in step. -
Keep files that must stay private off
publicTransformationcollections. On 1.2.x, a collection withpublicTransformationturned on uploads private originals as public Cloudinary assets, anduseFilenamenames them after the uploaded file. Upload files that must stay private to aprivateFilescollection withoutpublicTransformation, where 1.2.x uploads them asauthenticated. Turn offuseFilenameon any collection withpublicTransformation. This closes new private originals at a public URL, and new public IDs that follow file names. The cost: those files get no watermarked or blurred preview, and public IDs no longer match file names. Files already uploaded stay public until you upgrade and runmigratePrivateAssets. -
Don't make a saved file private with the checkbox. On 1.2.x, checking "Private File" on a saved document leaves its Cloudinary asset public. To make a public file private, upload it again to a new document with "Private File" checked, point your content at the new document, and delete the old one. This closes files marked private that still sit at a public URL. The cost: a new document ID, and the edits to move your content to it. Files already made private this way stay public until you re-upload them, or until you upgrade and run
migratePrivateAssets. -
Delete a private file's asset by hand. On 1.2.x, deleting a private document leaves its
authenticatedasset on Cloudinary, and its signed URLs keep working. After you delete a private document, delete its asset in your Cloudinary media library. Assets left by earlier deletes stay after the upgrade too, so remove those as well. This closes deleted private files that stay reachable. The cost: a manual step for each delete. -
Limit who can create and update. On 1.2.x, anyone who can create or update a document in a Cloudinary collection can also write the fields the plugin manages, such as the stored public ID. Limit
createandupdateon these collections to editors you trust with every file in them. This closes an editor pointing a document at another document's file, and deleting that file through it. The cost: fewer people can upload and edit.
None of these steps takes back a URL already handed out. A signed URL of an authenticated asset keeps working after the upgrade as well, until you delete the asset.
Breaking changes
Each of these can break a 1.2.x site. The detail is in the sections that follow.
Access
readaccess is the gate. The endpoints, every read and the server helpers trust your collection'sreadaccess. The docs used to recommendread: () => truewith anafterReadhook that hid private files. Makereadrefuse the callers who must not get the files.- Server helpers enforce access by default.
getSignedURL,getSignedURLs,getServerImageURL,getPrivateImageURLandgetPremiumImageURLapplyreadaccess and the access rule, and a call with noreqand nouseris anonymous, so it returnsnullon a restricted document. Passreq. For a trusted job with no reader (a sitemap, an email render), passoverrideAccess: true. getSignedURLreturnsnullto a preview-only reader. Render thepreviewUrlfrom that reader's own read, or usegetPremiumImageURL.- No original on a
publicTransformationcollection withoutcustomAuthCheck. Nobody gets the original through the plugin there. Add acustomAuthCheckthat lets the right readers through. - Your
transformationsconfig now takes effect. An object with anyTransformationConfigkey but nodefaultkey used to be read as legacy transformations, so its presets, preview andpublicTransformationsettings were ignored. Check that they now do what you expect. customAuthCheckreceives the stored document. It gets every field whatever the read selected or hid, relationship fields as IDs, and localized fields in one locale, and on a localized site it runs once per configured locale. Compare relationship IDs, not populated documents, and keep your fieldafterReadhooks free of side effects, since they run again during the decision.isAccessAllowedis deprecated and fails closed. It returnstrueonly for a reader who may get the original, and with a bareSignedURLConfigor no config it returns yourcustomAuthCheckanswer, andfalsewhen there is none (1.2.x returnedtrue). UseisAccessAllowedByID({ req, collection, id }).
Transformations
- Transformation allowlist on the endpoints. The signed URL endpoints answer 400 for any key outside
width,height,crop,gravity,quality,fetch_format,dprandaspect_ratio, or a value with/,,,:,$or a space (quality: "auto:good"andaspect_ratio: "16:9"included). Set those astransformations.defaultor a preset, and write a ratio as a decimal. - Transformation allowlist on the server helpers.
getSignedURL,getSignedURLsandgetServerImageURLthrow a 400APIErrorfor the same transformations,getSignedURLsfor the whole batch andgetServerImageURLfor a public file too. Move them into your collection config, or passoverrideTransformationAllowlist: truefor transformations built in trusted server code.
Endpoints and client helpers
- Batch cap.
POST /api/{collection}/signed-urlstakes at most 100 IDs, and a longer list is a 400. Split a larger gallery into several requests. - Batch results. The batch endpoint returns one result per requested ID, in order, and a refused ID is
{ id, status, error }with no URL. Skip results that carry anerror(fetchSignedURLsalready does). - No expiry in the endpoint responses. The signed URL endpoints no longer return
expiresInorexpiresAt. Stop reading them. - Client helpers throw the endpoint's error text.
fetchSignedURLandfetchSignedURLsnow throw anErrorwhose message is the endpoint'serrortext, such as "Access denied", notHTTP 400: Failed to fetch signed URL. Update code that matches on the message.
Stored data and URLs
- Delivery URLs are built at read time and no longer stored. API responses never return
cloudinaryUrlor the stored `origina...
v1.2.0
What's New in v1.2.0
Bug Fixes
- Fix fields not populating after upload with
plugin-cloud-storage3.70.0+ —handleUploadnow returns data correctly (#3) - Fix SVG uploads — SVG files are now uploaded as
resource_type: 'raw'instead of being misidentified (#2) - Fix cloud name resolution in client-side transformations
- Add
clientUploadContextsupport for newer versions ofplugin-cloud-storage
Security
- Add authentication checks to upload status endpoints
- Add
sanitizeFolderPath()to protect against path traversal attacks - Simplify
handleDeleteby removing fragile stack trace parsing
Reliability
- Add try-catch to
generateURLwith filename fallback - Add error logging to
staticHandler - Add
.catch()safety toserverHelpersbatch operations
Type Safety
- Add
CloudinaryUploadResultandCloudinaryDocumentDatatyped interfaces - Apply typed interfaces across all handlers
- Add runtime body validation to batch signed URL endpoint
Cleanup
- Remove leftover
console.log/console.warnfrom handlers - Deduplicate
commonPresets— single source of truth inclientTransformations - Enable
noUnusedLocalsin tsconfig for stricter compilation - Update CI to pnpm 10 and Node 20.x/22.x
Testing
- 43 unit tests covering uploads, deletions, URL generation, signed URLs, and upload status
Full Changelog: v1.1.3...v1.2.0
v1.0.1 - Security and Large File Fixes
What's Changed
🐛 Bug Fixes
- Fix large file uploads (>100MB) using Cloudinary's upload_large API
- Fix 413 "Payload Too Large" errors for video uploads
🔒 Security
- Improve access control implementation to properly integrate with Payload CMS
- Remove flawed owner-based access check
- Ensure plugin respects collection-level access control
📚 Documentation
- Add file size limit information based on Cloudinary plans
- Clarify security model and customAuthCheck behavior
Full Changelog: v1.0.0...v1.0.1