Skip to content

Commit d094e5a

Browse files
committed
Fixed: Improve file path validation to work with mounted directories
File.getCanonicalPath() resolves all symlinks in a path. OS-level mount points (Docker bind mounts, EFS) appear as symlinks. When a subdirectory like runtime/ is mounted, a file's canonical path resolves to the mount target (e.g. /mnt/efs/data/file.pdf), which no longer shares a prefix with the canonical path of ofbiz.home (/opt/ofbiz) (cherry picked from commit b3eadf1)
1 parent 30c83b9 commit d094e5a

3 files changed

Lines changed: 104 additions & 6 deletions

File tree

applications/content/src/main/java/org/apache/ofbiz/content/data/DataResourceWorker.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import java.nio.ByteBuffer;
3838
import java.nio.charset.StandardCharsets;
3939
import java.nio.file.Files;
40+
import java.nio.file.Path;
4041
import java.nio.file.StandardOpenOption;
4142
import java.util.Comparator;
4243
import java.util.HashMap;
@@ -473,12 +474,24 @@ public static String buildRequestPrefix(Delegator delegator, Locale locale, Stri
473474

474475
/**
475476
* Checks that the given file is within the provided context root directory.
477+
* Uses a dual-check strategy to support EFS/Docker mount points:
478+
* 1. Canonical paths (resolves symlinks on both sides) — works for non-mounted paths.
479+
* 2. Normalized absolute paths (collapses ".." without following symlinks) — fallback for
480+
* when contextRoot or a subdirectory inside it is a mount point, causing canonical paths
481+
* to diverge. Path traversal via ".." is still blocked by the normalization step.
476482
*/
477483
static void checkContextFileBoundary(File file, String contextRoot) throws GeneralException {
478484
try {
479485
String canonicalAllowed = new File(contextRoot).getCanonicalPath();
480486
String canonicalFilePath = file.getCanonicalPath();
481-
if (!canonicalFilePath.startsWith(canonicalAllowed + File.separator)) {
487+
boolean passesCanonical = canonicalFilePath.startsWith(canonicalAllowed + File.separator)
488+
|| canonicalFilePath.equals(canonicalAllowed);
489+
490+
Path normalizedAllowed = Path.of(contextRoot).toAbsolutePath().normalize();
491+
Path normalizedFilePath = file.toPath().toAbsolutePath().normalize();
492+
boolean passesNormalized = normalizedFilePath.startsWith(normalizedAllowed);
493+
494+
if (!passesCanonical && !passesNormalized) {
482495
throw new GeneralException("Access to file denied: path resolves outside of the allowed directory");
483496
}
484497
} catch (IOException e) {

framework/security/src/main/java/org/apache/ofbiz/security/SecurityUtil.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,16 +174,19 @@ public static void checkLocalFileAllowList(File file) throws GeneralException {
174174
}
175175

176176
/**
177-
* Checks that the given file is within the OFBiz home directory and within one of the
178-
* subdirectories listed in {@code content.data.ofbiz.file.allowed.paths} (security.properties).
177+
* Checks that the given file is within one of the subdirectories listed in
178+
* {@code content.data.ofbiz.file.allowed.paths} (security.properties), relative to the OFBiz
179+
* home directory. The check uses canonical paths (resolving symlinks on both sides), so
180+
* EFS/Docker volume mounts on allowed subdirectories are handled correctly. A preliminary
181+
* "must be under ofbiz.home" canonical check is intentionally absent: when an allowed
182+
* subdirectory (e.g. {@code runtime/}) is a mount point, the file's canonical path diverges
183+
* from {@code canonicalHome}, but the per-allowed-path comparison below still passes because
184+
* it resolves both sides through the mount. Path traversal via {@code ../} is still blocked.
179185
*/
180186
public static void checkOfbizFileAllowList(File file) throws GeneralException {
181187
try {
182188
String canonicalHome = new File(System.getProperty("ofbiz.home")).getCanonicalPath();
183189
String canonicalFilePath = file.getCanonicalPath();
184-
if (!canonicalFilePath.startsWith(canonicalHome + File.separator)) {
185-
throw new GeneralException("Access to file denied: path resolves outside of the OFBiz home directory");
186-
}
187190
String allowedPathsStr = UtilProperties.getPropertyValue("security",
188191
"content.data.ofbiz.file.allowed.paths", "applications/,themes/,plugins/,runtime/");
189192
if (UtilValidate.isNotEmpty(allowedPathsStr)) {

framework/security/src/test/java/org/apache/ofbiz/security/SecurityUtilTest.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,95 @@
2020

2121
import static org.junit.Assert.assertFalse;
2222
import static org.junit.Assert.assertTrue;
23+
import static org.junit.Assert.fail;
2324

25+
import java.io.File;
26+
import java.nio.file.Files;
27+
import java.nio.file.Path;
2428
import java.util.Arrays;
29+
import java.util.Comparator;
2530
import java.util.List;
2631

32+
import org.apache.ofbiz.base.util.GeneralException;
33+
import org.junit.After;
34+
import org.junit.Before;
2735
import org.junit.Test;
2836

2937
public class SecurityUtilTest {
38+
39+
private Path tempHome;
40+
private Path tempExternal;
41+
private String previousOfbizHome;
42+
43+
@Before
44+
public void setUpTempDirs() throws Exception {
45+
tempHome = Files.createTempDirectory("ofbiz-home-test");
46+
tempExternal = Files.createTempDirectory("ofbiz-ext-test");
47+
previousOfbizHome = System.getProperty("ofbiz.home");
48+
System.setProperty("ofbiz.home", tempHome.toString());
49+
}
50+
51+
@After
52+
public void tearDownTempDirs() throws Exception {
53+
if (previousOfbizHome != null) {
54+
System.setProperty("ofbiz.home", previousOfbizHome);
55+
} else {
56+
System.clearProperty("ofbiz.home");
57+
}
58+
deleteDirRecursively(tempHome);
59+
deleteDirRecursively(tempExternal);
60+
}
61+
62+
private static void deleteDirRecursively(Path dir) throws Exception {
63+
if (dir != null && Files.exists(dir)) {
64+
Files.walk(dir).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
65+
}
66+
}
67+
68+
@Test
69+
public void checkOfbizFileAllowListAcceptsFileInAllowedDir() throws Exception {
70+
Path runtimeDir = Files.createDirectory(tempHome.resolve("runtime"));
71+
Path file = Files.createTempFile(runtimeDir, "upload", ".dat");
72+
SecurityUtil.checkOfbizFileAllowList(file.toFile());
73+
}
74+
75+
@Test
76+
public void checkOfbizFileAllowListRejectsFileOutsideAllowedDir() throws Exception {
77+
Path forbiddenDir = Files.createDirectory(tempHome.resolve("forbidden"));
78+
Path file = Files.createTempFile(forbiddenDir, "upload", ".dat");
79+
try {
80+
SecurityUtil.checkOfbizFileAllowList(file.toFile());
81+
fail("Expected GeneralException for file outside allowed dirs");
82+
} catch (GeneralException e) {
83+
assertTrue(e.getMessage().contains("not within an allowed directory"));
84+
}
85+
}
86+
87+
@Test
88+
public void checkOfbizFileAllowListAcceptsFileUnderSymlinkedAllowedDir() throws Exception {
89+
// Simulates an EFS/Docker volume mount: runtime/ is a symlink to an external directory.
90+
// Prior to the fix, the home-boundary canonical check incorrectly rejected this because
91+
// the file's canonical path diverged from canonicalHome.
92+
Path runtimeLink = tempHome.resolve("runtime");
93+
Files.createSymbolicLink(runtimeLink, tempExternal);
94+
Path file = Files.createTempFile(tempExternal, "upload", ".dat");
95+
SecurityUtil.checkOfbizFileAllowList(file.toFile());
96+
}
97+
98+
@Test
99+
public void checkOfbizFileAllowListRejectsPathTraversal() throws Exception {
100+
Path runtimeDir = Files.createDirectory(tempHome.resolve("runtime"));
101+
// Construct a path that tries to escape via ".." — getCanonicalPath resolves this.
102+
File traversalFile = new File(runtimeDir.toFile(), "../../etc/passwd");
103+
try {
104+
SecurityUtil.checkOfbizFileAllowList(traversalFile);
105+
fail("Expected GeneralException for path traversal attempt");
106+
} catch (GeneralException e) {
107+
assertTrue(e.getMessage().contains("not within an allowed directory"));
108+
}
109+
}
110+
111+
30112
@Test
31113
public void basicAdminPermissionTesting() {
32114
List<String> adminPermissions = Arrays.asList("PARTYMGR", "EXAMPLE", "ACCTG_PREF");

0 commit comments

Comments
 (0)