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 af8ee514a2 Improved: Enhance data resource validation and permission 
checks
af8ee514a2 is described below

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

    Improved: Enhance data resource validation and permission checks
---
 applications/content/servicedef/services_data.xml  |   2 +
 .../ofbiz/content/data/DataResourceWorker.java     | 272 ++++++++++++++++++++-
 framework/security/config/security.properties      |  29 +++
 3 files changed, 298 insertions(+), 5 deletions(-)

diff --git a/applications/content/servicedef/services_data.xml 
b/applications/content/servicedef/services_data.xml
index c6a22a5f56..d9432eeaf5 100644
--- a/applications/content/servicedef/services_data.xml
+++ b/applications/content/servicedef/services_data.xml
@@ -89,6 +89,7 @@
     <service name="createDataResourceAndText" engine="java" 
default-entity-name="DataResource" auth="true"
             location="org.apache.ofbiz.content.data.DataServices" 
invoke="createDataResourceAndText">
         <description>Create a DataResource and, possibly, ElectronicText or 
ImageDataResource</description>
+        <permission-service service-name="genericDataResourcePermission" 
main-action="CREATE"/>
         <auto-attributes include="pk" mode="INOUT" optional="true"/>
         <auto-attributes include="nonpk" mode="IN" optional="true"/>
         <attribute name="textData" mode="IN" optional="true" type="String" 
allow-html="safe"/>
@@ -99,6 +100,7 @@
     <service name="updateDataResourceAndText" engine="java" 
default-entity-name="DataResource" auth="true"
             location="org.apache.ofbiz.content.data.DataServices" 
invoke="updateDataResourceAndText">
         <description>Create a DataResource and, possibly, ElectronicText or 
ImageDataResource</description>
+        <permission-service service-name="genericDataResourcePermission" 
main-action="UPDATE"/>
         <auto-attributes include="pk" mode="IN" optional="true"/>
         <auto-attributes include="nonpk" mode="IN" optional="true"/>
         <attribute name="textData" mode="IN" type="String" optional="true"  
allow-html="safe"/>
diff --git 
a/applications/content/src/main/java/org/apache/ofbiz/content/data/DataResourceWorker.java
 
b/applications/content/src/main/java/org/apache/ofbiz/content/data/DataResourceWorker.java
index d690f483bc..58008249c4 100644
--- 
a/applications/content/src/main/java/org/apache/ofbiz/content/data/DataResourceWorker.java
+++ 
b/applications/content/src/main/java/org/apache/ofbiz/content/data/DataResourceWorker.java
@@ -27,13 +27,17 @@ import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.StringWriter;
 import java.io.Writer;
+import java.net.HttpURLConnection;
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
 import java.net.URL;
 import java.net.URLConnection;
+import java.net.UnknownHostException;
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.StandardOpenOption;
-import java.sql.Timestamp;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.LinkedList;
@@ -55,14 +59,12 @@ import 
org.apache.commons.fileupload2.core.FileUploadException;
 import org.apache.commons.fileupload2.jakarta.JakartaServletFileUpload;
 
 import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.BoundedInputStream;
 import org.apache.ofbiz.base.location.FlexibleLocation;
 import org.apache.ofbiz.base.util.Debug;
 import org.apache.ofbiz.base.util.FileUtil;
 import org.apache.ofbiz.base.util.GeneralException;
-import org.apache.ofbiz.base.util.StringUtil;
-import org.apache.ofbiz.base.util.StringUtil.StringWrapper;
 import org.apache.ofbiz.base.util.UtilCodec;
-import org.apache.ofbiz.base.util.UtilDateTime;
 import org.apache.ofbiz.base.util.UtilGenerics;
 import org.apache.ofbiz.base.util.UtilHttp;
 import org.apache.ofbiz.base.util.UtilIO;
@@ -469,6 +471,218 @@ public class DataResourceWorker implements 
org.apache.ofbiz.widget.content.DataR
         return prefix;
     }
 
