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

jayblanc pushed a commit to branch UNOMI-973-file-endpoint-containment
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit c0ecaa7fc90f1fdca0902604dd1bb4f8c0c4c601
Author: Jérôme Blanchard <[email protected]>
AuthorDate: Tue Aug 11 15:11:17 2026 +0200

    UNOMI-973: Confine file import/export endpoints to permitted base 
directories
    
    EndpointValidator decides whether a configured endpoint URI may be used: its
    scheme must belong to the allow-list, and a file endpoint must resolve 
inside
    one of the base directories the deployment permits. Containment covers the
    directory the URI names and every path-bearing option it carries, and is
    decided on canonical paths -- percent-encoding decoded, RAW() unwrapped,
    parent segments resolved, symbolic links followed -- compared component by
    component, so a sibling sharing a textual prefix is not taken for a child.
    It is recursive and does not require the directory to exist, since an export
    destination is created on first write.
    
    Two new settings carry the base directories, one per direction, defaulting
    under karaf.data: an export cannot then write into a directory an import
    route polls. Route builders receive them through direction-specific setters,
    so neither can be wired with the other's list.
    
    The scheme test now matches whole schemes rather than searching the raw
    setting, and an endpoint with no scheme, or a blank destination, is reported
    and skipped instead of raising StringIndexOutOfBoundsException out of
    configure() -- which cost the deployment every other route of the batch.
    
    The oneshot upload route builds its own endpoint and is unaffected. Remote
    schemes carry no local path and stay governed by the scheme allow-list 
alone.
    Recurrent file configurations resolving outside the permitted directories
    stop building routes, which is a behaviour change for existing deployments.
---
 .../apache/unomi/router/api/EndpointValidator.java | 249 +++++++++++++++++++++
 .../router/core/context/RouterCamelContext.java    |  14 ++
 .../route/ProfileExportCollectRouteBuilder.java    |   7 +-
 .../route/ProfileImportFromSourceRouteBuilder.java |  10 +-
 .../resources/OSGI-INF/blueprint/blueprint.xml     |   4 +
 .../src/main/resources/org.apache.unomi.router.cfg |   9 +-
 .../core/route/FileEndpointContainmentTest.java    |  49 ++++
 .../org/apache/unomi/itests/ProfileExportIT.java   |   4 +-
 .../src/test/resources/org.apache.unomi.router.cfg |   6 +-
 9 files changed, 342 insertions(+), 10 deletions(-)

diff --git 
a/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/EndpointValidator.java
 
