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

jacopoc 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 da0febe182 Improved: Enhance file upload validation with allowlist and 
path checks
da0febe182 is described below

commit da0febe18237aeb4ab0333773c4fe18bb3037699
Author: Jacopo Cappellato <[email protected]>
AuthorDate: Wed Mar 11 08:50:13 2026 +0100

    Improved: Enhance file upload validation with allowlist and path checks
---
 .../org/apache/ofbiz/content/data/DataEvents.java  |  10 +-
 .../product/catalog/category/EditCategory.groovy   |   6 +
 .../config/EditProductConfigItemContent.groovy     |   6 +
 .../catalog/imagemanagement/ImageUpload.groovy     |   6 +
 .../catalog/imagemanagement/SetDefaultImage.groovy |  12 +
 .../catalog/product/EditProductContent.groovy      |   6 +
 .../ofbiz/product/product/ProductServices.java     |  74 +++-
 .../ofbiz/base/util/HttpRequestFileUpload.java     |  19 +-
 framework/security/config/security.properties      |  15 +-
 .../org/apache/ofbiz/security/SecuredUpload.java   | 400 ++++++++++++++++++++-
 .../ofbiz/service/engine/EntityAutoEngine.java     |   7 +
 11 files changed, 519 insertions(+), 42 deletions(-)

diff --git 
a/applications/content/src/main/java/org/apache/ofbiz/content/data/DataEvents.java
 
