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