+    /**
+     * Checks that the given file is within one of the directories listed in
+     * {@code content.data.local.file.allowed.paths} (security.properties).
+     * Use {@code ${ofbiz.home}} as a portable placeholder for the OFBiz home 
directory.
+     */
+    private static void checkLocalFileAllowList(File file) throws 
GeneralException {
+        try {
+            String canonicalFilePath = file.getCanonicalPath();
+            String ofbizHome = System.getProperty("ofbiz.home");
+            String allowedPathsStr = 
UtilProperties.getPropertyValue("security",
+                    "content.data.local.file.allowed.paths", "${ofbiz.home}");
+            if (UtilValidate.isNotEmpty(allowedPathsStr)) {
+                boolean inAllowedPath = false;
+                for (String allowedPath : allowedPathsStr.split(",")) {
+                    allowedPath = allowedPath.trim().replace("${ofbiz.home}", 
ofbizHome);
+                    if (UtilValidate.isEmpty(allowedPath)) {
+                        continue;
+                    }
+                    String canonicalAllowedDir = new 
File(allowedPath).getCanonicalPath();
+                    if (canonicalFilePath.startsWith(canonicalAllowedDir + 
File.separator)
+                            || canonicalFilePath.equals(canonicalAllowedDir)) {
+                        inAllowedPath = true;
+                        break;
+                    }
+                }
+                if (!inAllowedPath) {
+                    throw new GeneralException("Access to file denied: path is 
not within an allowed directory");
+                }
+            }
+        } catch (IOException e) {
+            throw new GeneralException("Unable to validate file path: " + 
e.getMessage());
+        }
+    }
+
+    /**
+     * Checks that the given file is within the OFBiz home directory and 
within one of the
+     * subdirectories listed in {@code content.data.ofbiz.file.allowed.paths} 
(security.properties).
+     */
+    private static void checkOfbizFileAllowList(File file) throws 
GeneralException {
+        try {
+            String canonicalHome = new 
File(System.getProperty("ofbiz.home")).getCanonicalPath();
+            String canonicalFilePath = file.getCanonicalPath();
+            if (!canonicalFilePath.startsWith(canonicalHome + File.separator)) 
{
+                throw new GeneralException("Access to file denied: path 
resolves outside of the OFBiz home directory");
+            }
+            String allowedPathsStr = 
UtilProperties.getPropertyValue("security",
+                    "content.data.ofbiz.file.allowed.paths", 
"applications/,themes/,plugins/,runtime/");
+            if (UtilValidate.isNotEmpty(allowedPathsStr)) {
+                boolean inAllowedPath = false;
+                for (String relPath : allowedPathsStr.split(",")) {
+                    relPath = relPath.trim().replaceAll("^/+", "");
+                    if (UtilValidate.isEmpty(relPath)) {
+                        continue;
+                    }
+                    String canonicalAllowedDir = new File(canonicalHome, 
relPath).getCanonicalPath();
+                    if (canonicalFilePath.startsWith(canonicalAllowedDir + 
File.separator)
+                            || canonicalFilePath.equals(canonicalAllowedDir)) {
+                        inAllowedPath = true;
+                        break;
+                    }
+                }
+                if (!inAllowedPath) {
+                    throw new GeneralException("Access to file denied: path is 
not within an allowed directory");
+                }
+            }
+        } catch (IOException e) {
+            throw new GeneralException("Unable to validate file path: " + 
e.getMessage());
+        }
+    }
+
+    /**
+     * Checks that the given file is within the provided context root 
directory.
+     */
+    private static void checkContextFileBoundary(File file, String 
contextRoot) throws GeneralException {
+        try {
+            String canonicalAllowed = new File(contextRoot).getCanonicalPath();
+            String canonicalFilePath = file.getCanonicalPath();
+            if (!canonicalFilePath.startsWith(canonicalAllowed + 
File.separator)) {
+                throw new GeneralException("Access to file denied: path 
resolves outside of the allowed directory");
+            }
+        } catch (IOException e) {
+            throw new GeneralException("Unable to validate file path: " + 
e.getMessage());
+        }
+    }
+
+    /**
+     * Validates a URL for the URL_RESOURCE data type against SSRF 
(Server-Side Request Forgery)
+     * attacks. Enforces:
+     * <ul>
+     *   <li>Protocol restricted to http/https only</li>
+     *   <li>Host must match the configured allow-list when
+     *       {@code content.data.url.resource.allowed.hosts} 
(security.properties) is non-empty;
+     *       both exact and subdomain matches are supported</li>
+     *   <li>All resolved IP addresses must not be private, loopback, 
link-local, multicast,
+     *       or otherwise reserved (mitigates DNS-rebinding)</li>
+     * </ul>
+     */
+    private static void checkUrlResourceAllowed(URL url) throws 
GeneralException {
+        // 1. Protocol: only http and https are permitted
+        String protocol = url.getProtocol();
+        if (!"http".equalsIgnoreCase(protocol) && 
!"https".equalsIgnoreCase(protocol)) {
+            throw new GeneralException("URL_RESOURCE only supports http/https 
protocols; rejected: " + protocol);
+        }
+        String host = url.getHost();
+        if (UtilValidate.isEmpty(host)) {
+            throw new GeneralException("URL_RESOURCE URL has no host 
component");
+        }
+
+        // 2. Allow-list: if configured, the host must match one of the entries
+        String allowedHostsStr = UtilProperties.getPropertyValue("security",
+                "content.data.url.resource.allowed.hosts", "");
+        if (UtilValidate.isNotEmpty(allowedHostsStr)) {
+            String lcHost = host.toLowerCase(Locale.ROOT);
+            boolean hostAllowed = false;
+            for (String entry : allowedHostsStr.split(",")) {
+                String allowedEntry = entry.trim().toLowerCase(Locale.ROOT);
+                if (UtilValidate.isEmpty(allowedEntry)) {
+                    continue;
+                }
+                if (lcHost.equals(allowedEntry) || lcHost.endsWith("." + 
allowedEntry)) {
+                    hostAllowed = true;
+                    break;
+                }
+            }
+            if (!hostAllowed) {
+                throw new GeneralException("URL_RESOURCE host is not in the 
allowed list: " + host);
+            }
+        }
+
+        // 3. DNS resolution: block private/reserved IP ranges (SSRF / 
DNS-rebinding mitigation)
+        InetAddress[] addresses;
+        try {
+            addresses = InetAddress.getAllByName(host);
+        } catch (UnknownHostException e) {
+            throw new GeneralException("URL_RESOURCE host cannot be resolved: 
" + host);
+        }
+        if (addresses == null || addresses.length == 0) {
+            throw new GeneralException("URL_RESOURCE host resolved to no 
addresses: " + host);
+        }
+        for (InetAddress addr : addresses) {
+            checkNotPrivateOrReservedAddress(addr);
+        }
+    }
+
+    /**
+     * Throws {@link GeneralException} if {@code addr} belongs to a private, 
loopback,
+     * link-local, multicast, or otherwise reserved IP range (IPv4 and IPv6).
+     */
+    private static void checkNotPrivateOrReservedAddress(InetAddress addr) 
throws GeneralException {
+        if (addr.isLoopbackAddress()) {
+            throw new GeneralException("URL_RESOURCE target resolves to a 
loopback address: " + addr.getHostAddress());
+        }
+        if (addr.isLinkLocalAddress()) {
+            throw new GeneralException("URL_RESOURCE target resolves to a 
link-local address: " + addr.getHostAddress());
+        }
+        if (addr.isSiteLocalAddress()) {
+            throw new GeneralException("URL_RESOURCE target resolves to a 
private (site-local) address: " + addr.getHostAddress());
+        }
+        if (addr.isAnyLocalAddress()) {
+            throw new GeneralException("URL_RESOURCE target resolves to a 
wildcard address: " + addr.getHostAddress());
+        }
+        if (addr.isMulticastAddress()) {
+            throw new GeneralException("URL_RESOURCE target resolves to a 
multicast address: " + addr.getHostAddress());
+        }
+        byte[] b = addr.getAddress();
+        if (addr instanceof Inet4Address) {
+            int i0 = b[0] & 0xFF;
+            int i1 = b[1] & 0xFF;
+            // 0.0.0.0/8 – "this" network (RFC 1122)
+            if (i0 == 0) {
+                throw new GeneralException("URL_RESOURCE target resolves to a 
reserved network address (0.0.0.0/8): " + addr.getHostAddress());
+            }
+            // 100.64.0.0/10 – shared address space / CGNAT (RFC 6598)
+            if (i0 == 100 && i1 >= 64 && i1 <= 127) {
+                throw new GeneralException("URL_RESOURCE target resolves to a 
shared address space (CGNAT, 100.64.0.0/10): " + addr.getHostAddress());
+            }
+            // 192.0.0.0/24 – IETF protocol assignments (RFC 6890)
+            if (i0 == 192 && i1 == 0 && (b[2] & 0xFF) == 0) {
+                throw new GeneralException("URL_RESOURCE target resolves to an 
IETF reserved address (192.0.0.0/24): " + addr.getHostAddress());
+            }
+            // 198.18.0.0/15 – network benchmarking (RFC 2544)
+            if (i0 == 198 && (i1 == 18 || i1 == 19)) {
+                throw new GeneralException("URL_RESOURCE target resolves to a 
benchmarking address (198.18.0.0/15): " + addr.getHostAddress());
+            }
+            // 240.0.0.0/4 – reserved for future use (RFC 1112)
+            if ((i0 & 0xF0) == 240) {
+                throw new GeneralException("URL_RESOURCE target resolves to a 
reserved address (240.0.0.0/4): " + addr.getHostAddress());
+            }
+        } else if (addr instanceof Inet6Address) {
+            // fc00::/7 – Unique Local Addresses (ULA), private IPv6 (RFC 4193)
+            if ((b[0] & 0xFE) == 0xFC) {
+                throw new GeneralException("URL_RESOURCE target resolves to a 
unique-local (private) IPv6 address: " + addr.getHostAddress());
+            }
+            // ::ffff:0:0/96 – IPv4-mapped IPv6; re-validate the embedded IPv4 
address
+            boolean isIpv4Mapped = true;
+            for (int i = 0; i < 10; i++) {
+                if (b[i] != 0) {
+                    isIpv4Mapped = false;
+                    break;
+                }
+            }
+            if (isIpv4Mapped && (b[10] & 0xFF) == 0xFF && (b[11] & 0xFF) == 
0xFF) {
+                try {
+                    checkNotPrivateOrReservedAddress(
+                            InetAddress.getByAddress(new byte[]{b[12], b[13], 
b[14], b[15]}));
+                } catch (UnknownHostException e) {
+                    throw new GeneralException("URL_RESOURCE target contains 
an invalid IPv4-mapped IPv6 address");
+                }
+            }
+        }
+    }
+
     public static File getContentFile(String dataResourceTypeId, String 
objectInfo, String contextRoot)
             throws GeneralException, FileNotFoundException {
         File file = null;
@@ -481,6 +695,7 @@ public class DataResourceWorker implements 
org.apache.ofbiz.widget.content.DataR
             if (!file.isAbsolute()) {
                 throw new GeneralException("File (" + objectInfo + ") is not 
absolute");
             }
+            checkLocalFileAllowList(file);
         } else if ("OFBIZ_FILE".equals(dataResourceTypeId) || 
"OFBIZ_FILE_BIN".equals(dataResourceTypeId)) {
             String prefix = System.getProperty("ofbiz.home");
 
@@ -492,6 +707,7 @@ public class DataResourceWorker implements 
org.apache.ofbiz.widget.content.DataR
             if (!file.exists()) {
                 throw new FileNotFoundException("No file found: " + (prefix + 
sep + objectInfo));
             }
+            checkOfbizFileAllowList(file);
         } else if ("CONTEXT_FILE".equals(dataResourceTypeId) || 
"CONTEXT_FILE_BIN".equals(dataResourceTypeId)) {
             if (UtilValidate.isEmpty(contextRoot)) {
                 throw new GeneralException("Cannot find CONTEXT_FILE with an 
empty context root!");
@@ -505,6 +721,7 @@ public class DataResourceWorker implements 
org.apache.ofbiz.widget.content.DataR
             if (!file.exists()) {
                 throw new FileNotFoundException("No file found: " + 
(contextRoot + sep + objectInfo));
             }
+            checkContextFileBoundary(file, contextRoot);
         }
 
         return file;
@@ -722,6 +939,8 @@ public class DataResourceWorker implements 
org.apache.ofbiz.widget.content.DataR
 
             // FTL template
             if ("FTL".equals(dataTemplateTypeId)) {
+                throw new GeneralException("Error rendering template: FTL 
template type is no longer supported for data resources.");
+                /*
                 try {
                     // get the template data for rendering
                     String templateText = getDataResourceText(dataResource, 
targetMimeTypeId, locale, templateContext, delegator, cache);
@@ -757,6 +976,7 @@ public class DataResourceWorker implements 
org.apache.ofbiz.widget.content.DataR
                 } catch (TemplateException e) {
                     throw new GeneralException("Error rendering FTL template", 
e);
                 }
+                */
 
             } else if ("XSLT".equals(dataTemplateTypeId)) {
                 File targetFileLocation = new 
File(System.getProperty("ofbiz.home") + "/runtime/tempfiles/docbook.css");
@@ -1039,6 +1259,7 @@ public class DataResourceWorker implements 
org.apache.ofbiz.widget.content.DataR
             if (!file.exists()) {
                 throw new FileNotFoundException("No file found: " + 
file.getAbsolutePath());
             }
+            checkLocalFileAllowList(file);
             try (InputStreamReader in = new InputStreamReader(new 
FileInputStream(file), StandardCharsets.UTF_8)) {
                 UtilIO.copy(in, out);
             }
@@ -1052,6 +1273,7 @@ public class DataResourceWorker implements 
org.apache.ofbiz.widget.content.DataR
             if (!file.exists()) {
                 throw new FileNotFoundException("No file found: " + 
file.getAbsolutePath());
             }
+            checkOfbizFileAllowList(file);
             try (InputStreamReader in = new InputStreamReader(new 
FileInputStream(file), StandardCharsets.UTF_8)) {
                 UtilIO.copy(in, out);
             }
@@ -1065,6 +1287,7 @@ public class DataResourceWorker implements 
org.apache.ofbiz.widget.content.DataR
             if (!file.exists()) {
                 throw new FileNotFoundException("No file found: " + 
file.getAbsolutePath());
             }
+            checkContextFileBoundary(file, rootDir);
             try (InputStreamReader in = new InputStreamReader(new 
FileInputStream(file), StandardCharsets.UTF_8)) {
                 if (Debug.infoOn()) {
                     String enc = in.getEncoding();
@@ -1183,8 +1406,47 @@ public class DataResourceWorker implements 
org.apache.ofbiz.widget.content.DataR
                     url = UtilURL.fromUrlString(newUrl);
                 }
 
+                // SSRF prevention: validate protocol, optional host 
allow-list, and resolved IP ranges
+                checkUrlResourceAllowed(url);
+
+                int connectTimeout = (int) 
UtilProperties.getPropertyNumber("security",
+                        "content.data.url.resource.connect.timeout", 10000.0);
+                int readTimeout = (int) 
UtilProperties.getPropertyNumber("security",
+                        "content.data.url.resource.read.timeout", 30000.0);
+                long maxResponseSize = (long) 
UtilProperties.getPropertyNumber("security",
+                        "content.data.url.resource.max.response.size", 
(double) (10L * 1024 * 1024));
+
                 URLConnection con = url.openConnection();
-                return UtilMisc.toMap("stream", con.getInputStream(), 
"length", (long) con.getContentLength());
+                con.setConnectTimeout(connectTimeout);
+                con.setReadTimeout(readTimeout);
+                // Disable automatic redirect-following to prevent SSRF bypass 
via redirect to private addresses
+                if (con instanceof HttpURLConnection) ((HttpURLConnection) 
con).setInstanceFollowRedirects(false);
+                con.connect();
+
+                // Reject redirects outright; we cannot safely re-validate an 
arbitrary Location header
+                if (con instanceof HttpURLConnection) {
+                    HttpURLConnection httpCon = (HttpURLConnection) con;
+                    int responseCode = httpCon.getResponseCode();
+                    if (responseCode >= 300 && responseCode < 400) {
+                        httpCon.disconnect();
+                        throw new GeneralException("URL_RESOURCE request 
returned a redirect (" + responseCode
+                                + "); redirects are not followed for security 
reasons");
+                    }
+                }
+
+                long contentLength = con.getContentLengthLong();
+                if (contentLength > maxResponseSize) {
+                    if (con instanceof HttpURLConnection) ((HttpURLConnection) 
con).disconnect();
+                    throw new GeneralException("URL_RESOURCE response 
Content-Length (" + contentLength
+                            + " bytes) exceeds the configured maximum of " + 
maxResponseSize + " bytes");
+                }
+
+                // Wrap with a bounded stream to enforce the size cap 
regardless of the Content-Length header
+                InputStream limitedStream = BoundedInputStream.builder()
+                        .setInputStream(con.getInputStream())
+                        .setMaxCount(maxResponseSize)
+                        .get();
+                return UtilMisc.toMap("stream", limitedStream, "length", 
contentLength);
             }
             throw new GeneralException("No objectInfo found for URL_RESOURCE 
type; cannot stream");
         }
diff --git a/framework/security/config/security.properties 
b/framework/security/config/security.properties
index 2e0d3ba0ed..10d2e5562d 100644
--- a/framework/security/config/security.properties
+++ b/framework/security/config/security.properties
@@ -175,6 +175,35 @@ security.token.key=
 #    During validation, only tokens containing this audience value will be 
considered valid.
 #security.token.audience=
 
+# -- Allowed subdirectories (relative to ofbiz.home) for the OFBIZ_FILE / 
OFBIZ_FILE_BIN data resource types.
+# -- Comma-separated list, no spaces after commas, no leading slashes.
+# -- Only files whose resolved canonical path starts with one of these entries 
will be served.
+# -- Set to empty to allow any path within ofbiz.home (the ofbiz.home 
containment check still applies).
+content.data.ofbiz.file.allowed.paths=applications/,themes/,plugins/,runtime/
+
+# -- Allowed directories for the LOCAL_FILE / LOCAL_FILE_BIN data resource 
types (absolute paths).
+# -- Comma-separated, no spaces after commas. Use ${ofbiz.home} as a portable 
placeholder.
+# -- Only files whose resolved canonical path starts with one of these entries 
will be served.
+# -- Set to empty to disable this check (NOT recommended).
+content.data.local.file.allowed.paths=${ofbiz.home}
+
+# -- Allowed hosts for the URL_RESOURCE data resource type (comma-separated 
host names or host:port values).
+# -- Both exact matches and subdomain matches are supported: "example.com" 
also permits "cdn.example.com".
+# -- Leave empty to skip host filtering and apply only IP-range checks 
(private/loopback/link-local/multicast
+# -- addresses are always blocked regardless of this setting).
+content.data.url.resource.allowed.hosts=
+
+# -- Connection timeout in milliseconds for URL_RESOURCE fetches. Default: 
10000 (10 s).
+content.data.url.resource.connect.timeout=10000
+
+# -- Read timeout in milliseconds for URL_RESOURCE fetches. Default: 30000 (30 
s).
+content.data.url.resource.read.timeout=30000
+
+# -- Maximum response body size in bytes for URL_RESOURCE fetches. Default: 
10485760 (10 MB).
+# -- Responses advertising a larger Content-Length are refused before the 
connection body is read;
+# -- the returned InputStream is additionally capped at this size regardless 
of the declared length.
+content.data.url.resource.max.response.size=10485760
+
 # -- List of domains or IP addresses to be checked to prevent Host Header 
Injection,
 # -- no spaces after commas,no wildcard, can be extended of course...
 
host-headers-allowed=localhost,127.0.0.1,demo-trunk.ofbiz.apache.org,demo-stable.ofbiz.apache.org,demo-next.ofbiz.apache.org,demo-trunk.ofbiz-test.apache.org,demo-stable-test.ofbiz.apache.org,demo-next.ofbiz-test.apache.org

Reply via email to