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 1da6eab73e Improved input handling and access control around the 
invoice email-sending flow (#1704)
1da6eab73e is described below

commit 1da6eab73e6ca5d09596878b96d553d81dd07f1f
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Sun Aug 23 23:49:31 2026 +0530

    Improved input handling and access control around the invoice email-sending 
flow (#1704)
    
    Improved input handling and access control around the invoice
    email-sending flow, and adds an extra safeguard to script evaluation
    used elsewhere in the framework.
    
    No functional change is intended for normal use of this feature.
    
    
    Thank you Krishna Uprit for your help.
---
 .../accounting/servicedef/services_invoice.xml     |  1 +
 .../SendInvoicePerEmailPermissionTests.groovy      | 63 ++++++++++++++
 applications/accounting/testdef/invoicetests.xml   |  3 +
 .../org/apache/ofbiz/base/util/GroovyUtil.java     | 26 ++++--
 .../apache/ofbiz/base/util/GroovyUtilTests.java    | 61 ++++++++++++++
 framework/common/ofbiz-component.xml               |  1 +
 .../apache/ofbiz/common/email/EmailServices.java   | 12 +--
 .../common/email/EmailServicesInjectionTests.java  | 95 ++++++++++++++++++++++
 .../common/testdef/EmailServicesInjectionTests.xml | 14 ++--
 9 files changed, 256 insertions(+), 20 deletions(-)

diff --git a/applications/accounting/servicedef/services_invoice.xml 
b/applications/accounting/servicedef/services_invoice.xml
index 9a4ab37ba6..43e1fc0309 100644
--- a/applications/accounting/servicedef/services_invoice.xml
+++ b/applications/accounting/servicedef/services_invoice.xml
@@ -285,6 +285,7 @@ under the License.
     <service name="sendInvoicePerEmail" engine="groovy"
         
location="component://accounting/src/main/groovy/org/apache/ofbiz/accounting/invoice/InvoiceServicesScript.groovy"
 invoke="sendInvoicePerEmail">
         <description>Send an invoice per email</description>
+        <permission-service service-name="acctgInvoicePermissionCheck" 
main-action="VIEW"/>
         <attribute name="invoiceId" type="String" mode="IN"/>
         <attribute name="sendFrom" type="String" mode="IN"/>
         <attribute name="sendTo" type="String" mode="IN"/>
diff --git 
a/applications/accounting/src/test/groovy/org/apache/ofbiz/accounting/invoice/SendInvoicePerEmailPermissionTests.groovy
 
b/applications/accounting/src/test/groovy/org/apache/ofbiz/accounting/invoice/SendInvoicePerEmailPermissionTests.groovy
new file mode 100644
index 0000000000..515d9a52e2
--- /dev/null
+++ 
b/applications/accounting/src/test/groovy/org/apache/ofbiz/accounting/invoice/SendInvoicePerEmailPermissionTests.groovy
@@ -0,0 +1,63 @@
+/*******************************************************************************
+ * 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.accounting.invoice
+
+import org.apache.ofbiz.service.ServiceAuthException
+import org.apache.ofbiz.service.ServiceUtil
+import org.apache.ofbiz.testtools.JunitJupiterTest
+import org.apache.ofbiz.testtools.JupiterTestHelper
+import org.junit.jupiter.api.Test
+
+/**
+ * sendInvoicePerEmail declared no permission-service, unlike its siblings in 
the same file
+ * (createInvoice, getInvoice, setInvoiceStatus), so any authenticated user 
could reach it regardless of
+ * accounting permissions. These tests guard the fix that gives it the same 
VIEW-level
+ * acctgInvoicePermissionCheck getInvoice already declares.
+ */
+@JunitJupiterTest
+class SendInvoicePerEmailPermissionTests implements JupiterTestHelper {
+
+    @Test
+    void sendInvoicePerEmailDeniesUserWithoutAccountingPermission() {
+        // DemoCustomer carries no UserLoginSecurityGroup membership anywhere 
in the demo data set.
+        Map params = [invoiceId: 'DEMO_PERM_TEST_NONEXISTENT',
+                       sendFrom: '[email protected]',
+                       sendTo: '[email protected]',
+                       userLogin: getUserLogin('DemoCustomer')]
+        try {
+            Map results = getDispatcher().runSync('sendInvoicePerEmail', 
params)
+            assert ServiceUtil.isError(results):
+                    'sendInvoicePerEmail must not succeed for a userLogin 
holding no accounting permission'
+        } catch (ServiceAuthException e) {
+            assert e != null
+        }
+    }
+
+    @Test
+    void sendInvoicePerEmailAllowsUserWithAccountingViewPermission() {
+        Map params = [invoiceId: 'DEMO_PERM_TEST_NONEXISTENT',
+                       sendFrom: '[email protected]',
+                       sendTo: '[email protected]',
+                       userLogin: getUserLogin()]
+        Map results = getDispatcher().runSync('sendInvoicePerEmail', params)
+        assert ServiceUtil.isSuccess(results):
+                'sendInvoicePerEmail must still succeed for a 
fully-permissioned userLogin'
+    }
+
+}
diff --git a/applications/accounting/testdef/invoicetests.xml 
b/applications/accounting/testdef/invoicetests.xml
index 61f6347d4b..505a2f9a3b 100644
--- a/applications/accounting/testdef/invoicetests.xml
+++ b/applications/accounting/testdef/invoicetests.xml
@@ -28,4 +28,7 @@
     <test-case case-name="invoice-per-shipment-tests">
         <jupiter-test-suite 
class-name="org.apache.ofbiz.accounting.accounting.InvoicePerShipmentTests"/>
     </test-case>
+    <test-case case-name="send-invoice-per-email-permission-tests">
+        <jupiter-test-suite 
class-name="org.apache.ofbiz.accounting.invoice.SendInvoicePerEmailPermissionTests"/>
+    </test-case>
 </test-suite>
diff --git 
a/framework/base/src/main/java/org/apache/ofbiz/base/util/GroovyUtil.java 
b/framework/base/src/main/java/org/apache/ofbiz/base/util/GroovyUtil.java
index 4db6df99e4..51aec9a1f2 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/util/GroovyUtil.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/util/GroovyUtil.java
@@ -59,16 +59,31 @@ public final class GroovyUtil {
         if (!scriptBaseClass.isEmpty()) {
             CompilerConfiguration conf = new CompilerConfiguration();
             conf.setScriptBaseClass(scriptBaseClass);
+            // Same compile-time AST restrictions as SANDBOXED_COMPILER_CONFIG 
below, so that parseClass(),
+            // not just eval(), refuses OS-execution APIs and dynamic 
class-loading.
+            conf.addCompilationCustomizers(buildSecureAstCustomizer());
             groovyClassLoader = new 
GroovyClassLoader(GroovyUtil.class.getClassLoader(), conf);
         }
         GROOVY_CLASS_LOADER = groovyClassLoader;
     }
 
     static {
-        // Compile-time AST restrictions applied to eval() expressions.
-        // Blocks OS-execution APIs and dynamic class-loading as a 
defence-in-depth measure.
-        // Note: SecureASTCustomizer operates at compile time and does not 
constitute a
-        // complete sandbox; eval() expressions should never originate from 
untrusted input.
+        SANDBOXED_COMPILER_CONFIG = new CompilerConfiguration();
+        
SANDBOXED_COMPILER_CONFIG.addCompilationCustomizers(buildSecureAstCustomizer());
+    }
+
+    /**
+     * Builds a fresh {@link SecureASTCustomizer} applying the compile-time 
AST restrictions used by both
+     * GROOVY_CLASS_LOADER and SANDBOXED_COMPILER_CONFIG. Blocks OS-execution 
APIs and dynamic class-loading
+     * as a defence-in-depth measure.
+     * <p>Returns a new instance on every call rather than a shared constant: 
{@code SecureASTCustomizer}
+     * visits the AST during compilation, so handing the same instance to two 
{@code CompilerConfiguration}s
+     * used concurrently would be unsafe.
+     * <p>Note: SecureASTCustomizer operates at compile time and does not 
constitute a complete sandbox;
+     * expressions compiled through either path should never originate from 
untrusted input.
+     * @return a new, independently-usable SecureASTCustomizer
+     */
+    private static SecureASTCustomizer buildSecureAstCustomizer() {
         SecureASTCustomizer secureAst = new SecureASTCustomizer();
         secureAst.setDisallowedImports(List.of(
                 "java.lang.Runtime",
@@ -88,8 +103,7 @@ public final class GroovyUtil {
                 Thread.class,
                 ClassLoader.class);
         secureAst.setDisallowedReceiversClasses(blockedReceivers);
-        SANDBOXED_COMPILER_CONFIG = new CompilerConfiguration();
-        SANDBOXED_COMPILER_CONFIG.addCompilationCustomizers(secureAst);
+        return secureAst;
     }
 
     /**
diff --git 
a/framework/base/src/test/java/org/apache/ofbiz/base/util/GroovyUtilTests.java 
b/framework/base/src/test/java/org/apache/ofbiz/base/util/GroovyUtilTests.java
new file mode 100644
index 0000000000..f306a3b0a4
--- /dev/null
+++ 
b/framework/base/src/test/java/org/apache/ofbiz/base/util/GroovyUtilTests.java
@@ -0,0 +1,61 @@
+/*
+ * 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.base.util;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.codehaus.groovy.control.CompilationFailedException;
+import org.junit.jupiter.api.Test;
+
+public class GroovyUtilTests {
+
+    /**
+     * GroovyUtil.parseClass() backs ScriptUtil.parseScript(), which is what 
FlexibleStringExpander uses to
+     * compile every {@code ${groovy:...}} substring it finds -- including 
ones that originate from a
+     * caller-supplied string reaching FlexibleStringExpander.expandString() 
before any application-level
+     * fix strips that call out. Before the fix, parseClass() used a 
GroovyClassLoader with no
+     * SecureASTCustomizer, so this construction compiled cleanly. 
ProcessBuilder is java.lang, so no
+     * import statement is needed to reach it -- an import denylist alone 
would not catch this.
+     */
+    @Test
+    public void parseClassRejectsProcessBuilderConstruction() {
+        String maliciousScript = "new ProcessBuilder(['id']).start()";
+        assertThrows(CompilationFailedException.class, () -> 
GroovyUtil.parseClass(maliciousScript),
+                "GroovyUtil.parseClass() must refuse to compile a script that 
constructs a ProcessBuilder");
+    }
+
+    /**
+     * Same restriction, reached via a method call on an existing receiver 
rather than a constructor call.
+     */
+    @Test
+    public void parseClassRejectsRuntimeExec() {
+        String maliciousScript = "Runtime.getRuntime().exec('id')";
+        assertThrows(CompilationFailedException.class, () -> 
GroovyUtil.parseClass(maliciousScript),
+                "GroovyUtil.parseClass() must refuse to compile a script that 
calls Runtime.exec()");
+    }
+
+    /**
+     * The restriction must not be so broad that it breaks compiling ordinary, 
non-malicious scripts --
+     * the shape every legitimate internal ${groovy:...} scriptlet and .groovy 
script location has.
+     */
+    @Test
+    public void parseClassStillAcceptsOrdinaryScripts() {
+        assertDoesNotThrow(() -> GroovyUtil.parseClass("1 + 1"),
+                "GroovyUtil.parseClass() must keep compiling ordinary, 
non-malicious scripts");
+    }
+}
diff --git a/framework/common/ofbiz-component.xml 
b/framework/common/ofbiz-component.xml
index 32bac50a1a..7d4b06331f 100644
--- a/framework/common/ofbiz-component.xml
+++ b/framework/common/ofbiz-component.xml
@@ -85,4 +85,5 @@ under the License.
 
     <test-suite loader="main" location="testdef/UserLoginTests.xml"/>
     <test-suite loader="main" location="testdef/PerformFindTests.xml"/>
+    <test-suite loader="main" 
location="testdef/EmailServicesInjectionTests.xml"/>
 </ofbiz-component>
diff --git 
a/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
 
b/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
index c2cacb918b..550a5c7313 100644
--- 
a/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
+++ 
b/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
@@ -72,7 +72,6 @@ import org.apache.ofbiz.base.util.UtilMisc;
 import org.apache.ofbiz.base.util.UtilProperties;
 import org.apache.ofbiz.base.util.UtilValidate;
 import org.apache.ofbiz.base.util.collections.MapStack;
-import org.apache.ofbiz.base.util.string.FlexibleStringExpander;
 import org.apache.ofbiz.entity.Delegator;
 import org.apache.ofbiz.entity.GenericEntityException;
 import org.apache.ofbiz.entity.GenericValue;
@@ -576,7 +575,8 @@ public class EmailServices {
         if (UtilValidate.isNotEmpty(xslfoAttachScreenLocationList)) {
             List<Map<String, ? extends Object>> bodyParts = new LinkedList<>();
             if (bodyText != null) {
-                bodyText = FlexibleStringExpander.expandString(bodyText, 
screenContext, locale);
+                // bodyText is caller-supplied (e.g. from a request 
parameter); it must not be run through
+                // FlexibleStringExpander, which would compile and execute any 
${groovy:...} substring it contains.
                 bodyParts.add(UtilMisc.<String, Object>toMap("content", 
bodyText, "type", UtilValidate.isNotEmpty(contentType) ? contentType
                         : "text/html"));
             } else {
@@ -635,7 +635,7 @@ public class EmailServices {
             isMultiPart = false;
             // store body and type for single part message in the context.
             if (bodyText != null) {
-                bodyText = FlexibleStringExpander.expandString(bodyText, 
screenContext, locale);
+                // bodyText is caller-supplied; see the comment on the 
identical guard above.
                 serviceContext.put("body", bodyText);
             } else {
                 serviceContext.put("body", bodyWriter.toString());
@@ -650,11 +650,11 @@ public class EmailServices {
             }
         }
 
-        // also expand the subject at this point, just in case it has the 
FlexibleStringExpander syntax in it...
+        // subject is caller-supplied; do not run it through 
FlexibleStringExpander, which would compile and
+        // execute any ${groovy:...} substring it contains -- same reasoning 
as the bodyText guards above.
         String subject = (String) serviceContext.remove("subject");
-        subject = FlexibleStringExpander.expandString(subject, screenContext, 
locale);
         if (Debug.infoOn()) {
-            Debug.logInfo("Expanded email subject to: " + subject, MODULE);
+            Debug.logInfo("Email subject: " + subject, MODULE);
         }
         serviceContext.put("subject", subject);
         serviceContext.put("partyId", partyId);
diff --git 
a/framework/common/src/test/java/org/apache/ofbiz/common/email/EmailServicesInjectionTests.java
 
b/framework/common/src/test/java/org/apache/ofbiz/common/email/EmailServicesInjectionTests.java
new file mode 100644
index 0000000000..deb5741774
--- /dev/null
+++ 
b/framework/common/src/test/java/org/apache/ofbiz/common/email/EmailServicesInjectionTests.java
@@ -0,0 +1,95 @@
+/*******************************************************************************
+ * 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.common.email;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.ofbiz.testtools.JunitJupiterTest;
+import org.apache.ofbiz.testtools.JupiterTestHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * sendMailFromScreen's bodyText and subject are ordinary caller-supplied 
strings -- for example, the
+ * "Send Per Email" invoice form (accounting's InvoiceForms.xml) posts 
bodyText straight from a request
+ * textarea. Before the fix, EmailServices ran both through 
FlexibleStringExpander.expandString(), which
+ * compiles and executes any {@code ${groovy:...}} substring it finds using a 
GroovyClassLoader carrying
+ * no SecureASTCustomizer -- arbitrary code execution reachable from a form 
field held by any
+ * authenticated user the webapp lets in. This guards the fix that stops 
expanding that text at all.
+ *
+ * <p>The marker below is set via a fully-qualified call to a static method on 
this class rather than
+ * {@code System.setProperty(...)} deliberately: security.properties' 
deniedScriptletsTokens denylist
+ * (a separate, pre-existing control -- see security.properties' "System\s*\." 
token) would otherwise
+ * block the marker script before it ever reached the code path this test 
exists to guard, making the
+ * test pass for the wrong reason regardless of the fix under test. Confirmed 
by running this test
+ * against the pre-fix code: with {@code System.setProperty(...)} it passed 
vacuously (blocked by the
+ * denylist); with this marker it fails as expected, proving it actually 
exercises the vulnerability.
+ */
+@JunitJupiterTest
+public final class EmailServicesInjectionTests implements JupiterTestHelper {
+
+    private static volatile boolean marker;
+
+    private static final String INJECTION =
+            "before-${groovy: 
org.apache.ofbiz.common.email.EmailServicesInjectionTests.setMarker() }-after";
+
+    /** Called from the injected scriptlet, if it is ever compiled and 
executed. */
+    public static void setMarker() {
+        marker = true;
+    }
+
+    @AfterEach
+    public void clearMarker() {
+        marker = false;
+    }
+
+    @Test
+    public void sendMailFromScreenDoesNotEvaluateGroovyInBodyText() throws 
Exception {
+        Map<String, Object> context = new HashMap<>();
+        context.put("userLogin", getUserLogin());
+        context.put("bodyText", INJECTION);
+        context.put("sendTo", "[email protected]");
+        context.put("sendFrom", "[email protected]");
+        context.put("contentType", "text/plain");
+
+        // sendMailFromScreen goes on to fail the actual send (no SMTP server 
in the test environment);
+        // that happens after the injection point and is not what this test is 
about, so any result --
+        // success or error -- is fine here. Only the marker matters.
+        getDispatcher().runSync("sendMailFromScreen", context);
+
+        assertFalse(marker, "bodyText must not be compiled and executed as 
Groovy by sendMailFromScreen");
+    }
+
+    @Test
+    public void sendMailFromScreenDoesNotEvaluateGroovyInSubject() throws 
Exception {
+        Map<String, Object> context = new HashMap<>();
+        context.put("userLogin", getUserLogin());
+        context.put("subject", INJECTION);
+        context.put("sendTo", "[email protected]");
+        context.put("sendFrom", "[email protected]");
+        context.put("contentType", "text/plain");
+
+        getDispatcher().runSync("sendMailFromScreen", context);
+
+        assertFalse(marker, "subject must not be compiled and executed as 
Groovy by sendMailFromScreen");
+    }
+}
diff --git a/applications/accounting/testdef/invoicetests.xml 
b/framework/common/testdef/EmailServicesInjectionTests.xml
similarity index 70%
copy from applications/accounting/testdef/invoicetests.xml
copy to framework/common/testdef/EmailServicesInjectionTests.xml
index 61f6347d4b..f276074033 100644
--- a/applications/accounting/testdef/invoicetests.xml
+++ b/framework/common/testdef/EmailServicesInjectionTests.xml
@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="UTF-8" ?>
+<?xml version="1.0" encoding="UTF-8"?>
 <!--
   Licensed to the Apache Software Foundation (ASF) under one
   or more contributor license agreements.  See the NOTICE file
@@ -16,16 +16,14 @@
   KIND, either express or implied.  See the License for the
   specific language governing permissions and limitations
   under the License.
-  -->
+-->
 
-<test-suite suite-name="invoicetests"
+<test-suite suite-name="emailservicesinjectiontests"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
         
xsi:noNamespaceSchemaLocation="https://ofbiz.apache.org/dtds/test-suite.xsd";>
 
-    <test-group case-name="auto-invoice-tests">
-        <jupiter-test-suite 
class-name="org.apache.ofbiz.accounting.accounting.AutoInvoiceTests"/>
-    </test-group>
-    <test-case case-name="invoice-per-shipment-tests">
-        <jupiter-test-suite 
class-name="org.apache.ofbiz.accounting.accounting.InvoicePerShipmentTests"/>
+    <test-case case-name="email-services-injection-tests">
+        <jupiter-test-suite 
class-name="org.apache.ofbiz.common.email.EmailServicesInjectionTests"/>
     </test-case>
+
 </test-suite>

Reply via email to