VYPR
Critical severity9.4NVD Advisory· Published Apr 7, 2026· Updated Apr 15, 2026

CVE-2026-39397

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.

PackageAffected versionsPatched versions
@delmaredigital/payload-pucknpm
< 0.6.230.6.23

Affected products

1

Patches

1
9148201c6bbf

fix: enforce collection access control on /api/puck endpoints

https://github.com/delmaredigital/payload-puckallandelmareApr 6, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.