CVE-2026-39397
Description
@delmaredigital/payload-puck is a PayloadCMS plugin for integrating Puck visual page builder. Prior to 0.6.23, all /api/puck/* CRUD endpoint handlers registered by createPuckPlugin() called Payload's local API with the default overrideAccess: true, bypassing all collection-level access control. The access option passed to createPuckPlugin() and any access rules defined on Puck-registered collections were silently ignored on these endpoints. This vulnerability is fixed in 0.6.23.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@delmaredigital/payload-pucknpm | < 0.6.23 | 0.6.23 |
Affected products
1- cpe:2.3:a:delmaredigital:payload-puck:*:*:*:*:*:node.js:*:*Range: <0.6.23
Patches
19148201c6bbffix: enforce collection access control on /api/puck endpoints
6 files changed · +73 −54
CHANGELOG.md+11 −0 modified@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.23] - 2026-04-06 + +### Fixed + +- **Security: API endpoints now enforce collection access control.** Previously, all Puck CRUD endpoints (`/api/puck/:collection/*`) bypassed collection-level access rules because they used Payload's local API with the default `overrideAccess: true`. The `access` config passed to `createPuckPlugin()` had no effect on these endpoints — unauthenticated users could read, create, update, and delete documents. All endpoint handlers now pass `overrideAccess: false` and forward `req` so Payload evaluates access rules against the current user. ([#7](https://github.com/delmaredigital/payload-puck/issues/7)) + +### Added + +- **Locale-aware editing.** The Puck editor now propagates the active locale through save, publish, version history, and restore operations, enabling multi-language content management. Locale is resolved from the request body, query params, or Payload middleware. ([#6](https://github.com/delmaredigital/payload-puck/pull/6) — thanks [@georgisoft2020](https://github.com/georgisoft2020)) +- **Version restore endpoint fix.** The version history UI now correctly calls `/restore` instead of `/versions` for restore operations. ([#6](https://github.com/delmaredigital/payload-puck/pull/6)) + ## [0.6.22] - 2026-03-31 ### Fixed
src/api/createPuckApiRoutesVersions.ts+2 −4 modified@@ -5,7 +5,7 @@ import type { PuckApiVersionsRouteHandlers, RouteHandlerWithIdContext, } from './types.js' -import {resolveLocaleFromNextRequest} from "../utils/locale"; +import { resolveLocaleFromNextRequest } from '../utils/locale.js' /** * Create API route handlers for /api/puck/pages/[id]/versions @@ -90,9 +90,7 @@ export function createPuckApiRoutesVersions( const url = new URL(request.url) const limit = parseInt(url.searchParams.get('limit') || '20', 10) const page = parseInt(url.searchParams.get('page') || '1', 10) - const body = await request.json?.() - const { _locale } = body || {} - const locale = resolveLocaleFromNextRequest(request, _locale) + const locale = resolveLocaleFromNextRequest(request) // Fetch versions for this page const versions = await payload.findVersions({
src/editor/plugins/VersionHistoryPanel.tsx+1 −1 modified@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, memo, type CSSProperties } from 'react' import { createUsePuck, type Data } from '@puckeditor/core' -import { Loader2, Check, RotateCcw, AlertCircle, CaseUpper } from 'lucide-react' +import { Loader2, Check, RotateCcw, AlertCircle } from 'lucide-react' import { useLocale } from '@payloadcms/ui'; // Create usePuck hook for accessing editor state and dispatch
src/editor/PuckEditorImpl.client.tsx+2 −2 modified@@ -1,9 +1,9 @@ 'use client' -import {useState, useCallback, useMemo, useRef, type ReactNode, createElement, useEffect} from 'react' +import { useState, useCallback, useMemo, useRef, type ReactNode, createElement, useEffect } from 'react' import { useRouter } from 'next/navigation' import { Puck, type Config as PuckConfig, type Data, type Plugin as PuckPlugin, type Overrides as PuckOverrides } from '@puckeditor/core' -import { useLocale } from "@payloadcms/ui"; +import { useLocale } from '@payloadcms/ui' import '@puckeditor/core/puck.css' import headingAnalyzer from '@puckeditor/plugin-heading-analyzer' import '@puckeditor/plugin-heading-analyzer/dist/index.css'
src/endpoints/index.ts+34 −28 modified@@ -3,6 +3,9 @@ * * These handlers are registered via config.endpoints in the plugin. * They provide CRUD operations for Puck-enabled collections. + * + * Access control: All handlers pass `overrideAccess: false` and `req` to + * Payload's local API, so collection-level access rules are enforced. */ import type { PayloadHandler, CollectionSlug } from 'payload' @@ -32,16 +35,16 @@ export function createListHandler(options: PuckEndpointOptions): PayloadHandler ) } - const body = await req.json?.() - const { _locale } = body || {} - const locale = resolveLocale(req, _locale) + const locale = resolveLocale(req) const result = await req.payload.find({ collection: collection as CollectionSlug, + req, + overrideAccess: false, draft: true, depth: 0, limit: 100, - ...(locale ? { locale: locale.toString() } : {}), + ...(locale ? { locale } : {}), }) return Response.json(result) @@ -75,18 +78,15 @@ export function createCreateHandler(options: PuckEndpointOptions): PayloadHandle const body = await req.json?.() const { _locale, ...data } = body || {} - let locale = resolveLocale(req, _locale) - const referrer = req.headers.get('referer'); - if(referrer && !locale){ - const { searchParams } = new URL(referrer); - locale = searchParams.get('locale') || undefined; - } + const locale = resolveLocale(req, _locale) const doc = await req.payload.create({ collection: collection as CollectionSlug, + req, + overrideAccess: false, data, draft: true, - ...(locale ? { locale: locale.toString() } : {}), + ...(locale ? { locale } : {}), }) return Response.json({ doc }) @@ -118,16 +118,17 @@ export function createGetHandler(options: PuckEndpointOptions): PayloadHandler { { status: 400 } ) } - const body = await req.json?.() - const { _locale } = body || {} - const locale = resolveLocale(req, _locale) + + const locale = resolveLocale(req) const doc = await req.payload.findByID({ collection: collection as CollectionSlug, + req, + overrideAccess: false, id, draft: true, depth: 0, - ...(locale ? { locale: locale.toString() } : {}), + ...(locale ? { locale } : {}), }) return Response.json({ doc }) @@ -162,12 +163,7 @@ export function createUpdateHandler(options: PuckEndpointOptions): PayloadHandle const body = await req.json?.() const { _status, _locale, swapHomepage, ...data } = body || {} - let locale = resolveLocale(req, _locale); - const referrer = req.headers.get('referer'); - if(referrer && !locale){ - const { searchParams } = new URL(referrer); - locale = searchParams.get('locale') || undefined; - } + const locale = resolveLocale(req, _locale) // Determine if this is a publish or draft save const shouldPublish = _status === 'published' @@ -179,6 +175,8 @@ export function createUpdateHandler(options: PuckEndpointOptions): PayloadHandle // Find the current homepage const existingHomepage = await req.payload.find({ collection: collection as CollectionSlug, + req, + overrideAccess: false, where: { and: [ { isHomepage: { equals: true } }, @@ -187,7 +185,7 @@ export function createUpdateHandler(options: PuckEndpointOptions): PayloadHandle }, limit: 1, depth: 0, - ...(locale ? { locale: locale.toString() } : {}), + ...(locale ? { locale } : {}), }) // Unset the existing homepage if found @@ -199,6 +197,8 @@ export function createUpdateHandler(options: PuckEndpointOptions): PayloadHandle const doc = await req.payload.update({ collection: collection as CollectionSlug, + req, + overrideAccess: false, id, data: { ...data, @@ -207,11 +207,11 @@ export function createUpdateHandler(options: PuckEndpointOptions): PayloadHandle draft: !shouldPublish, context: { // Skip the isHomepage hook if we've already handled the swap - ...((swapHomepage || locale) && { skipIsHomepageHook: swapHomepage }), + ...(swapHomepage && { skipIsHomepageHook: true }), // Pass locale to context so hooks can access it without re-reading body ...(locale && { locale }), }, - ...(locale ? { locale: locale.toString() } : {}), + ...(locale ? { locale } : {}), }) return Response.json({ doc, published: shouldPublish }) @@ -232,8 +232,8 @@ export function createUpdateHandler(options: PuckEndpointOptions): PayloadHandle // Handle other APIErrors if (error instanceof APIError) { return Response.json( - { error: error.message, data: error.data }, - { status: error.status || 500 } + { error: error.message, data: error.data }, + { status: error.status || 500 } ) } @@ -266,6 +266,8 @@ export function createDeleteHandler(options: PuckEndpointOptions): PayloadHandle await req.payload.delete({ collection: collection as CollectionSlug, + req, + overrideAccess: false, id, }) @@ -288,10 +290,10 @@ export function createVersionsHandler(options: PuckEndpointOptions): PayloadHand const { collections } = options return async (req) => { - const locale = resolveLocale(req) try { const collection = req.routeParams?.collection as string const id = req.routeParams?.id as string + const locale = resolveLocale(req) if (!collections.includes(collection)) { return Response.json( @@ -302,12 +304,14 @@ export function createVersionsHandler(options: PuckEndpointOptions): PayloadHand const versions = await req.payload.findVersions({ collection: collection as CollectionSlug, + req, + overrideAccess: false, where: { parent: { equals: id }, }, sort: '-updatedAt', limit: 20, - ...(locale ? { locale: locale.toString(), fallbackLocale: false} : {}), + ...(locale ? { locale, fallbackLocale: false } : {}), }) return Response.json({ versions: versions.docs }) @@ -352,6 +356,8 @@ export function createRestoreHandler(options: PuckEndpointOptions): PayloadHandl const doc = await req.payload.restoreVersion({ collection: collection as CollectionSlug, + req, + overrideAccess: false, id: versionId, ...(locale ? { locale: locale.toString() } : {}), })
src/utils/locale.ts+23 −19 modified@@ -1,37 +1,41 @@ /** - * Locale Resolver - Determines the locale to use for a Payload operation based on multiple sources. -*/ -import { Locale, PayloadHandler } from "payload" -import {NextRequest} from "next/server"; + * Locale Resolver — Determines the locale for a Payload operation. + */ +import type { Locale, PayloadHandler } from 'payload' +import type { NextRequest } from 'next/server' /** * Resolves the locale to use for a Payload operation. * * Priority: - * 1. Explicit `_locale` field in the request body (sent by PuckEditor) - * 2. `?locale=` query param (standard REST convention, already parsed by Payload) + * 1. Explicit `bodyLocale` (from `_locale` in the request body, sent by PuckEditor) + * 2. `?locale=` query param (standard REST convention, already parsed by Payload) * 3. `req.locale` set by Payload's middleware (from admin UI context) */ export function resolveLocale( req: Parameters<PayloadHandler>[0], bodyLocale?: string, ): string | undefined { - if (bodyLocale) return bodyLocale - const queryLocale = req.query?.locale as string | undefined - if (queryLocale) return queryLocale - if (req.locale) return typeof req.locale === 'string' ? req.locale : (req.locale as Locale).code - return undefined + if (bodyLocale) return bodyLocale + const queryLocale = req.query?.locale as string | undefined + if (queryLocale) return queryLocale + if (req.locale) return typeof req.locale === 'string' ? req.locale : (req.locale as Locale).code + return undefined } /** - * Resolves the locale from NextRequest -*/ + * Resolves the locale from a Next.js API route request. + * + * Priority: + * 1. Explicit `bodyLocale` (from `_locale` in the request body) + * 2. `?locale=` query param + */ export function resolveLocaleFromNextRequest( - req: NextRequest, - bodyLocale?: string, + req: NextRequest, + bodyLocale?: string, ): string | undefined { - if (bodyLocale) return bodyLocale - const queryLocale = req.nextUrl.searchParams.get('locale') as string | undefined - if (queryLocale) return queryLocale - return undefined + if (bodyLocale) return bodyLocale + const queryLocale = req.nextUrl.searchParams.get('locale') ?? undefined + if (queryLocale) return queryLocale + return undefined }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/delmaredigital/payload-puck/commit/9148201c6bbfa140d44546438027a2f8a70f79a4nvdPatchWEB
- github.com/delmaredigital/payload-puck/security/advisories/GHSA-65w6-pf7x-5g85nvdPatchVendor AdvisoryWEB
- github.com/delmaredigital/payload-puck/issues/7nvdExploitIssue TrackingWEB
- github.com/advisories/GHSA-65w6-pf7x-5g85ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-39397ghsaADVISORY
News mentions
0No linked articles in our index yet.