Skip to content

Releases: nick-vogel/payload-storage-cloudinary

v1.3.0

Choose a tag to compare

@nick-vogel nick-vogel released this 09 Oct 15:11
10faf6b

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/:id and POST /api/{collection}/signed-urls don't apply your collection's read access. Remove both with a small plugin listed after cloudinaryStorage, 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, fetchSignedURLs and useSignedURL stop working, so a page that loads private files through them shows nothing until you upgrade.

  • Make read access the gate. On 1.2.x, every reader of a private document gets a link to the original that doesn't expire. Make each privateFiles collection's read access refuse every reader who must not get the files. Don't rely on an afterRead hook that hides private files, or on customAuthCheck alone: write the same rule into read. In your own server code, read these collections with req and overrideAccess: false. This closes the stored links in REST and GraphQL reads, populated relationships and your Local API reads. The cost: a reader read refuses 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, getPrivateImageURL and getPremiumImageURL don't apply your collection's read access or customAuthCheck. Before you call one for a visitor, read the document yourself with payload.findByID({ collection, id, req, overrideAccess: false }), run your customAuthCheck rule 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 publicTransformation collections. On 1.2.x, a collection with publicTransformation turned on uploads private originals as public Cloudinary assets, and useFilename names them after the uploaded file. Upload files that must stay private to a privateFiles collection without publicTransformation, where 1.2.x uploads them as authenticated. Turn off useFilename on any collection with publicTransformation. 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 run migratePrivateAssets.

  • 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 authenticated asset 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 create and update on 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

  • read access is the gate. The endpoints, every read and the server helpers trust your collection's read access. The docs used to recommend read: () => true with an afterRead hook that hid private files. Make read refuse the callers who must not get the files.
  • Server helpers enforce access by default. getSignedURL, getSignedURLs, getServerImageURL, getPrivateImageURL and getPremiumImageURL apply read access and the access rule, and a call with no req and no user is anonymous, so it returns null on a restricted document. Pass req. For a trusted job with no reader (a sitemap, an email render), pass overrideAccess: true.
  • getSignedURL returns null to a preview-only reader. Render the previewUrl from that reader's own read, or use getPremiumImageURL.
  • No original on a publicTransformation collection without customAuthCheck. Nobody gets the original through the plugin there. Add a customAuthCheck that lets the right readers through.
  • Your transformations config now takes effect. An object with any TransformationConfig key but no default key used to be read as legacy transformations, so its presets, preview and publicTransformation settings were ignored. Check that they now do what you expect.
  • customAuthCheck receives 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 field afterRead hooks free of side effects, since they run again during the decision.
  • isAccessAllowed is deprecated and fails closed. It returns true only for a reader who may get the original, and with a bare SignedURLConfig or no config it returns your customAuthCheck answer, and false when there is none (1.2.x returned true). Use isAccessAllowedByID({ 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, dpr and aspect_ratio, or a value with /, ,, :, $ or a space (quality: "auto:good" and aspect_ratio: "16:9" included). Set those as transformations.default or a preset, and write a ratio as a decimal.
  • Transformation allowlist on the server helpers. getSignedURL, getSignedURLs and getServerImageURL throw a 400 APIError for the same transformations, getSignedURLs for the whole batch and getServerImageURL for a public file too. Move them into your collection config, or pass overrideTransformationAllowlist: true for transformations built in trusted server code.

Endpoints and client helpers

  • Batch cap. POST /api/{collection}/signed-urls takes 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 an error (fetchSignedURLs already does).
  • No expiry in the endpoint responses. The signed URL endpoints no longer return expiresIn or expiresAt. Stop reading them.
  • Client helpers throw the endpoint's error text. fetchSignedURL and fetchSignedURLs now throw an Error whose message is the endpoint's error text, such as "Access denied", not HTTP 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 cloudinaryUrl or the stored `origina...
Read more

v1.2.0

Choose a tag to compare

@nick-vogel nick-vogel released this 22 Feb 22:46

What's New in v1.2.0

Bug Fixes

  • Fix fields not populating after upload with plugin-cloud-storage 3.70.0+ — handleUpload now 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 clientUploadContext support for newer versions of plugin-cloud-storage

Security

  • Add authentication checks to upload status endpoints
  • Add sanitizeFolderPath() to protect against path traversal attacks
  • Simplify handleDelete by removing fragile stack trace parsing

Reliability

  • Add try-catch to generateURL with filename fallback
  • Add error logging to staticHandler
  • Add .catch() safety to serverHelpers batch operations

Type Safety

  • Add CloudinaryUploadResult and CloudinaryDocumentData typed interfaces
  • Apply typed interfaces across all handlers
  • Add runtime body validation to batch signed URL endpoint

Cleanup

  • Remove leftover console.log/console.warn from handlers
  • Deduplicate commonPresets — single source of truth in clientTransformations
  • Enable noUnusedLocals in 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

Choose a tag to compare

@nick-vogel nick-vogel released this 03 Jul 20:49

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