b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/EndpointValidator.java
new file mode 100644
index 000000000..1a18a0e89
--- /dev/null
+++ 
b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/EndpointValidator.java
@@ -0,0 +1,249 @@
+/*
+ * 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.unomi.router.api;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Decides whether the endpoint URI carried by an import or export 
configuration may be used.
+ *
+ * <p>Two rules apply. The scheme must belong to the configured allow-list. 
And a {@code file}
+ * endpoint must resolve inside one of the base directories the deployment 
permits — the directory
+ * the URI names, and every path-bearing option it carries, since validating 
only the directory would
+ * leave {@code file:///permitted/?fileName=../../elsewhere} open.
+ *
+ * <p>Containment is recursive: any depth under a permitted base directory is 
accepted, whether or
+ * not the directory exists yet. It is decided on canonical paths — 
percent-encoding decoded, parent
+ * segments resolved, symbolic links followed — and compared component by 
component, so a sibling
+ * that merely shares a textual prefix with a permitted directory is not 
mistaken for one of its
+ * children.
+ *
+ * <p>Schemes other than {@code file} carry no local path and are left to the 
scheme allow-list.
+ */
+public final class EndpointValidator {
+
+    public static final String FILE_SCHEME = "file";
+
+    /**
+     * The Camel file endpoint options whose value is, or contains, a path. 
Compared in lower case.
+     */
+    private static final Set<String> PATH_BEARING_OPTIONS = 
Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+            "filename", "tempfilename", "move", "movefailed", "premove", 
"donefilename",
+            "include", "antinclude", "antfilter")));
+
+    private EndpointValidator() {
+    }
+
+    /**
+     * Validates the endpoint URI of an import or export configuration.
+     *
+     * @param endpointUri       the endpoint URI, as configured
+     * @param allowedSchemes    the comma-separated list of allowed schemes
+     * @param permittedBaseDirs the comma-separated list of base directories a 
{@code file} endpoint may
+     *                          resolve into
+     * @return {@code null} when the endpoint may be used, otherwise the 
reason it is refused
+     */
+    public static String validate(String endpointUri, String allowedSchemes, 
String permittedBaseDirs) {
+        if (isBlank(endpointUri)) {
+            return "no endpoint is configured";
+        }
+
+        int schemeSeparator = endpointUri.indexOf(':');
+        if (schemeSeparator <= 0) {
+            return "endpoint '" + endpointUri + "' has no scheme";
+        }
+
+        String scheme = endpointUri.substring(0, schemeSeparator);
+        if (!containsIgnoreCase(split(allowedSchemes), scheme)) {
+            return "endpoint scheme '" + scheme + "' is not allowed";
+        }
+
+        if (!FILE_SCHEME.equalsIgnoreCase(scheme)) {
+            return null;
+        }
+
+        return validateContainment(endpointUri, permittedBaseDirs);
+    }
+
+    private static String validateContainment(String endpointUri, String 
permittedBaseDirs) {
+        List<Path> baseDirs = new ArrayList<>();
+        for (String baseDir : split(permittedBaseDirs)) {
+            baseDirs.add(canonicalize(Paths.get(baseDir)));
+        }
+        if (baseDirs.isEmpty()) {
+            return "no permitted base directory is configured for file 
endpoints";
+        }
+
+        int querySeparator = endpointUri.indexOf('?');
+        String head = querySeparator < 0 ? endpointUri : 
endpointUri.substring(0, querySeparator);
+        String query = querySeparator < 0 ? "" : 
endpointUri.substring(querySeparator + 1);
+
+        Path directory = Paths.get(decode(stripScheme(head)));
+        if (!isContained(directory, baseDirs)) {
+            return "directory '" + directory + "' is outside the permitted 
directories";
+        }
+
+        for (String[] parameter : parseQuery(query)) {
+            if 
(!PATH_BEARING_OPTIONS.contains(parameter[0].toLowerCase(Locale.ROOT))) {
+                continue;
+            }
+            String value = stripRaw(decode(parameter[1]));
+            if (value.isEmpty()) {
+                continue;
+            }
+            if (!isContained(directory.resolve(value), baseDirs)) {
+                return "option '" + parameter[0] + "' points outside the 
permitted directories";
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Removes the scheme, and the authority separator Camel tolerates in any 
of its forms
+     * ({@code file:dir}, {@code file://dir}, {@code file:///dir}).
+     */
+    private static String stripScheme(String head) {
+        String path = head.substring(head.indexOf(':') + 1);
+        return path.startsWith("//") ? path.substring(2) : path;
+    }
+
+    private static boolean isContained(Path path, List<Path> baseDirs) {
+        Path candidate = canonicalize(path);
+        for (Path baseDir : baseDirs) {
+            if (candidate.startsWith(baseDir)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Resolves a path to the one the file system would actually use: made 
absolute, stripped of its
+     * parent segments, and with the symbolic links of its existing part 
followed. A path that does not
+     * exist yet is canonicalized through its deepest existing ancestor — an 
export destination is
+     * created on first write, and must be decided on before it exists.
+     */
+    private static Path canonicalize(Path path) {
+        Path normalized = path.toAbsolutePath().normalize();
+        Path existing = normalized;
+        while (existing != null && !Files.exists(existing, 
LinkOption.NOFOLLOW_LINKS)) {
+            existing = existing.getParent();
+        }
+        if (existing == null) {
+            return normalized;
+        }
+        try {
+            return 
existing.toRealPath().resolve(existing.relativize(normalized));
+        } catch (IOException e) {
+            return normalized;
+        }
+    }
+
+    /**
+     * {@code RAW(...)} and {@code RAW{...}} tell Camel not to decode a value; 
the path it wraps is used
+     * as it stands.
+     */
+    private static String stripRaw(String value) {
+        if (value.startsWith("RAW(") && value.endsWith(")")) {
+            return value.substring(4, value.length() - 1);
+        }
+        if (value.startsWith("RAW{") && value.endsWith("}")) {
+            return value.substring(4, value.length() - 1);
+        }
+        return value;
+    }
+
+    private static List<String[]> parseQuery(String query) {
+        List<String[]> parameters = new ArrayList<>();
+        for (String parameter : query.split("&")) {
+            if (parameter.isEmpty()) {
+                continue;
+            }
+            int separator = parameter.indexOf('=');
+            if (separator > 0) {
+                parameters.add(new String[]{parameter.substring(0, separator), 
parameter.substring(separator + 1)});
+            }
+        }
+        return parameters;
+    }
+
+    /**
+     * Decodes the percent-encoding of a URI, so that containment is decided 
on the path the file system
+     * will see. Unlike form decoding, {@code +} is left alone: it is a valid 
character in a file name.
+     */
+    private static String decode(String value) {
+        if (value.indexOf('%') < 0) {
+            return value;
+        }
+        ByteArrayOutputStream decoded = new 
ByteArrayOutputStream(value.length());
+        for (int i = 0; i < value.length(); i++) {
+            char character = value.charAt(i);
+            if (character == '%' && i + 2 < value.length()) {
+                int high = Character.digit(value.charAt(i + 1), 16);
+                int low = Character.digit(value.charAt(i + 2), 16);
+                if (high >= 0 && low >= 0) {
+                    decoded.write((high << 4) + low);
+                    i += 2;
+                    continue;
+                }
+            }
+            decoded.write(character);
+        }
+        return new String(decoded.toByteArray(), StandardCharsets.UTF_8);
+    }
+
+    private static List<String> split(String commaSeparated) {
+        List<String> values = new ArrayList<>();
+        if (commaSeparated == null) {
+            return values;
+        }
+        for (String value : commaSeparated.split(",")) {
+            String trimmed = value.trim();
+            if (!trimmed.isEmpty()) {
+                values.add(trimmed);
+            }
+        }
+        return values;
+    }
+
+    private static boolean containsIgnoreCase(List<String> values, String 
searched) {
+        for (String value : values) {
+            if (value.equalsIgnoreCase(searched)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+}
diff --git 
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
 
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
index 4d329209d..397e50cc1 100644
--- 
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
+++ 
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
@@ -71,6 +71,8 @@ public class RouterCamelContext implements 
IRouterCamelContext {
     private Map<String, String> kafkaProps;
     private String configType;
     private String allowedEndpoints;
+    private String permittedImportBaseDirs;
+    private String permittedExportBaseDirs;
     private BundleContext bundleContext;
     private ConfigSharingService configSharingService;
 
@@ -179,6 +181,7 @@ public class RouterCamelContext implements 
IRouterCamelContext {
         
builderReader.setImportConfigurationService(importConfigurationService);
         builderReader.setJacksonDataFormat(jacksonDataFormat);
         builderReader.setAllowedEndpoints(allowedEndpoints);
+        builderReader.setPermittedImportBaseDirs(permittedImportBaseDirs);
         builderReader.setContext(camelContext);
         camelContext.addRoutes(builderReader);
 
@@ -206,6 +209,7 @@ public class RouterCamelContext implements 
IRouterCamelContext {
         
profileExportCollectRouteBuilder.setExportConfigurationList(exportConfigurationService.getAll());
         
profileExportCollectRouteBuilder.setPersistenceService(persistenceService);
         profileExportCollectRouteBuilder.setAllowedEndpoints(allowedEndpoints);
+        
profileExportCollectRouteBuilder.setPermittedExportBaseDirs(permittedExportBaseDirs);
         
profileExportCollectRouteBuilder.setJacksonDataFormat(jacksonDataFormat);
         profileExportCollectRouteBuilder.setContext(camelContext);
         camelContext.addRoutes(profileExportCollectRouteBuilder);
@@ -249,6 +253,7 @@ public class RouterCamelContext implements 
IRouterCamelContext {
             builder.setImportConfigurationService(importConfigurationService);
             builder.setProfileService(profileService);
             builder.setAllowedEndpoints(allowedEndpoints);
+            builder.setPermittedImportBaseDirs(permittedImportBaseDirs);
             builder.setJacksonDataFormat(jacksonDataFormat);
             builder.setContext(camelContext);
             camelContext.addRoutes(builder);
@@ -269,6 +274,7 @@ public class RouterCamelContext implements 
IRouterCamelContext {
             
profileExportCollectRouteBuilder.setExportConfigurationList(Collections.singletonList(exportConfiguration));
             
profileExportCollectRouteBuilder.setPersistenceService(persistenceService);
             
profileExportCollectRouteBuilder.setAllowedEndpoints(allowedEndpoints);
+            
profileExportCollectRouteBuilder.setPermittedExportBaseDirs(permittedExportBaseDirs);
             
profileExportCollectRouteBuilder.setJacksonDataFormat(jacksonDataFormat);
             profileExportCollectRouteBuilder.setContext(camelContext);
             camelContext.addRoutes(profileExportCollectRouteBuilder);
@@ -334,4 +340,12 @@ public class RouterCamelContext implements 
IRouterCamelContext {
     public void setAllowedEndpoints(String allowedEndpoints) {
         this.allowedEndpoints = allowedEndpoints;
     }
+
+    public void setPermittedImportBaseDirs(String permittedImportBaseDirs) {
+        this.permittedImportBaseDirs = permittedImportBaseDirs;
+    }
+
+    public void setPermittedExportBaseDirs(String permittedExportBaseDirs) {
+        this.permittedExportBaseDirs = permittedExportBaseDirs;
+    }
 }
diff --git 
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java
 
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java
index a9b8ec5bc..d0f075f1c 100644
--- 
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java
+++ 
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java
@@ -19,8 +19,8 @@ package org.apache.unomi.router.core.route;
 import org.apache.camel.LoggingLevel;
 import org.apache.camel.component.kafka.KafkaEndpoint;
 import org.apache.camel.model.ProcessorDefinition;
-import org.apache.commons.lang3.StringUtils;
 import org.apache.unomi.persistence.spi.PersistenceService;
+import org.apache.unomi.router.api.EndpointValidator;
 import org.apache.unomi.router.api.ExportConfiguration;
 import org.apache.unomi.router.api.RouterConstants;
 import org.apache.unomi.router.core.bean.CollectProfileBean;
@@ -62,7 +62,8 @@ public class ProfileExportCollectRouteBuilder extends 
RouterAbstractRouteBuilder
                     exportConfiguration.getProperties() != null && 
exportConfiguration.getProperties().size() > 0) {
                 if ((Map<String, String>) 
exportConfiguration.getProperties().get("mapping") != null) {
                     String destinationEndpoint = (String) 
exportConfiguration.getProperties().get("destination");
-                    if (StringUtils.isNotBlank(destinationEndpoint) && 
allowedEndpoints.contains(destinationEndpoint.substring(0, 
destinationEndpoint.indexOf(':')))) {
+                    String refusal = 
EndpointValidator.validate(destinationEndpoint, allowedEndpoints, 
permittedBaseDirs);
+                    if (refusal == null) {
                         String timerString = 
"timer://collectProfile?fixedRate=true&period=" + (String) 
exportConfiguration.getProperties().get("period");
                         if ((String) 
exportConfiguration.getProperties().get("delay") != null) {
                             timerString += "&delay=" + (String) 
exportConfiguration.getProperties().get("delay");
@@ -82,7 +83,7 @@ public class ProfileExportCollectRouteBuilder extends 
RouterAbstractRouteBuilder
                             prDef.to((String) 
getEndpointURI(RouterConstants.DIRECTION_FROM, 
RouterConstants.DIRECT_EXPORT_DEPOSIT_BUFFER));
                         }
                     } else {
-                        LOGGER.error("Endpoint scheme {} is not allowed, route 
{} will be skipped.", destinationEndpoint.substring(0, 
destinationEndpoint.indexOf(':')), exportConfiguration.getItemId());
+                        LOGGER.error("Destination endpoint is refused ({}), 
route {} will be skipped.", refusal, exportConfiguration.getItemId());
                     }
                 } else {
                     LOGGER.warn("Mapping is null in export configuration, 
route {} will be skipped!", exportConfiguration.getItemId());
diff --git 
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
 
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
index d9e41ce3a..54b9109fc 100644
--- 
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
+++ 
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
@@ -23,6 +23,7 @@ import org.apache.camel.ShutdownRunningTask;
 import org.apache.camel.component.kafka.KafkaEndpoint;
 import org.apache.camel.model.ProcessorDefinition;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.unomi.router.api.EndpointValidator;
 import org.apache.unomi.router.api.ImportConfiguration;
 import org.apache.unomi.router.api.RouterConstants;
 import org.apache.unomi.router.api.services.ImportExportConfigurationService;
@@ -92,9 +93,12 @@ public class ProfileImportFromSourceRouteBuilder extends 
RouterAbstractRouteBuil
                 
lineSplitProcessor.setProfilePropertyTypes(profileService.getTargetPropertyTypes("profiles"));
 
                 String endpoint = (String) 
importConfiguration.getProperties().get("source");
-                endpoint += "&moveFailed=.error";
+                if (StringUtils.isNotBlank(endpoint)) {
+                    endpoint += "&moveFailed=.error";
+                }
 
-                if (StringUtils.isNotBlank(endpoint) && 
allowedEndpoints.contains(endpoint.substring(0, endpoint.indexOf(':')))) {
+                String refusal = EndpointValidator.validate(endpoint, 
allowedEndpoints, permittedBaseDirs);
+                if (refusal == null) {
                     ProcessorDefinition prDef = from(endpoint)
                             .routeId(importConfiguration.getItemId())// This 
allow identification of the route for manual start/stop
                             .autoStartup(importConfiguration.isActive())// 
Auto-start if the import configuration is set active
@@ -126,7 +130,7 @@ public class ProfileImportFromSourceRouteBuilder extends 
RouterAbstractRouteBuil
                         prDef.to((String) 
getEndpointURI(RouterConstants.DIRECTION_FROM, 
RouterConstants.DIRECT_IMPORT_DEPOSIT_BUFFER));
                     }
                 } else {
-                    LOGGER.error("Endpoint scheme {} is not allowed, route {} 
will be skipped.", endpoint.substring(0, endpoint.indexOf(':')), 
importConfiguration.getItemId());
+                    LOGGER.error("Source endpoint is refused ({}), route {} 
will be skipped.", refusal, importConfiguration.getItemId());
                 }
             }
         }
diff --git 
a/extensions/router/router-core/src/main/resources/OSGI-INF/blueprint/blueprint.xml
 
b/extensions/router/router-core/src/main/resources/OSGI-INF/blueprint/blueprint.xml
index aae3abbe2..291e80e6b 100644
--- 
a/extensions/router/router-core/src/main/resources/OSGI-INF/blueprint/blueprint.xml
+++ 
b/extensions/router/router-core/src/main/resources/OSGI-INF/blueprint/blueprint.xml
@@ -26,6 +26,8 @@
         <cm:default-properties>
             <cm:property name="router.config.type" value="nobroker"/>
             <cm:property name="config.allowedEndpoints" value="file,ftp"/>
+            <cm:property name="config.import.baseDir" 
value="${karaf.data}/router/import/"/>
+            <cm:property name="config.export.baseDir" 
value="${karaf.data}/router/export/"/>
             <cm:property name="kafka.host" value="localhost"/>
             <cm:property name="kafka.port" value="9092"/>
             <cm:property name="kafka.import.topic" value="import-deposit"/>
@@ -84,6 +86,8 @@
           init-method="init" destroy-method="destroy">
         <property name="configType" value="${router.config.type}"/>
         <property name="allowedEndpoints" value="${config.allowedEndpoints}"/>
+        <property name="permittedImportBaseDirs" 
value="${config.import.baseDir}"/>
+        <property name="permittedExportBaseDirs" 
value="${config.export.baseDir}"/>
         <property name="uploadDir" value="${import.oneshot.uploadDir}"/>
         <property name="execHistorySize" value="${executionsHistory.size}"/>
         <property name="execErrReportSize" 
value="${executions.error.report.size}"/>
diff --git 
a/extensions/router/router-core/src/main/resources/org.apache.unomi.router.cfg 
b/extensions/router/router-core/src/main/resources/org.apache.unomi.router.cfg
index 7a87050c6..0f89e2d42 100644
--- 
a/extensions/router/router-core/src/main/resources/org.apache.unomi.router.cfg
+++ 
b/extensions/router/router-core/src/main/resources/org.apache.unomi.router.cfg
@@ -38,4 +38,11 @@ 
executionsHistory.size=${org.apache.unomi.router.executionsHistory.size:-5}
 
executions.error.report.size=${org.apache.unomi.router.executions.error.report.size:-200}
 
 #Allowed source endpoints
-config.allowedEndpoints=${org.apache.unomi.router.config.allowedEndpoints:-file,ftp,sftp,ftps}
\ No newline at end of file
+config.allowedEndpoints=${org.apache.unomi.router.config.allowedEndpoints:-file,ftp,sftp,ftps}
+
+#Base directories a file endpoint may resolve into, comma-separated. A 
recurrent import source or
+#export destination using the file scheme is refused unless it resolves inside 
one of them, at any
+#depth. Import and export are kept apart so that an export cannot write into a 
directory an import
+#route is polling; point them at the same directory only if that is what you 
mean.
+config.import.baseDir=${org.apache.unomi.router.config.import.baseDir:-${karaf.data}/router/import/}
+config.export.baseDir=${org.apache.unomi.router.config.export.baseDir:-${karaf.data}/router/export/}
\ No newline at end of file
diff --git 
a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
 
b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
index a771e6439..e10d3b0c6 100644
--- 
a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
+++ 
b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
@@ -185,6 +185,39 @@ public class FileEndpointContainmentTest {
         assertRouteRefused("move-escape", "move carries a path and must be 
contained too");
     }
 
+    @Test
+    public void 
importRouteIsRefusedWhenMoveFailedOptionEscapesPermittedBaseDir() throws 
Exception {
+        addImportRoutes(recurrentImport("movefailed-escape",
+                fileUri(permittedImportDir, 
"?fileName=profiles.csv&moveFailed=../" + arbitraryDir.getName())));
+
+        assertRouteRefused("movefailed-escape",
+                "moveFailed carries a path, and the route builder appends one 
of its own — every occurrence must be contained");
+    }
+
+    @Test
+    public void importRouteIsRefusedWhenPreMoveOptionEscapesPermittedBaseDir() 
throws Exception {
+        addImportRoutes(recurrentImport("premove-escape",
+                fileUri(permittedImportDir, 
"?fileName=profiles.csv&preMove=../" + arbitraryDir.getName())));
+
+        assertRouteRefused("premove-escape", "preMove carries a path and must 
be contained too");
+    }
+
+    @Test
+    public void 
importRouteIsRefusedWhenDoneFileNameOptionEscapesPermittedBaseDir() throws 
Exception {
+        addImportRoutes(recurrentImport("donefilename-escape",
+                fileUri(permittedImportDir, 
"?fileName=profiles.csv&doneFileName=../" + arbitraryDir.getName() + "/done")));
+
+        assertRouteRefused("donefilename-escape", "doneFileName carries a path 
and must be contained too");
+    }
+
+    @Test
+    public void importRouteIsRefusedWhenPathBearingOptionIsWrappedInRaw() 
throws Exception {
+        addImportRoutes(recurrentImport("raw-escape",
+                fileUri(permittedImportDir, "?fileName=RAW(../" + 
arbitraryDir.getName() + "/profiles.csv)")));
+
+        assertRouteRefused("raw-escape", "RAW() only tells Camel not to decode 
the value — the path it carries is used as-is");
+    }
+
     @Test
     public void 
importRouteIsBuiltWhenMoveOptionIsRelativeAndStaysInsidePermittedBaseDir() 
throws Exception {
         addImportRoutes(recurrentImport("relative-move",
@@ -268,6 +301,22 @@ public class FileEndpointContainmentTest {
         assertRouteRefused("filename-escape", "fileName carries a path and 
must be contained too");
     }
 
+    @Test
+    public void 
exportRouteIsRefusedWhenTempFileNameOptionEscapesPermittedBaseDir() throws 
Exception {
+        addExportRoutes(recurrentExport("tempfilename-escape",
+                fileUri(permittedExportDir, 
"?fileName=profiles.csv&tempFileName=../" + arbitraryDir.getName() + 
"/profiles.tmp")));
+
+        assertRouteRefused("tempfilename-escape", "tempFileName carries a path 
and must be contained too");
+    }
+
+    @Test
+    public void 
exportRouteIsRefusedWhenDoneFileNameOptionEscapesPermittedBaseDir() throws 
Exception {
+        addExportRoutes(recurrentExport("donefilename-escape",
+                fileUri(permittedExportDir, 
"?fileName=profiles.csv&doneFileName=../" + arbitraryDir.getName() + "/done")));
+
+        assertRouteRefused("donefilename-escape", "doneFileName carries a path 
and must be contained too");
+    }
+
     @Test
     public void remoteExportEndpointIsNotSubjectToDirectoryContainment() 
throws Exception {
         addExportRoutes(recurrentExport("remote", 
"ftp://ftp.example.com/profiles?fileName=profiles.csv";));
diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileExportIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ProfileExportIT.java
index 2b52d53d1..e767a73ba 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProfileExportIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProfileExportIT.java
@@ -99,13 +99,13 @@ public class ProfileExportIT extends BaseIT {
         exportConfiguration.getProperties().put("mapping", mapping);
         exportConfiguration.getProperties().put("segment", "exportItSeg");
         exportConfiguration.getProperties().put("period", "1m");
-        File exportDir = new File("data/tmp/");
+        File exportDir = new File("data/tmp/recurrent_export/");
         exportConfiguration.getProperties().put("destination", "file://" + 
exportDir.getAbsolutePath() + "?fileName=profiles-export.csv");
         exportConfiguration.setActive(true);
 
         exportConfigurationService.save(exportConfiguration, true);
 
-        final File exportResult = new File("data/tmp/profiles-export.csv");
+        final File exportResult = new 
File("data/tmp/recurrent_export/profiles-export.csv");
         keepTrying("Failed waiting for export file to be created", () -> 
exportResult, File::exists, 1000, 100);
 
         logger.info("PATH : {}", exportResult.getAbsolutePath());
diff --git a/itests/src/test/resources/org.apache.unomi.router.cfg 
b/itests/src/test/resources/org.apache.unomi.router.cfg
index 6d5f985bb..d0e5c3753 100644
--- a/itests/src/test/resources/org.apache.unomi.router.cfg
+++ b/itests/src/test/resources/org.apache.unomi.router.cfg
@@ -40,4 +40,8 @@ executionsHistory.size=5
 executions.error.report.size=200
 
 #Allowed source endpoints
-config.allowedEndpoints=file,ftp,sftp,ftps
\ No newline at end of file
+config.allowedEndpoints=file,ftp,sftp,ftps
+
+#Base directories a file endpoint may resolve into
+config.import.baseDir=${karaf.data}/tmp/recurrent_import
+config.export.baseDir=${karaf.data}/tmp/recurrent_export

Reply via email to