CVE case study
CVE-2026-62670: Missing Flex Permissions Failed Open
The Grav Flex Objects API treated a missing directory permissions map as permission to proceed. An account with only api.access could operate on a directory it was not authorized to list.
- Severity
- Medium (6.3)
- Scoring
- CVSS 3.1
- Weakness
- CWE-862
- Affected
- grav-plugin-flex-objects 1.4.2 and earlier
- Remediation state
- Upgrade to grav-plugin-flex-objects 1.4.3
- Advisory published
- 29 Jun 2026
Official vectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L
Why it matters
The Admin Next Flex Objects API checked an explicit directory permissions map when one existed. When a custom directory omitted that map, the authorization helper returned normally instead of denying access or consulting Grav's core fallback.
The same low-privilege API user could not see the directory in the listing, yet could address it directly and create, read, update, delete, or export its objects. That mismatch showed that the direct object path had failed open.
GitHub's structured advisory metadata and the published CVSS vector produce a score of 6.3. One sentence in the advisory prose says 5.4; this page follows the structured score and vector.
How I found it
I compared requireFlexPermission() with Grav core's Flex authorization fallback and with the plugin's own directory-listing gate.
Core maps a directory without an explicit permissions block to admin.flex-object.<action>. The listing path honored that policy and hid the directory from a baseline API user, while the direct CRUD helper returned normally when the permissions map was empty.
A disposable custom directory shaped like the public plugin example confirmed the mismatch: the same user could not list it but could address it directly. I then verified that the patch delegates the missing-map branch back to core authorization.
Root cause
requireFlexPermission() first checked the baseline api.access grant, then entered its action-specific permission loop only when admin.permissions was present and non-empty.
There was no deny branch for a directory without that map. Grav core already defined the fallback admin.flex-object.<action>, and the plugin's directory listing used core authorization, but the CRUD helper skipped it.
The Flex API routes had no separate route-level permission rule, so this missing decision affected every handler that relied on the shared helper.
Source-to-sink trace
- 01Authenticated source
Flex API directory and action parametersAn API user selects a directory and operation under the Admin Next Flex Objects routes.
- 02Baseline gate
FlexApiController::requireFlexPermission()The helper verifies
api.accessbefore reading the directory's action-permission map. - 03Fail-open branch
getConfig('admin.permissions')In 1.4.2, an empty map skips the permission loop and reaches the end of the void method without a denial.
- 04Shared object sinks
Flex index, show, create, update, delete, export, and media handlersThe handlers rely on the same helper and the routes add no separate action permission.
Safe proof of concept
Prerequisites
- Git and Python 3.
- Network access only to clone the public grav-plugin-flex-objects repository; every check after cloning is local.
Step-by-step reproduction
- Clone the public repository into a unique temporary directory and export
FlexApiController.phpfrom 1.4.2 and the fixed commit. - Run the focused diff and confirm that the affected helper has no decision when
admin.permissionsis empty. - Run the synthetic policy model. It isolates the exact branch by holding baseline API access constant and setting the core fallback decision to deny.
- Confirm that the affected model allows the permission-less directory while the fixed model returns the core denial.
- Inspect the fixed source and verify that
isAuthorized($action, 'admin', $user)is called when no explicit permissions map exists. - If performing the optional owned-lab retest, use only a synthetic marker record. Verify listing-hidden plus direct CRUD on 1.4.2, then require consistent denial on 1.4.3. Do not claim runtime media coverage unless it is separately exercised.
Export and compare the authorization helper
LAB_DIR=$(mktemp -d "\${TMPDIR:-/tmp}/grav-flex-cve.XXXXXX")
git clone https://github.com/trilbymedia/grav-plugin-flex-objects.git "$LAB_DIR/flex"
git -C "$LAB_DIR/flex" show 1.4.2:classes/Api/FlexApiController.php \
> "$LAB_DIR/affected.php"
git -C "$LAB_DIR/flex" show 198d1a0eb7b94777a026ed0001d9a369d94c3002:classes/Api/FlexApiController.php \
> "$LAB_DIR/fixed.php"
git -C "$LAB_DIR/flex" diff 1.4.2 198d1a0eb7b94777a026ed0001d9a369d94c3002 \
-- classes/Api/FlexApiController.php
rg -n -C 12 'requireFlexPermission|admin.permissions|isAuthorized' \
"$LAB_DIR/affected.php" "$LAB_DIR/fixed.php"Synthetic fail-open decision model
def affected(explicit_permissions, explicit_match, core_authorized):
if explicit_permissions:
return explicit_match
return True # missing branch in 1.4.2
def fixed(explicit_permissions, explicit_match, core_authorized):
if not explicit_permissions:
return core_authorized
return explicit_match
case = dict(
explicit_permissions=False,
explicit_match=False,
core_authorized=False,
)
print("affected:", "ALLOW" if affected(**case) else "DENY")
print("fixed: ", "ALLOW" if fixed(**case) else "DENY")Expected model output
affected: ALLOW
fixed: DENYExpected evidence
- The source diff adds an explicit empty-permissions branch and delegates the decision to Grav core authorization.
- The synthetic model prints
affected: ALLOWandfixed: DENYfor the same permission-less directory and unauthorized user. - No request reaches a live Grav site and no real Flex object is read, modified, or deleted.
Set core_authorized=True to model an authorized user or explicit_permissions=True with a matching action grant. Both affected and fixed policies should allow that legitimate case.
On 1.4.3, a baseline API user must receive the same denial when listing and directly addressing a permission-less directory. Authorized users and super-admins must retain the intended access.
Impact
An authenticated account with only api.access could operate on a Flex directory whose blueprint omitted an explicit permissions map. The affected actions included object CRUD and export; the fixed helper also covered the media handlers.
The practical impact depended on what that directory stored. The official vector uses low confidentiality, integrity, and availability impact at the directory level. This finding does not imply access to pages, users, or configuration that required separate permissions.
The precondition was realistic because the plugin's custom-directory example omitted the permissions block, but exposure still required the API and Flex Objects plugins plus a permission-less directory.
Fix and retest
Upgrade to grav-plugin-flex-objects 1.4.3. The patch sends the empty-permissions case through $directory->isAuthorized($action, 'admin', $user) and denies access when the core fallback does not authorize the caller.
Retest listing, index, show, create, update, delete, export, and media routes with a baseline API user, an explicitly authorized user, and a super-admin. The first must be denied consistently in both discovery and direct-object paths.
Engineering lesson
Missing security configuration is not authorization. Default to deny, reuse the platform's canonical policy resolver, and verify that discovery and direct-object routes make the same decision for the same identity.