This is an automated email from the ASF dual-hosted git repository.

ashishvijaywargiya pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 4cc9848625 Fixed: updateLayout authorizing under CONTENT_CREATE then 
updating unrelated records unchecked (#1745)
4cc9848625 is described below

commit 4cc9848625cfe4d22765167b6476573752f74c82
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Wed Aug 26 22:56:43 2026 +0530

    Fixed: updateLayout authorizing under CONTENT_CREATE then updating 
unrelated records unchecked (#1745)
    
    The updateLayout event built a Content value from caller-supplied
    parameters and checked permission for it under the CONTENT_CREATE
    operation, then separately loaded the real Content identified by
    parameters.contentId and the real DataResource identified by
    parameters.dataResourceId and stored both directly, without checking
    UPDATE permission on either record and without any check that the
    DataResource actually belonged to the Content.
    
    A user holding only view-level Content Manager permissions could use an
    existing RESPONSE-purpose Content to pass the CONTENT_CREATE check, then
    relink that Content to an arbitrary DataResource and convert it into a
    template-bearing (FTL) OFBIZ_FILE resource - a path the normal
    updateDataResource service already blocks for the same user, since it
    requires CONTENTMGR_SUPER for template-bearing DataResources.
    
    Route both updates through the existing updateContent/updateDataResource
    services instead, so permission is checked (via
    genericContentPermission/ genericDataResourcePermission, main-action
    UPDATE) against the real target records, the same way every other update
    path in this component already works.
    
    Added a regression test that creates a low-privilege user with only
    OFBTOOLS_VIEW/CONTENTMGR_VIEW and verifies it is denied both when
    updating an existing Content and when converting an existing
    DataResource into a template-bearing OFBIZ_FILE resource.
    
    Thank you Krishna Uprit for your help in reviewing the changes.
---
 .../content/minilang/layout/LayoutEvents.xml       |  43 ++++----
 .../permission/UpdateLayoutPermissionTests.groovy  | 114 +++++++++++++++++++++
 applications/content/testdef/ContentTests.xml      |   4 +
 3 files changed, 138 insertions(+), 23 deletions(-)

diff --git a/applications/content/minilang/layout/LayoutEvents.xml 
b/applications/content/minilang/layout/LayoutEvents.xml
index 078218c7a7..692b7279e2 100644
--- a/applications/content/minilang/layout/LayoutEvents.xml
+++ b/applications/content/minilang/layout/LayoutEvents.xml
@@ -52,37 +52,34 @@ under the License.
     <simple-method login-required="true" method-name="updateLayout" 
short-description="Update Layout">
         <log level="info" message="in updateLayout."/>
 
-        <make-value entity-name="Content" value-field="currentContent"/>
-        <set-pk-fields map="parameters" value-field="currentContent"/>
-        <set-nonpk-fields map="parameters" value-field="currentContent"/>
-        <set field="context.currentContent" from-field="currentContent"/>
+        <!-- Route the actual persistence through 
updateContent/updateDataResource so permission is
+             checked (via 
genericContentPermission/genericDataResourcePermission, main-action UPDATE)
+             against the real target Content and DataResource records 
identified by parameters.contentId
+             and parameters.dataResourceId, instead of against an 
attacker-constructed value under the
+             unrelated CONTENT_CREATE operation. This also ensures converting 
the target DataResource
+             into a template-bearing (e.g. FTL) resource is gated behind 
CONTENTMGR_SUPER, same as the
+             normal updateDataResource path. -->
+        <make-value entity-name="Content" value-field="content"/>
+        <set-pk-fields map="parameters" value-field="content"/>
+        <set-nonpk-fields map="parameters" value-field="content"/>
+        <log level="info" message="content: ${content}"/>
+        <map-to-map map="content" to-map="context"/>
         <set field="context.contentPurposeTypeId" 
from-field="parameters.contentPurposeTypeId"/>
-        <string-to-list list="targetOperationList" string="CONTENT_CREATE"/>
-        <set field="context.targetOperationList" 
from-field="targetOperationList"/>
         <session-to-field field="context.userLogin" session-name="userLogin"/>
-        <call-service in-map-name="context" 
service-name="checkContentPermission">
-            <result-to-field result-name="permissionStatus" 
field="permissionStatus"/>
-        </call-service>
-        <if-compare field="permissionStatus" operator="not-equals" 
value="granted" type="String">
-            <add-error>
-                <fail-property resource="ContentUiLabels" 
property="ContentPermissionNotGranted"/>
-            </add-error>
-        </if-compare>
+        <call-service in-map-name="context" service-name="updateContent"/>
         <check-errors/>
-        <set field="content.contentId" from-field="parameters.contentId"/>
-        <find-by-primary-key entity-name="Content" value-field="content" 
map="content"/>
-        <set-nonpk-fields map="parameters" value-field="content"/>
-        <log level="info" message="content: ${content}"/>
-        <store-value value-field="content"/>
         <field-to-request field="content.contentId" request-name="contentId"/>
-        <set field="dataResource.dataResourceId" 
from-field="parameters.dataResourceId"/>
-        <find-by-primary-key entity-name="DataResource" 
value-field="dataResource" map="dataResource"/>
+
+        <make-value entity-name="DataResource" value-field="dataResource"/>
+        <set-pk-fields map="parameters" value-field="dataResource"/>
         <set-nonpk-fields map="parameters" value-field="dataResource"/>
         <set field="dataResource.objectInfo" 
from-field="parameters.drObjectInfo"/>
         <log level="info" message="dataResource: ${dataResource}"/>
-        <store-value value-field="dataResource"/>
-        <field-to-request field="dataResource.dataResourceId" 
request-name="drDataResourceId"/>
+        <map-to-map map="dataResource" to-map="context2"/>
+        <session-to-field field="context2.userLogin" session-name="userLogin"/>
+        <call-service in-map-name="context2" 
service-name="updateDataResource"/>
         <check-errors/>
+        <field-to-request field="dataResource.dataResourceId" 
request-name="drDataResourceId"/>
     </simple-method>
 
     <simple-method login-required="true" method-name="createLayoutText" 
short-description="Create Layout Text">
diff --git 
a/applications/content/src/test/groovy/org/apache/ofbiz/content/permission/UpdateLayoutPermissionTests.groovy
 
b/applications/content/src/test/groovy/org/apache/ofbiz/content/permission/UpdateLayoutPermissionTests.groovy
new file mode 100644
index 0000000000..2f3aa882f5
--- /dev/null
+++ 
b/applications/content/src/test/groovy/org/apache/ofbiz/content/permission/UpdateLayoutPermissionTests.groovy
@@ -0,0 +1,114 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.content.permission
+
+import static org.junit.jupiter.api.Assertions.assertThrows
+
+import java.sql.Timestamp
+
+import org.apache.ofbiz.entity.GenericValue
+import org.apache.ofbiz.service.ServiceAuthException
+import org.apache.ofbiz.testtools.JunitJupiterTest
+import org.apache.ofbiz.testtools.JupiterTestHelper
+import org.junit.jupiter.api.Test
+
+/**
+ * Regression coverage for the updateLayout permission bypass: the event used 
to authorize its
+ * attacker-supplied target Content under the unrelated CONTENT_CREATE 
operation and then update
+ * the real Content and DataResource records directly, without checking UPDATE 
permission on
+ * either one. It now delegates both updates to the 
updateContent/updateDataResource services,
+ * which check permission (via 
genericContentPermission/genericDataResourcePermission, main-action
+ * UPDATE) against the real target records. These tests exercise exactly that 
permission boundary
+ * for a low-privilege user - one whose only grants are OFBTOOLS_VIEW and 
CONTENTMGR_VIEW, with no
+ * CONTENTMGR admin permission and no role or ownership standing on the target 
records - the same
+ * profile the reported exploit used.
+ */
+@JunitJupiterTest
+class UpdateLayoutPermissionTests implements JupiterTestHelper {
+
+    // Content/DataResource/SecurityGroup ids are "id" fields (VARCHAR(20)); 
keep these <= 20 chars
+    private static final String GROUP_ID = 'TEST_LAYOUT_VIEW'
+    private static final String USER_LOGIN_ID = 'testLayoutViewOnlyUser'
+    private static final String CONTENT_ID = 'TEST_LAYOUT_RESP_CT'
+    private static final String DATA_RESOURCE_ID = 'TEST_LAYOUT_TGT_DR'
+    private static final Timestamp GRANT_FROM_DATE = 
Timestamp.valueOf('2020-01-01 00:00:00.0')
+
+    @Test
+    void testViewOnlyUserCannotUpdateContentOrConvertDataResourceToTemplate() {
+        GenericValue lowPrivUserLogin = createViewOnlyUserLogin()
+        GenericValue content = createTestContent()
+        GenericValue dataResource = createTestDataResource()
+
+        // relinking/renaming the real Content record must be denied without 
UPDATE standing on it;
+        // a failed permission-service check surfaces as ServiceAuthException 
out of runSync, not as
+        // a returned error-result map
+        assertThrows(ServiceAuthException) {
+            dispatcher.runSync('updateContent', [contentId: content.contentId,
+                    contentName: 'Hijacked by view-only user', userLogin: 
lowPrivUserLogin])
+        }
+
+        // converting the real DataResource into a template-bearing OFBIZ_FILE 
resource must be
+        // denied without CONTENTMGR_SUPER, regardless of its current 
(non-template) type
+        assertThrows(ServiceAuthException) {
+            dispatcher.runSync('updateDataResource', [dataResourceId: 
dataResource.dataResourceId,
+                    dataResourceTypeId: 'OFBIZ_FILE', dataTemplateTypeId: 
'FTL',
+                    objectInfo: 'runtime/logs/access_log.txt', userLogin: 
lowPrivUserLogin])
+        }
+    }
+
+    private GenericValue createViewOnlyUserLogin() {
+        GenericValue existing = from('UserLogin').where(userLoginId: 
USER_LOGIN_ID).queryOne()
+        if (existing) {
+            return existing
+        }
+
+        if (!from('SecurityGroup').where(groupId: GROUP_ID).queryOne()) {
+            delegator.create('SecurityGroup', [groupId: GROUP_ID,
+                    description: 'View-only group for the updateLayout 
permission regression test'])
+            delegator.create('SecurityGroupPermission', [groupId: GROUP_ID, 
permissionId: 'OFBTOOLS_VIEW',
+                    fromDate: GRANT_FROM_DATE])
+            delegator.create('SecurityGroupPermission', [groupId: GROUP_ID, 
permissionId: 'CONTENTMGR_VIEW',
+                    fromDate: GRANT_FROM_DATE])
+        }
+
+        GenericValue userLogin = delegator.create('UserLogin', [userLoginId: 
USER_LOGIN_ID, enabled: 'Y'])
+        delegator.create('UserLoginSecurityGroup', [userLoginId: 
USER_LOGIN_ID, groupId: GROUP_ID,
+                fromDate: GRANT_FROM_DATE])
+        return userLogin
+    }
+
+    private GenericValue createTestContent() {
+        GenericValue existing = from('Content').where(contentId: 
CONTENT_ID).queryOne()
+        if (existing) {
+            return existing
+        }
+        return delegator.create('Content', [contentId: CONTENT_ID, 
contentTypeId: 'DOCUMENT',
+                contentName: 'Test Layout Response Content'])
+    }
+
+    private GenericValue createTestDataResource() {
+        GenericValue existing = from('DataResource').where(dataResourceId: 
DATA_RESOURCE_ID).queryOne()
+        if (existing) {
+            return existing
+        }
+        return delegator.create('DataResource', [dataResourceId: 
DATA_RESOURCE_ID,
+                dataResourceTypeId: 'ELECTRONIC_TEXT', mimeTypeId: 
'text/plain'])
+    }
+
+}
diff --git a/applications/content/testdef/ContentTests.xml 
b/applications/content/testdef/ContentTests.xml
index 1b02492356..3a457a0b44 100644
--- a/applications/content/testdef/ContentTests.xml
+++ b/applications/content/testdef/ContentTests.xml
@@ -28,5 +28,9 @@
     <test-case case-name="content-tests">
         <jupiter-test-suite 
class-name="org.apache.ofbiz.content.content.ContentTests"/>
     </test-case>
+
+    <test-case case-name="update-layout-permission-tests">
+        <jupiter-test-suite 
class-name="org.apache.ofbiz.content.permission.UpdateLayoutPermissionTests"/>
+    </test-case>
 </test-suite>
 

Reply via email to