b/applications/content/src/main/java/org/apache/ofbiz/content/data/DataEvents.java
index 3a87a1c363..8661d0bbb7 100644
--- 
a/applications/content/src/main/java/org/apache/ofbiz/content/data/DataEvents.java
+++ 
b/applications/content/src/main/java/org/apache/ofbiz/content/data/DataEvents.java
@@ -85,9 +85,13 @@ public class DataEvents {
         String permissionService = 
EntityUtilProperties.getPropertyValue("content", "stream.permission.service",
                 "genericContentPermission", delegator);
 
-        // For OFBIZ-11840, checks if a webshell is not uploaded
-        // It's counterintuitive to return success but it makes sense if you 
thing about it.
-        // It simply returns a blank screen.
+        // For OFBIZ-11840, validate contentId using a strict allow-list 
instead of a
+        // deny-list: entity keys must match [a-zA-Z0-9_:\-]{1,255}.  An 
allow-list
+        // cannot be bypassed by encoding tricks or token splitting.
+        if (!SecuredUpload.isValidEntityKey(contentId)) {
+            Debug.logError("contentId parameter has invalid format, rejected 
for security reason", MODULE);
+            return "success";
+        }
         try {
             if (!SecuredUpload.isValidText(contentId, 
Collections.emptyList())) {
                 Debug.logError("================== Not saved for security 
reason ==================", MODULE);
diff --git 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/category/EditCategory.groovy
 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/category/EditCategory.groovy
index d204f9f491..221fe3c919 100644
--- 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/category/EditCategory.groovy
+++ 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/category/EditCategory.groovy
@@ -74,6 +74,12 @@ if (fileType) {
         contentType = '--' + contentType
     }
 
+    // Guard against path traversal: the resolved save directory must remain 
inside imageServerPath
+    if (!java.nio.file.Paths.get(imageServerPath + '/' + 
filePathPrefix).normalize()
+            .startsWith(java.nio.file.Paths.get(imageServerPath).normalize())) 
{
+        logError('Path traversal attempt detected in category image upload')
+        return error(UtilProperties.getMessage('SecurityUiLabels', 
'SupportedImageFormats', locale))
+    }
     defaultFileName = filenameToUse + '_temp'
     uploadObject = new HttpRequestFileUpload()
     uploadObject.setOverrideFilename(defaultFileName)
diff --git 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/config/EditProductConfigItemContent.groovy
 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/config/EditProductConfigItemContent.groovy
index bf19db8cfd..2ad99f7108 100644
--- 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/config/EditProductConfigItemContent.groovy
+++ 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/config/EditProductConfigItemContent.groovy
@@ -88,6 +88,12 @@ if (fileType) {
         contentType = '--' + contentType
     }
 
+    // Guard against path traversal: the resolved save directory must remain 
inside imageServerPath
+    if (!java.nio.file.Paths.get(imageServerPath + '/' + 
filePathPrefix).normalize()
+            .startsWith(java.nio.file.Paths.get(imageServerPath).normalize())) 
{
+        logError('Path traversal attempt detected in config item image upload')
+        return error(UtilProperties.getMessage('SecurityUiLabels', 
'SupportedImageFormats', locale))
+    }
     defaultFileName = filenameToUse + '_temp'
     uploadObject = new HttpRequestFileUpload()
     uploadObject.setOverrideFilename(defaultFileName)
diff --git 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/imagemanagement/ImageUpload.groovy
 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/imagemanagement/ImageUpload.groovy
index dcb2b14257..d15816597d 100644
--- 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/imagemanagement/ImageUpload.groovy
+++ 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/imagemanagement/ImageUpload.groovy
@@ -87,6 +87,12 @@ if (fileType) {
         contentType = '--' + contentType
     }
 
+    // Guard against path traversal: the resolved save directory must remain 
inside imageServerPath
+    if (!java.nio.file.Paths.get(imageServerPath + '/' + 
filePathPrefix).normalize()
+            .startsWith(java.nio.file.Paths.get(imageServerPath).normalize())) 
{
+        logError('Path traversal attempt detected in product image upload')
+        return error(UtilProperties.getMessage('SecurityUiLabels', 
'SupportedImageFormats', locale))
+    }
     defaultFileName = filenameToUse + '_temp'
     uploadObject = new HttpRequestFileUpload()
     uploadObject.setOverrideFilename(defaultFileName)
diff --git 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/imagemanagement/SetDefaultImage.groovy
 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/imagemanagement/SetDefaultImage.groovy
index a67abc2e30..5cacb00cdc 100644
--- 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/imagemanagement/SetDefaultImage.groovy
+++ 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/imagemanagement/SetDefaultImage.groovy
@@ -106,6 +106,18 @@ if (fileType) {
         contentType = '--' + contentType
     }
 
+    // Guard against path traversal: the resolved save directory must remain 
inside imageServerPath
+    if (!java.nio.file.Paths.get(imageServerPath + '/' + 
filePathPrefix).normalize()
+            .startsWith(java.nio.file.Paths.get(imageServerPath).normalize())) 
{
+        logError("Path traversal attempt detected in default image upload, 
productId: ${productId}")
+        return error('Invalid image path')
+    }
+    // Guard against path traversal: the management path must remain inside 
imageManagementPath
+    if (!java.nio.file.Paths.get(imageManagementPath + '/' + 
productId).normalize()
+            
.startsWith(java.nio.file.Paths.get(imageManagementPath).normalize())) {
+        logError("Path traversal attempt detected in image management path, 
productId: ${productId}")
+        return error('Invalid image path')
+    }
     defaultFileName = 'temp_' + dataResourceName
     checkPathFile = imageManagementPath + '/' + productId + '/' + 
dataResourceName
     BufferedImage bufImg
diff --git 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/product/EditProductContent.groovy
 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/product/EditProductContent.groovy
index 7832b55a2f..007b7c159b 100644
--- 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/product/EditProductContent.groovy
+++ 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/catalog/product/EditProductContent.groovy
@@ -88,6 +88,12 @@ if (fileType) {
         contentType = '--' + contentType
     }
 
+    // Guard against path traversal: the resolved save directory must remain 
inside imageServerPath
+    if (!java.nio.file.Paths.get(imageServerPath + '/' + 
filePathPrefix).normalize()
+            .startsWith(java.nio.file.Paths.get(imageServerPath).normalize())) 
{
+        logError('Path traversal attempt detected in product image upload')
+        return error(UtilProperties.getMessage('SecurityUiLabels', 
'SupportedImageFormats', locale))
+    }
     defaultFileName = filenameToUse + '_temp'
     uploadObject = new HttpRequestFileUpload()
     uploadObject.setOverrideFilename(defaultFileName)
diff --git 
a/applications/product/src/main/java/org/apache/ofbiz/product/product/ProductServices.java
 
b/applications/product/src/main/java/org/apache/ofbiz/product/product/ProductServices.java
index e5b546f071..73f3e04b6e 100644
--- 
a/applications/product/src/main/java/org/apache/ofbiz/product/product/ProductServices.java
+++ 
b/applications/product/src/main/java/org/apache/ofbiz/product/product/ProductServices.java
@@ -21,11 +21,12 @@ package org.apache.ofbiz.product.product;
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
-import java.io.RandomAccessFile;
 import java.math.BigDecimal;
 import java.nio.ByteBuffer;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
 import java.nio.file.StandardOpenOption;
 import java.sql.Timestamp;
 import java.util.Arrays;
@@ -991,6 +992,7 @@ public class ProductServices {
             String imageUrlPrefix = 
FlexibleStringExpander.expandString(EntityUtilProperties.getPropertyValue("catalog",
                     "image.url.prefix", delegator), imageContext);
             imageServerPath = imageServerPath.endsWith("/") ? 
imageServerPath.substring(0, imageServerPath.length() - 1) : imageServerPath;
+            Path imageServerNormalizedPath = 
Paths.get(imageServerPath).normalize();
             imageUrlPrefix = imageUrlPrefix.endsWith("/") ? 
imageUrlPrefix.substring(0, imageUrlPrefix.length() - 1) : imageUrlPrefix;
             FlexibleStringExpander filenameExpander = 
FlexibleStringExpander.getInstance(imageFilenameFormat);
             String viewNumber = 
String.valueOf(productContentTypeId.charAt(productContentTypeId.length() - 1));
@@ -1027,8 +1029,15 @@ public class ProductServices {
 
             /* Write the new image file */
             String targetDirectory = imageServerPath + "/" + filePathPrefix;
+            // Guard against path traversal: the resolved directory must 
remain inside imageServerPath
+            Path resolvedTargetDir = Paths.get(targetDirectory).normalize();
+            if (!resolvedTargetDir.startsWith(imageServerNormalizedPath)) {
+                Debug.logError("Path traversal attempt detected in product 
image upload, productId: " + productId, MODULE);
+                return 
ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE,
+                        "ProductImageViewUnableWriteFile", 
UtilMisc.toMap("fileName", targetDirectory), locale));
+            }
             try {
-                File targetDir = new File(targetDirectory);
+                File targetDir = resolvedTargetDir.toFile();
                 // Create the new directory
                 if (!targetDir.exists()) {
                     boolean created = targetDir.mkdirs();
@@ -1074,21 +1083,31 @@ public class ProductServices {
             }
             // Write
             try {
-                String fileToCheck = imageServerPath + "/" + fileLocation + 
"." + extension.getString("fileExtensionId");
-                File file = new File(fileToCheck);
+                String fileExtId = extension.getString("fileExtensionId");
+                // Guard against path traversal in the resolved file path
+                Path resolvedFilePath = Paths.get(imageServerPath, 
fileLocation + "." + fileExtId).normalize();
+                if (!resolvedFilePath.startsWith(imageServerNormalizedPath)) {
+                    Debug.logError("Path traversal attempt detected in product 
image file path, productId: " + productId, MODULE);
+                    return 
ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE,
+                            "ProductImageViewUnableWriteFile", 
UtilMisc.toMap("fileName", resolvedFilePath.toString()), locale));
+                }
+                File file = resolvedFilePath.toFile();
                 try {
-                    Path tempFile = Files.createTempFile(null, null);
+                    // Use the intended extension so that isValidFile's 
allow-list check applies correctly
+                    // (e.g. rejects htm/html before content inspection) and 
so that the imageMadeSafe()
+                    // call inside isValidFile re-encodes and sanitizes the 
image in-place in the temp file.
+                    Path tempFile = Files.createTempFile(null, "." + 
fileExtId);
                     Files.write(tempFile, imageData.array(), 
StandardOpenOption.APPEND);
                     // Check if a webshell is not uploaded
                     if 
(!org.apache.ofbiz.security.SecuredUpload.isValidFile(tempFile.toString(), 
"Image", delegator)) {
                         String errorMessage = 
UtilProperties.getMessage("SecurityUiLabels", "SupportedImageFormats", locale);
+                        new File(tempFile.toString()).deleteOnExit();
                         return ServiceUtil.returnError(errorMessage);
                     }
-                    File tempFileToDelete = new File(tempFile.toString());
-                    tempFileToDelete.deleteOnExit();
-                    RandomAccessFile out = new RandomAccessFile(fileToCheck, 
"rw");
-                    out.write(imageData.array());
-                    out.close();
+                    // Copy from the sanitized temp file, not from the 
original byte array, so that
+                    // metadata and payloads stripped by imageMadeSafe() are 
not reintroduced.
+                    Files.copy(tempFile, resolvedFilePath, 
StandardCopyOption.REPLACE_EXISTING);
+                    new File(tempFile.toString()).deleteOnExit();
                 } catch (FileNotFoundException e) {
                     Debug.logError(e, MODULE);
                     return 
ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE,
@@ -1348,6 +1367,7 @@ public class ProductServices {
             String imageUrlPrefix = 
FlexibleStringExpander.expandString(EntityUtilProperties.getPropertyValue("catalog",
                     "image.url.prefix", delegator), imageContext);
             imageServerPath = imageServerPath.endsWith("/") ? 
imageServerPath.substring(0, imageServerPath.length() - 1) : imageServerPath;
+            Path imageServerNormalizedPath = 
Paths.get(imageServerPath).normalize();
             imageUrlPrefix = imageUrlPrefix.endsWith("/") ? 
imageUrlPrefix.substring(0, imageUrlPrefix.length() - 1) : imageUrlPrefix;
             FlexibleStringExpander filenameExpander = 
FlexibleStringExpander.getInstance(imageFilenameFormat);
             String id = productPromoId + "_Image_" + 
productPromoContentTypeId.charAt(productPromoContentTypeId.length() - 1);
@@ -1375,29 +1395,45 @@ public class ProductServices {
                 filenameToUse += "." + extension.getString("fileExtensionId");
             }
 
-            File makeResourceDirectory = new File(imageServerPath + "/" + 
filePathPrefix);
+            // Guard against path traversal: the resolved directory must 
remain inside imageServerPath
+            Path resolvedResourceDir = Paths.get(imageServerPath, 
filePathPrefix).normalize();
+            if (!resolvedResourceDir.startsWith(imageServerNormalizedPath)) {
+                Debug.logError("Path traversal attempt detected in product 
promo image upload, productPromoId: " + productPromoId, MODULE);
+                return 
ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE,
+                        "ProductImageViewUnableWriteFile", 
UtilMisc.toMap("fileName", imageServerPath + "/" + filePathPrefix), locale));
+            }
+            File makeResourceDirectory = resolvedResourceDir.toFile();
             if (!makeResourceDirectory.exists()) {
                 if (!makeResourceDirectory.mkdirs()) {
                     Debug.logError("Directory :" + 
makeResourceDirectory.getPath() + ", couldn't be created", MODULE);
                 }
             }
 
-            String fileToCheck = imageServerPath + "/" + filePathPrefix + 
filenameToUse;
-            File file = new File(fileToCheck);
+            // Guard against path traversal in the resolved file path
+            Path resolvedFilePath = Paths.get(imageServerPath, filePathPrefix 
+ filenameToUse).normalize();
+            if (!resolvedFilePath.startsWith(imageServerNormalizedPath)) {
+                Debug.logError("Path traversal attempt detected in product 
promo image file path, productPromoId: " + productPromoId, MODULE);
+                return 
ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE,
+                        "ProductImageViewUnableWriteFile", 
UtilMisc.toMap("fileName", resolvedFilePath.toString()), locale));
+            }
+            File file = resolvedFilePath.toFile();
+            String fileExtId = extension != null ? 
extension.getString("fileExtensionId") : "";
 
             try {
-                Path tempFile = Files.createTempFile(null, null);
+                // Use the intended extension so that isValidFile's allow-list 
check applies correctly
+                // and imageMadeSafe() sanitizes the image in-place in the 
temp file.
+                Path tempFile = Files.createTempFile(null, fileExtId.isEmpty() 
? null : "." + fileExtId);
                 Files.write(tempFile, imageData.array(), 
StandardOpenOption.APPEND);
                 // Check if a webshell is not uploaded
                 if 
(!org.apache.ofbiz.security.SecuredUpload.isValidFile(tempFile.toString(), 
"Image", delegator)) {
                     String errorMessage = 
UtilProperties.getMessage("SecurityUiLabels", "SupportedImageFormats", locale);
+                    new File(tempFile.toString()).deleteOnExit();
                     return ServiceUtil.returnError(errorMessage);
                 }
-                File tempFileToDelete = new File(tempFile.toString());
-                tempFileToDelete.deleteOnExit();
-                RandomAccessFile out = new RandomAccessFile(file, "rw");
-                out.write(imageData.array());
-                out.close();
+                // Copy from the sanitized temp file, not from the original 
byte array, so that
+                // metadata and payloads stripped by imageMadeSafe() are not 
reintroduced.
+                Files.copy(tempFile, resolvedFilePath, 
StandardCopyOption.REPLACE_EXISTING);
+                new File(tempFile.toString()).deleteOnExit();
             } catch (FileNotFoundException e) {
                 Debug.logError(e, MODULE);
                 return 
ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE,
diff --git 
a/framework/base/src/main/java/org/apache/ofbiz/base/util/HttpRequestFileUpload.java
 
b/framework/base/src/main/java/org/apache/ofbiz/base/util/HttpRequestFileUpload.java
index 7e7a079357..0c72894e06 100644
--- 
a/framework/base/src/main/java/org/apache/ofbiz/base/util/HttpRequestFileUpload.java
+++ 
b/framework/base/src/main/java/org/apache/ofbiz/base/util/HttpRequestFileUpload.java
@@ -270,10 +270,27 @@ public class HttpRequestFileUpload {
                         }
                         fos.flush();
 
+                        // If the saved override filename carries no extension 
but the original client filename does,
+                        // temporarily rename the file to add that extension 
so that isAllowedExtension inside
+                        // isValidFile can compare it against the per-type 
allow-list.
+                        String fileToValidate = fileTocheck;
+                        if (overrideFilename != null && filename != null
+                                && filename.lastIndexOf('.') > 0
+                                && filenameToUse.lastIndexOf('.') < 0) {
+                            String originalExt = 
filename.substring(filename.lastIndexOf('.')).toLowerCase(java.util.Locale.ROOT);
+                            new File(fileTocheck).renameTo(new 
File(fileTocheck + originalExt));
+                            fileToValidate = fileTocheck + originalExt;
+                        }
                         // Check if a webshell is not uploaded
-                        if 
(!org.apache.ofbiz.security.SecuredUpload.isValidFile(fileTocheck, fileType, 
delegator)) {
+                        if 
(!org.apache.ofbiz.security.SecuredUpload.isValidFile(fileToValidate, fileType, 
delegator)) {
+                            if (!fileToValidate.equals(fileTocheck)) {
+                                new File(fileToValidate).renameTo(new 
File(fileTocheck));
+                            }
                             return false;
                         }
+                        if (!fileToValidate.equals(fileTocheck)) {
+                            new File(fileToValidate).renameTo(new 
File(fileTocheck));
+                        }
                     } catch (ImageReadException e) {
                         Debug.logError(e, MODULE);
                         return false;
diff --git a/framework/security/config/security.properties 
b/framework/security/config/security.properties
index 10d2e5562d..8d5b7c7bc9 100644
--- a/framework/security/config/security.properties
+++ b/framework/security/config/security.properties
@@ -285,7 +285,20 @@ templateClassResolver=
 #-- For text files, the philosophy is we can't presume of all possible text 
contents used for attacks with payloads
 #-- At least there is an easy way to prevent them in 
SecuredUpload::isValidTextFile
 #--
-#-- List of denied files suffixes to be uploaded
+#-- Extension ALLOW-LIST per file type (primary gate, checked before the 
deny-list below).
+#-- Only extensions listed here are accepted for each file type.
+#-- Comma-separated, case-insensitive. Modify with care: every entry is a 
potential attack surface.
+allowedExtensionsImage=jpg,jpeg,png,gif,tiff,tif
+allowedExtensionsImageAndSvg=jpg,jpeg,png,gif,tiff,tif,svg
+allowedExtensionsPdf=pdf
+allowedExtensionsAudio=mp3,wav,ogg,flac,mp4,m4a,aac
+allowedExtensionsVideo=mp4,mov,avi,mkv,webm,wmv,mpg,mpeg,3gp,flv
+allowedExtensionsText=txt
+allowedExtensionsCsv=csv
+allowedExtensionsCompressed=zip
+
+#-- List of denied files suffixes to be uploaded (secondary defence; kept for 
direct callers
+#-- of isValidFileName that operate without a file-type context, e.g. service 
validators).
 #-- OFBiz of course also check contents...
 
deniedFileExtensions=html,htm,php,php1,php2,hph3,php4,php5,php6,php7,phps,asp,aspx,asa,asax,ascx,ashx,asmx,jsp,jspa,jspx,jsw,jsv,jspf,jtml,cfm,cfc,bat,exe,com,dll,\
                      
vbs,js,reg,cgi,asis,sh,phtm,pht,phtml,shtm,inc,asp,cdx,asa,cer,py,pl,shtml,hta,ps1,tag,pgif,htaccess,phar,inc,cgi,wss,do,action
diff --git 
a/framework/security/src/main/java/org/apache/ofbiz/security/SecuredUpload.java 
b/framework/security/src/main/java/org/apache/ofbiz/security/SecuredUpload.java
index c0c5759d8a..5f46c3cedf 100644
--- 
a/framework/security/src/main/java/org/apache/ofbiz/security/SecuredUpload.java
+++ 
b/framework/security/src/main/java/org/apache/ofbiz/security/SecuredUpload.java
@@ -46,16 +46,23 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Locale;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 import java.util.UUID;
+import java.util.regex.Pattern;
 import java.util.zip.DataFormatException;
 import java.util.zip.Inflater;
 
 import javax.imageio.ImageIO;
 import javax.imageio.ImageReader;
 import javax.imageio.stream.ImageInputStream;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
 import javax.xml.parsers.ParserConfigurationException;
 
 import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
@@ -102,7 +109,11 @@ import org.apache.tika.sax.BasicContentHandlerFactory;
 import org.apache.tika.sax.ContentHandlerFactory;
 import org.apache.tika.sax.RecursiveParserWrapperHandler;
 import org.mustangproject.ZUGFeRD.ZUGFeRDImporter;
+import org.w3c.dom.Attr;
 import org.w3c.dom.Document;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
 import org.xml.sax.SAXException;
 
 import com.drew.imaging.ImageMetadataReader;
@@ -128,12 +139,148 @@ public class SecuredUpload {
     private static final Boolean ALLOWSTRINGCONCATENATIONINUPLOADEDFILES =
             UtilProperties.getPropertyAsBoolean("security", 
"allowStringConcatenationInUploadedFiles", false);
 
+    /**
+     * Per-type extension allow-lists loaded from {@code security.properties}.
+     * Keys match the {@code fileType} argument of {@link #isValidFile}.
+     * "AllButCompressed" and "All" are derived automatically as unions of the 
others.
+     */
+    private static final Map<String, Set<String>> ALLOWED_EXTENSIONS_BY_TYPE = 
buildAllowedExtensionsMap();
+
+    /**
+     * SVG element names (lower-cased) whose presence in an SVG file is 
unconditionally rejected.
+     * These elements either execute script, embed foreign content, or enable 
timing-based attacks.
+     */
+    private static final Set<String> DENIED_SVG_ELEMENTS = 
Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+            "script", "foreignobject", "animate", "animatecolor",
+            "animatemotion", "animatetransform", "set", "handler", 
"listener")));
+
+    /**
+     * Allow-list pattern for entity primary-key values (e.g. {@code 
contentId}).
+     * Accepts ASCII letters, digits, underscores, hyphens and colons — the 
character
+     * set used by all OFBiz auto-generated and user-supplied IDs.
+     */
+    private static final Pattern ENTITY_KEY_PATTERN = 
Pattern.compile("[a-zA-Z0-9_:\\-]{1,255}");
+
+    /**
+     * Allow-list pattern for webapp-relative path parameters (e.g. {@code 
webappPath}).
+     * Accepts ASCII letters, digits and the path punctuation {@code / . - _}.
+     */
+    private static final Pattern WEBAPP_PATH_PATTERN = 
Pattern.compile("[a-zA-Z0-9/_\\.\\-]{1,4096}");
+
     // "(" and ")" for duplicates files
     private static final String FILENAMEVALIDCHARACTERS_DUPLICATES =
             UtilProperties.getPropertyValue("security", 
"fileNameValidCharactersDuplicates", "[a-zA-Z0-9-_ ()]");
     private static final String FILENAMEVALIDCHARACTERS =
             UtilProperties.getPropertyValue("security", 
"fileNameValidCharacters", "[a-zA-Z0-9-_ ]");
 
+    // -----------------------------------------------------------------------
+    // Allow-list validators (preferred over the deny-list in isValidText)
+    // -----------------------------------------------------------------------
+
+    /**
+     * Allow-list validator for entity primary-key values such as {@code 
contentId}.
+     * <p>Only ASCII letters, digits, underscores, hyphens and colons are 
accepted —
+     * the character set used by OFBiz auto-generated and user-supplied IDs.
+     * <p>This is an <em>allow-list</em> check and does not rely on a 
deny-list, so it
+     * cannot be bypassed by encoding tricks or token splitting.
+     *
+     * @param key the string to validate (must not be {@code null} or empty)
+     * @return {@code true} when {@code key} conforms to the expected format
+     */
+    public static boolean isValidEntityKey(String key) {
+        if (UtilValidate.isEmpty(key)) {
+            Debug.logError("Entity key is null or empty", MODULE);
+            return false;
+        }
+        if (!ENTITY_KEY_PATTERN.matcher(key).matches()) {
+            Debug.logError("Entity key contains disallowed characters: " + 
key, MODULE);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Allow-list validator for webapp-relative path parameters such as {@code 
webappPath}.
+     * <p>Accepts only ASCII letters, digits and the path punctuation {@code / 
. - _}.
+     * Path-traversal sequences ({@code ..}) and null bytes are always 
rejected regardless
+     * of encoding.
+     * <p>This is an <em>allow-list</em> check and does not rely on a 
deny-list.
+     *
+     * @param path the string to validate
+     * @return {@code true} when {@code path} is safe to use as a webapp path
+     */
+    public static boolean isValidWebAppPath(String path) {
+        if (UtilValidate.isEmpty(path)) {
+            Debug.logError("Webapp path is null or empty", MODULE);
+            return false;
+        }
+        if (path.indexOf('\0') >= 0) {
+            Debug.logError("Webapp path contains a null byte", MODULE);
+            return false;
+        }
+        if (path.contains("..")) {
+            Debug.logError("Path traversal sequence '..' is not allowed in 
webapp path: " + path, MODULE);
+            return false;
+        }
+        if (!WEBAPP_PATH_PATTERN.matcher(path).matches()) {
+            Debug.logError("Webapp path contains disallowed characters: " + 
path, MODULE);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Allow-list validator for text content (uploaded files, string 
parameters).
+     * <p>Accepts all printable Unicode code points and the four universally 
legitimate
+     * whitespace characters (U+0009 HT, U+000A LF, U+000D CR, U+0020 SPACE).
+     * All other C0 control characters (U+0000–U+001F) and C1 control 
characters
+     * (U+007F–U+009F) are rejected.
+     * <p>This approach is significantly more robust than the token deny-list 
in
+     * {@link #isValidText} because it operates on decoded Unicode code points.
+     * It cannot be bypassed by token splitting, alternate encodings, or 
obfuscation
+     * with whitespace.  Its intent is to guarantee that stored text can safely
+     * round-trip through any character-level processing without unexpected
+     * control-character effects, <em>not</em> to detect every conceivable 
injection
+     * payload — that responsibility belongs to the surrounding context (output
+     * encoding, CSP, FreeMarker SAFER_RESOLVER, etc.).
+     *
+     * @param content the text content to validate; {@code null} is rejected
+     * @return {@code true} when all code points are within the allowed set
+     */
+    public static boolean isValidTextContent(String content) {
+        if (content == null) {
+            return false;
+        }
+        for (int i = 0; i < content.length();) {
+            int cp = content.codePointAt(i);
+            i += Character.charCount(cp);
+            // Allow standard whitespace: HT, LF, CR
+            if (cp == 0x09 || cp == 0x0A || cp == 0x0D) {
+                continue;
+            }
+            // Reject all other C0 control characters, including the null byte 
(U+0000)
+            if (cp < 0x20) {
+                Debug.logInfo("Text content rejected: contains C0 control 
character U+"
+                        + String.format("%04X", cp), MODULE);
+                return false;
+            }
+            // Reject DEL (U+007F) and C1 control characters (U+0080–U+009F)
+            if (cp >= 0x7F && cp <= 0x9F) {
+                Debug.logInfo("Text content rejected: contains C1 control 
character U+"
+                        + String.format("%04X", cp), MODULE);
+                return false;
+            }
+            // Lone surrogate code points should not appear in valid Java 
strings, but
+            // guard against them explicitly to prevent bypass via malformed 
UTF-16.
+            if (cp >= 0xD800 && cp <= 0xDFFF) {
+                Debug.logInfo("Text content rejected: contains surrogate code 
point", MODULE);
+                return false;
+            }
+            // All other printable Unicode code points are accepted.
+        }
+        return true;
+    }
+
     // Cover method of the same name below. Historically used with 84 
references when below was created
     // check there is no web shell in the uploaded file
     // A file containing a reverse shell will be rejected.
@@ -273,6 +420,14 @@ public class SecuredUpload {
             return true;
         }
 
+        // Primary gate: file extension must be in the allow-list for the 
declared file type.
+        // This is the strongest control — only explicitly whitelisted 
extensions pass.
+        // The deny-list in isValidFileName acts as a secondary defence.
+        if (!isAllowedExtension(fileToCheck, fileType)) {
+            deleteBadFile(fileToCheck);
+            return false;
+        }
+
         // Check the file name
         if (!isValidFileName(fileToCheck, delegator)) { // Useless when the 
file is internally generated, but not sure for all cases
             return false;
@@ -395,12 +550,18 @@ public class SecuredUpload {
         Path filePath = Paths.get(fileName);
         byte[] bytesFromFile = Files.readAllBytes(filePath);
         ImageFormat imageFormat = Imaging.guessFormat(bytesFromFile);
-        return (imageFormat.equals(ImageFormats.PNG)
+        boolean knownRasterFormat = imageFormat.equals(ImageFormats.PNG)
                 || imageFormat.equals(ImageFormats.GIF)
                 || imageFormat.equals(ImageFormats.TIFF)
-                || imageFormat.equals(ImageFormats.JPEG))
-                && imageMadeSafe(fileName)
-                && isValidTextFile(fileName, false);
+                || imageFormat.equals(ImageFormats.JPEG);
+        if (!knownRasterFormat) {
+            return false;
+        }
+        // imageMadeSafe() re-encodes the pixel data and overwrites the file 
on disk,
+        // stripping all metadata and steganographic payloads. Running 
isValidTextFile
+        // on the resulting binary pixel data would be both redundant and 
unreliable,
+        // so it is intentionally omitted here.
+        return imageMadeSafe(fileName);
     }
 
     /**
@@ -645,7 +806,11 @@ public class SecuredUpload {
             } catch (IOException e) {
                 return false;
             }
-            return isValidTextFile(fileName, true); // Validate content to 
prevent webshell
+            // DOM-level inspection: block dangerous elements (<script>, 
<foreignObject>, …)
+            // and unsafe attribute values (on* event handlers, 
javascript:/data: URIs).
+            // This is stronger than the text-token deny-list which can be 
bypassed by
+            // splitting tokens or using alternate serialisations.
+            return isSafeSvgContent(fileName);
         }
         return false;
     }
@@ -660,9 +825,10 @@ public class SecuredUpload {
             if (Objects.isNull(file) || !file.exists()) {
                 return false;
             }
-            // Load stream in PDF parser
-            new PdfReader(file.getAbsolutePath()); // Just a check
-            return true;
+            // Load stream in PDF parser — just probe whether the file is a 
valid PDF
+            try (PdfReader ignored = new PdfReader(file.getAbsolutePath())) {
+                return true;
+            }
         } catch (Exception e) {
             // If it's not a PDF then exception will be thrown and return will 
be false
             return false;
@@ -685,9 +851,11 @@ public class SecuredUpload {
                 return safeState;
             }
             // Load stream in PDF parser
-            PdfReader reader = new PdfReader(file.getAbsolutePath());
-            // Check 1: detect if the document contains any JavaScript code
-            String jsCode = reader.getJavaScript();
+            String jsCode;
+            try (PdfReader reader = new PdfReader(file.getAbsolutePath())) {
+                // Check 1: detect if the document contains any JavaScript code
+                jsCode = reader.getJavaScript();
+            }
             if (!Objects.isNull(jsCode)) {
                 return safeState;
             }
@@ -971,11 +1139,23 @@ public class SecuredUpload {
     }
 
     /**
-     * Does this text file contains a Freemarker Server-Side Template 
Injection (SSTI) using freemarker.template.utility.Execute? Etc.
-     * @param fileName must be an UTF-8 encoded text file
-     * @param encodedContent true id the file content is encoded
-     * @return true if the text file does not contains a Freemarker SSTI or 
other issues
-     * @throws IOException
+     * Validates that the file at {@code fileName} is safe plain text.
+     * <p>Three checks are applied in order:
+     * <ol>
+     *   <li><strong>UTF-8 encoding</strong> (when {@code encodedContent} is 
{@code true}) — rejects
+     *       binary files masquerading as text.</li>
+     *   <li><strong>Dangerous XML constructs</strong> — rejects {@code 
xlink:href="http"} (external
+     *       resource loading) and {@code <!ENTITY} (Billion-Laughs / 
XXE).</li>
+     *   <li><strong>Character-class allow-list</strong> ({@link 
#isValidTextContent}) — accepts only
+     *       printable Unicode code points and standard whitespace.  This is 
an <em>allow-list</em>
+     *       strategy and cannot be bypassed by token splitting, alternate 
encodings, or obfuscation
+     *       with whitespace, unlike the token deny-list previously used 
here.</li>
+     * </ol>
+     *
+     * @param fileName       path to the file to validate
+     * @param encodedContent {@code true} to additionally enforce UTF-8 
validity
+     * @return {@code true} when the file passes all checks
+     * @throws IOException if the file cannot be read
      */
     private static boolean isValidTextFile(String fileName, Boolean 
encodedContent) throws IOException {
         Path filePath = Paths.get(fileName);
@@ -993,8 +1173,11 @@ public class SecuredUpload {
             Debug.logInfo("Linked images inside or Entity in SVG are not 
allowed for security reason", MODULE);
             return false;
         }
-        ArrayList<String> allowed = new ArrayList<>();
-        return isValidText(content, allowed);
+        // Use the character-class allow-list instead of the token deny-list.
+        // The deny-list can be bypassed by token splitting, alternate 
encodings and
+        // obfuscation; isValidTextContent operates at the decoded code-point 
level
+        // and is immune to those bypass techniques.
+        return isValidTextContent(content);
     }
 
     // Check there is no web shell
@@ -1033,6 +1216,187 @@ public class SecuredUpload {
         }
     }
 
+    // -----------------------------------------------------------------------
+    // Extension allow-list helpers
+    // -----------------------------------------------------------------------
+
+    /**
+     * Builds the per-file-type extension allow-list map from {@code 
security.properties}.
+     * Each key matches a {@code fileType} value accepted by {@link 
#isValidFile}.
+     * "AllButCompressed" and "All" are derived as unions of the individual 
sets.
+     */
+    private static Map<String, Set<String>> buildAllowedExtensionsMap() {
+        Set<String> image = loadAllowedExtensions("allowedExtensionsImage",
+                "jpg,jpeg,png,gif,tiff,tif");
+        Set<String> imageAndSvg = 
loadAllowedExtensions("allowedExtensionsImageAndSvg",
+                "jpg,jpeg,png,gif,tiff,tif,svg");
+        Set<String> pdf = loadAllowedExtensions("allowedExtensionsPdf",
+                "pdf");
+        Set<String> audio = loadAllowedExtensions("allowedExtensionsAudio",
+                "mp3,wav,ogg,flac,mp4,m4a,aac");
+        Set<String> video = loadAllowedExtensions("allowedExtensionsVideo",
+                "mp4,mov,avi,mkv,webm,wmv,mpg,mpeg,3gp,flv");
+        Set<String> text = loadAllowedExtensions("allowedExtensionsText",
+                "txt");
+        Set<String> csv = loadAllowedExtensions("allowedExtensionsCsv",
+                "csv");
+        Set<String> compressed = 
loadAllowedExtensions("allowedExtensionsCompressed",
+                "zip");
+
+        Set<String> allButCompressed = new LinkedHashSet<>();
+        allButCompressed.addAll(image);
+        allButCompressed.addAll(imageAndSvg);
+        allButCompressed.addAll(pdf);
+        allButCompressed.addAll(audio);
+        allButCompressed.addAll(video);
+        allButCompressed.addAll(text);
+        allButCompressed.addAll(csv);
+
+        Set<String> all = new LinkedHashSet<>(allButCompressed);
+        all.addAll(compressed);
+
+        Map<String, Set<String>> map = new LinkedHashMap<>();
+        map.put("Image", image);
+        map.put("ImageAndSvg", imageAndSvg);
+        map.put("PDF", pdf);
+        map.put("Audio", audio);
+        map.put("Video", video);
+        map.put("Text", text);
+        map.put("CSV", csv);
+        map.put("Compressed", compressed);
+        map.put("AllButCompressed", 
Collections.unmodifiableSet(allButCompressed));
+        map.put("All", Collections.unmodifiableSet(all));
+        return Collections.unmodifiableMap(map);
+    }
+
+    /**
+     * Loads a comma-separated list of lowercase file extensions from
+     * {@code security.properties} under the given key, falling back to
+     * {@code defaultValue} when the property is absent or blank.
+     */
+    private static Set<String> loadAllowedExtensions(String propertyKey, 
String defaultValue) {
+        String value = UtilProperties.getPropertyValue("security", 
propertyKey, defaultValue);
+        Set<String> exts = new LinkedHashSet<>();
+        for (String ext : value.split(",")) {
+            String trimmed = ext.trim().toLowerCase(Locale.ROOT);
+            if (!trimmed.isEmpty()) {
+                exts.add(trimmed);
+            }
+        }
+        return Collections.unmodifiableSet(exts);
+    }
+
+    /**
+     * Returns {@code true} if the extension of {@code fileToCheck} is in the
+     * allow-list for the given {@code fileType}.  Falls back to the "All" set
+     * for unknown file-type strings so that callers are never silently blocked
+     * by a misconfigured type name.
+     */
+    private static boolean isAllowedExtension(String fileToCheck, String 
fileType) {
+        Set<String> allowed = ALLOWED_EXTENSIONS_BY_TYPE.getOrDefault(
+                fileType, ALLOWED_EXTENSIONS_BY_TYPE.get("All"));
+        String extension = 
FilenameUtils.getExtension(fileToCheck).toLowerCase(Locale.ROOT);
+        if (allowed.contains(extension)) {
+            return true;
+        }
+        Debug.logError("File extension [." + extension + "] is not in the 
allow-list for"
+                + " file type [" + fileType + "]. To permit it, add it to the"
+                + " allowedExtensions" + fileType + " property in 
security.properties.", MODULE);
+        return false;
+    }
+
+    // -----------------------------------------------------------------------
+    // SVG DOM safety check
+    // -----------------------------------------------------------------------
+
+    /**
+     * Parses {@code fileName} as XML and walks the DOM tree looking for 
elements
+     * or attributes that can execute code or load external resources.
+     *
+     * <p>This replaces the text-token deny-list ({@code isValidTextFile}) for 
SVG
+     * files.  Token scanning can be bypassed by splitting keywords across 
lines,
+     * using alternate encodings, or exploiting SVG/XML serialisation 
ambiguities.
+     * DOM-level inspection operates on the fully-parsed structure and is 
therefore
+     * immune to those bypass techniques.
+     *
+     * <p>The parser is hardened against XXE and DOCTYPE-injection before use.
+     *
+     * @return {@code true} if no unsafe element or attribute was found
+     */
+    private static boolean isSafeSvgContent(String fileName) {
+        try {
+            DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+            // Harden against XXE and DOCTYPE-based injection attacks
+            
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl";, true);
+            
dbf.setFeature("http://xml.org/sax/features/external-general-entities";, false);
+            
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities";, 
false);
+            dbf.setExpandEntityReferences(false);
+            dbf.setNamespaceAware(true);
+            DocumentBuilder db = dbf.newDocumentBuilder();
+            Document doc = db.parse(new File(fileName));
+            return checkSvgNode(doc.getDocumentElement());
+        } catch (Exception e) {
+            Debug.logWarning(e, "SVG content safety check failed for: " + 
fileName, MODULE);
+            return false;
+        }
+    }
+
+    /**
+     * Recursively walks an SVG DOM tree and returns {@code false} on the first
+     * unsafe element or attribute found.
+     *
+     * <p>Rejected elements: anything in {@link #DENIED_SVG_ELEMENTS} (e.g.
+     * {@code <script>}, {@code <foreignObject>}, animation elements).
+     * Rejected attributes:
+     * <ul>
+     *   <li>{@code on*} event-handler attributes (e.g. {@code onclick}, 
{@code onload})</li>
+     *   <li>{@code href}, {@code xlink:href}, {@code src}, {@code action} 
pointing to a
+     *       {@code javascript:}, {@code data:}, or {@code vbscript:} URI 
scheme</li>
+     * </ul>
+     */
+    private static boolean checkSvgNode(Node node) {
+        if (node.getNodeType() == Node.ELEMENT_NODE) {
+            String localName = node.getLocalName() != null ? 
node.getLocalName() : node.getNodeName();
+            if 
(DENIED_SVG_ELEMENTS.contains(localName.toLowerCase(Locale.ROOT))) {
+                Debug.logInfo("SVG rejected: contains denied element <" + 
localName + ">", MODULE);
+                return false;
+            }
+            NamedNodeMap attrs = node.getAttributes();
+            if (attrs != null) {
+                for (int i = 0; i < attrs.getLength(); i++) {
+                    Attr attr = (Attr) attrs.item(i);
+                    String attrName = attr.getName().toLowerCase(Locale.ROOT);
+                    String attrValue = attr.getValue() == null ? ""
+                            : attr.getValue().trim().toLowerCase(Locale.ROOT);
+                    // Block on* event-handler attributes
+                    if (attrName.startsWith("on")) {
+                        Debug.logInfo("SVG rejected: event-handler attribute ["
+                                + attr.getName() + "]", MODULE);
+                        return false;
+                    }
+                    // Block unsafe URI schemes in link/source attributes
+                    if (attrName.equals("href") || 
attrName.equals("xlink:href")
+                            || attrName.equals("src") || 
attrName.equals("action")) {
+                        if (attrValue.startsWith("javascript:")
+                                || attrValue.startsWith("data:")
+                                || attrValue.startsWith("vbscript:")) {
+                            Debug.logInfo("SVG rejected: unsafe URI scheme in 
attribute ["
+                                    + attr.getName() + "]", MODULE);
+                            return false;
+                        }
+                    }
+                }
+            }
+        }
+        NodeList children = node.getChildNodes();
+        for (int i = 0; i < children.getLength(); i++) {
+            if (!checkSvgNode(children.item(i))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     private static List<String> getDeniedFileExtensions() {
         String deniedExtensions = UtilProperties.getPropertyValue("security", 
"deniedFileExtensions");
         return UtilValidate.isNotEmpty(deniedExtensions) ? 
StringUtil.split(deniedExtensions, ",") : new ArrayList<>();
diff --git 
a/framework/service/src/main/java/org/apache/ofbiz/service/engine/EntityAutoEngine.java
 
b/framework/service/src/main/java/org/apache/ofbiz/service/engine/EntityAutoEngine.java
index 1445a49a4e..e95261b982 100644
--- 
a/framework/service/src/main/java/org/apache/ofbiz/service/engine/EntityAutoEngine.java
+++ 
b/framework/service/src/main/java/org/apache/ofbiz/service/engine/EntityAutoEngine.java
@@ -625,6 +625,13 @@ public final class EntityAutoEngine extends 
GenericAsyncEngine {
         // TODO maybe more parameters will be needed in future...
         String parameter = (String) parameters.get("webappPath");
         if (parameter != null) {
+            // Use an allow-list path validator instead of the token deny-list:
+            // only ASCII letters, digits and / . - _ are accepted, and 
path-traversal
+            // sequences (..) and null bytes are always rejected regardless of 
encoding.
+            if (!SecuredUpload.isValidWebAppPath(parameter)) {
+                Debug.logError("================== Not saved for security 
reason ==================", MODULE);
+                return false;
+            }
             try {
                 if (!SecuredUpload.isValidText(parameter, 
Collections.emptyList())) {
                     Debug.logError("================== Not saved for security 
reason ==================", MODULE);

Reply via email to