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 173970ea3184fafa87fc90824678bed01265f515 Author: Jérôme Blanchard <[email protected]> AuthorDate: Tue Aug 11 15:03:33 2026 +0200 UNOMI-973: Add route-construction tests for file endpoint containment A recurrent import configuration names a source, and a recurrent export configuration names a destination; both are used as Camel endpoint URIs. When the scheme is file, the URI must resolve inside a base directory the deployment permits -- the directory it names, and every path-bearing option it carries (fileName, move, moveFailed, preMove, doneFileName, include). FileEndpointContainmentTest covers route construction for both directions: in-bounds sources and destinations keep building routes, at any depth under the base directory and whether or not the directory exists yet; out-of-bounds ones build none. Remote schemes carry no local path and stay unaffected. Carry the permitted base directories into the route builders, separately for each direction, so a route builder cannot be wired with the wrong list. They are not read yet, so the containment tests fail. Two more fail on their own ground: a source without a scheme, and a blank destination, each raise StringIndexOutOfBoundsException out of configure() and cost the deployment the other routes of the same batch. --- extensions/router/router-core/pom.xml | 7 + .../route/ProfileExportCollectRouteBuilder.java | 9 + .../route/ProfileImportFromSourceRouteBuilder.java | 9 + .../core/route/RouterAbstractRouteBuilder.java | 1 + .../core/route/FileEndpointContainmentTest.java | 373 +++++++++++++++++++++ 5 files changed, 399 insertions(+) diff --git a/extensions/router/router-core/pom.xml b/extensions/router/router-core/pom.xml index abaecd038..efb1f5fed 100644 --- a/extensions/router/router-core/pom.xml +++ b/extensions/router/router-core/pom.xml @@ -148,6 +148,13 @@ <artifactId>slf4j-api</artifactId> <scope>provided</scope> </dependency> + + <!-- tests --> + <dependency> + <groupId>junit</groupId> + <artifactId>junit</artifactId> + <scope>test</scope> + </dependency> </dependencies> <build> 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 5529c109b..a9b8ec5bc 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 @@ -93,6 +93,15 @@ public class ProfileExportCollectRouteBuilder extends RouterAbstractRouteBuilder } } + /** + * Sets the comma-separated list of base directories an export {@code file} endpoint may resolve into. + * + * @param permittedExportBaseDirs the permitted base directories + */ + public void setPermittedExportBaseDirs(String permittedExportBaseDirs) { + this.permittedBaseDirs = permittedExportBaseDirs; + } + public void setExportConfigurationList(List<ExportConfiguration> exportConfigurationList) { this.exportConfigurationList = exportConfigurationList; } 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 1ebc5c388..d9e41ce3a 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 @@ -132,6 +132,15 @@ public class ProfileImportFromSourceRouteBuilder extends RouterAbstractRouteBuil } } + /** + * Sets the comma-separated list of base directories an import {@code file} endpoint may resolve into. + * + * @param permittedImportBaseDirs the permitted base directories + */ + public void setPermittedImportBaseDirs(String permittedImportBaseDirs) { + this.permittedBaseDirs = permittedImportBaseDirs; + } + public void setImportConfigurationList(List<ImportConfiguration> importConfigurationList) { this.importConfigurationList = importConfigurationList; } diff --git a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/RouterAbstractRouteBuilder.java b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/RouterAbstractRouteBuilder.java index ad06a00ec..00ae70204 100644 --- a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/RouterAbstractRouteBuilder.java +++ b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/RouterAbstractRouteBuilder.java @@ -45,6 +45,7 @@ public abstract class RouterAbstractRouteBuilder extends RouteBuilder { protected String configType; protected String allowedEndpoints; + protected String permittedBaseDirs; protected ProfileService profileService; 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 new file mode 100644 index 000000000..a771e6439 --- /dev/null +++ b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java @@ -0,0 +1,373 @@ +/* + * 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.core.route; + +import org.apache.camel.component.jackson.JacksonDataFormat; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.unomi.api.services.ProfileService; +import org.apache.unomi.router.api.ExportConfiguration; +import org.apache.unomi.router.api.ImportConfiguration; +import org.apache.unomi.router.api.ProfileToImport; +import org.apache.unomi.router.api.RouterConstants; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * A recurrent import configuration names a {@code source}, and a recurrent export configuration + * names a {@code destination}. Both are used as Apache Camel endpoint URIs. When the URI uses the + * {@code file} scheme, it must resolve <em>inside</em> one of the base directories the deployment + * permits: a {@code file} endpoint that resolves anywhere else is refused, and its route is not + * built. + * + * <p>Containment covers the directory named by the URI <em>and</em> every path-bearing option it + * carries ({@code fileName}, {@code move}, {@code moveFailed}, {@code preMove}, {@code doneFileName}, + * {@code include}, ...) — validating only the directory part would leave + * {@code file:///permitted/?fileName=../../elsewhere} open. + * + * <p>Remote schemes ({@code ftp}, {@code sftp}, {@code ftps}) carry no local path and are not + * subject to directory containment; the scheme allow-list keeps governing them. + * + * <p>These tests exercise route <em>construction</em>, which is where a configuration is turned into + * a live route: a configuration whose endpoint is refused must leave no route behind, whether it + * arrives through the REST API or was already persisted before the deployment was configured. + */ +public class FileEndpointContainmentTest { + + /** The shipped default. The containment rules must hold while {@code file} is an allowed scheme. */ + private static final String DEFAULT_ALLOWED_ENDPOINTS = "file,ftp,sftp,ftps"; + + private static final Map<String, String> NO_KAFKA = new HashMap<>(); + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private DefaultCamelContext camelContext; + + /** The base directory recurrent imports are confined to. */ + private File permittedImportDir; + + /** The base directory recurrent exports are confined to. */ + private File permittedExportDir; + + /** A directory the deployment never permitted — the "arbitrary directory" of the defect. */ + private File arbitraryDir; + + /** A sibling whose path shares a textual prefix with the permitted import directory. */ + private File permittedImportDirLookalike; + + @Before + public void setUp() throws Exception { + permittedImportDir = tmp.newFolder("permitted-import"); + permittedExportDir = tmp.newFolder("permitted-export"); + arbitraryDir = tmp.newFolder("arbitrary"); + permittedImportDirLookalike = tmp.newFolder("permitted-import-evil"); + camelContext = new DefaultCamelContext(); + } + + @After + public void tearDown() throws Exception { + camelContext.stop(); + } + + // --------------------------------------------------------------------------------------------- + // Import — the configured source is read by Unomi + // --------------------------------------------------------------------------------------------- + + @Test + public void importRouteIsBuiltWhenSourceIsInsidePermittedBaseDir() throws Exception { + addImportRoutes(recurrentImport("in-bounds", fileUri(permittedImportDir, "?fileName=profiles.csv"))); + + assertRouteBuilt("in-bounds", "a source inside the permitted base directory is legitimate"); + } + + @Test + public void importRouteIsBuiltWhenSourceIsInAnExistingSubdirectoryOfPermittedBaseDir() throws Exception { + File dropDir = new File(permittedImportDir, "incoming"); + assertTrue("could not prepare the test fixture", dropDir.mkdir()); + + addImportRoutes(recurrentImport("subdirectory", fileUri(dropDir, "?fileName=profiles.csv"))); + + assertRouteBuilt("subdirectory", "containment is recursive — a source at any depth under the base directory is legitimate"); + } + + @Test + public void importRouteIsRefusedWhenSourceIsASymlinkPointingOutsidePermittedBaseDir() throws Exception { + File link = new File(permittedImportDir, "elsewhere"); + try { + Files.createSymbolicLink(link.toPath(), arbitraryDir.toPath()); + } catch (IOException | UnsupportedOperationException e) { + Assume.assumeNoException("this file system does not support symbolic links", e); + } + + addImportRoutes(recurrentImport("symlink", fileUri(link, "?fileName=profiles.csv"))); + + assertRouteRefused("symlink", "the source leaves the permitted base directory once symbolic links are resolved"); + } + + @Test + public void importRouteIsRefusedWhenSourceIsOutsidePermittedBaseDir() throws Exception { + addImportRoutes(recurrentImport("arbitrary-dir", fileUri(arbitraryDir, "?fileName=profiles.csv"))); + + assertRouteRefused("arbitrary-dir", "the source directory is not one the deployment permits"); + } + + @Test + public void importRouteIsRefusedWhenSourceEscapesPermittedBaseDirWithParentSegments() throws Exception { + String escaping = fileUri(permittedImportDir, "/../" + arbitraryDir.getName() + "?fileName=profiles.csv"); + + addImportRoutes(recurrentImport("dot-dot", escaping)); + + assertRouteRefused("dot-dot", "the source resolves outside the permitted base directory once normalized"); + } + + @Test + public void importRouteIsRefusedWhenSourceEscapesPermittedBaseDirWithEncodedParentSegments() throws Exception { + String escaping = fileUri(permittedImportDir, "/%2e%2e/" + arbitraryDir.getName() + "?fileName=profiles.csv"); + + addImportRoutes(recurrentImport("encoded-dot-dot", escaping)); + + assertRouteRefused("encoded-dot-dot", "percent-encoded parent segments must be decoded before containment is decided"); + } + + @Test + public void importRouteIsRefusedWhenSourceDirectoryOnlySharesAPrefixWithPermittedBaseDir() throws Exception { + addImportRoutes(recurrentImport("lookalike", fileUri(permittedImportDirLookalike, "?fileName=profiles.csv"))); + + assertRouteRefused("lookalike", "containment compares path components, not string prefixes"); + } + + @Test + public void importRouteIsRefusedWhenFileNameOptionEscapesPermittedBaseDir() throws Exception { + addImportRoutes(recurrentImport("filename-escape", + fileUri(permittedImportDir, "?fileName=../" + arbitraryDir.getName() + "/profiles.csv"))); + + assertRouteRefused("filename-escape", "fileName carries a path and must be contained too"); + } + + @Test + public void importRouteIsRefusedWhenMoveOptionEscapesPermittedBaseDir() throws Exception { + addImportRoutes(recurrentImport("move-escape", + fileUri(permittedImportDir, "?fileName=profiles.csv&move=../" + arbitraryDir.getName()))); + + assertRouteRefused("move-escape", "move carries a path and must be contained too"); + } + + @Test + public void importRouteIsBuiltWhenMoveOptionIsRelativeAndStaysInsidePermittedBaseDir() throws Exception { + addImportRoutes(recurrentImport("relative-move", + fileUri(permittedImportDir, "?fileName=profiles.csv&move=.done"))); + + assertRouteBuilt("relative-move", "a relative move target inside the base directory is how the feature is normally used"); + } + + @Test + public void remoteImportEndpointIsNotSubjectToDirectoryContainment() throws Exception { + addImportRoutes(recurrentImport("remote", "ftp://ftp.example.com/profiles?fileName=profiles.csv")); + + assertRouteBuilt("remote", "ftp is an allowed scheme and carries no local path"); + } + + @Test + public void importRouteIsRefusedWhenSchemeIsNotAllowed() throws Exception { + addImportRoutes("ftp,sftp,ftps", recurrentImport("scheme-denied", fileUri(permittedImportDir, "?fileName=profiles.csv"))); + + assertRouteRefused("scheme-denied", "file is not in the configured scheme allow-list"); + } + + @Test + public void importRouteIsRefusedWhenSchemeIsOnlyASubstringOfAnAllowedScheme() throws Exception { + addImportRoutes(recurrentImport("substring-scheme", "fil://" + permittedImportDir.getAbsolutePath() + "?fileName=profiles.csv")); + + assertRouteRefused("substring-scheme", "the allow-list is a set of schemes, not a string to search"); + } + + @Test + public void malformedSourceIsSkippedWithoutPreventingTheOtherRoutesFromBeingBuilt() throws Exception { + addImportRoutes( + recurrentImport("no-scheme", permittedImportDir.getAbsolutePath() + "/profiles.csv"), + recurrentImport("well-formed", fileUri(permittedImportDir, "?fileName=profiles.csv"))); + + assertRouteRefused("no-scheme", "an endpoint without a scheme cannot be honoured"); + assertRouteBuilt("well-formed", "one malformed configuration must not cost the deployment its other routes"); + } + + // --------------------------------------------------------------------------------------------- + // Export — the configured destination is written by Unomi + // --------------------------------------------------------------------------------------------- + + @Test + public void exportRouteIsBuiltWhenDestinationIsInsidePermittedBaseDir() throws Exception { + addExportRoutes(recurrentExport("in-bounds", fileUri(permittedExportDir, "?fileName=profiles.csv"))); + + assertRouteBuilt("in-bounds", "a destination inside the permitted base directory is legitimate"); + } + + @Test + public void exportRouteIsBuiltWhenDestinationIsInASubdirectoryThatDoesNotExistYet() throws Exception { + File notCreatedYet = new File(permittedExportDir, "2026-08"); + + addExportRoutes(recurrentExport("subdirectory", fileUri(notCreatedYet, "?fileName=profiles.csv"))); + + assertRouteBuilt("subdirectory", "an export destination is created on first write — containment must not require the directory to exist"); + } + + @Test + public void exportRouteIsRefusedWhenDestinationIsOutsidePermittedBaseDir() throws Exception { + addExportRoutes(recurrentExport("arbitrary-dir", fileUri(arbitraryDir, "?fileName=profiles.csv"))); + + assertRouteRefused("arbitrary-dir", "the destination directory is not one the deployment permits"); + } + + @Test + public void exportRouteIsRefusedWhenDestinationEscapesPermittedBaseDirWithParentSegments() throws Exception { + String escaping = fileUri(permittedExportDir, "/../" + arbitraryDir.getName() + "?fileName=profiles.csv"); + + addExportRoutes(recurrentExport("dot-dot", escaping)); + + assertRouteRefused("dot-dot", "the destination resolves outside the permitted base directory once normalized"); + } + + @Test + public void exportRouteIsRefusedWhenFileNameOptionEscapesPermittedBaseDir() throws Exception { + addExportRoutes(recurrentExport("filename-escape", + fileUri(permittedExportDir, "?fileName=../" + arbitraryDir.getName() + "/profiles.csv"))); + + assertRouteRefused("filename-escape", "fileName 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")); + + assertRouteBuilt("remote", "ftp is an allowed scheme and carries no local path"); + } + + @Test + public void malformedDestinationIsSkippedWithoutPreventingTheOtherRoutesFromBeingBuilt() throws Exception { + addExportRoutes( + recurrentExport("blank", ""), + recurrentExport("well-formed", fileUri(permittedExportDir, "?fileName=profiles.csv"))); + + assertRouteRefused("blank", "a blank destination cannot be honoured"); + assertRouteBuilt("well-formed", "one malformed configuration must not cost the deployment its other routes"); + } + + // --------------------------------------------------------------------------------------------- + // Fixtures + // --------------------------------------------------------------------------------------------- + + private void assertRouteBuilt(String routeId, String why) { + assertNotNull("no route was built for configuration '" + routeId + "', although " + why, + camelContext.getRouteDefinition(routeId)); + } + + private void assertRouteRefused(String routeId, String why) { + assertNull("a route was built for configuration '" + routeId + "', although " + why, + camelContext.getRouteDefinition(routeId)); + } + + private String fileUri(File directory, String suffix) { + return "file://" + directory.getAbsolutePath() + suffix; + } + + private ImportConfiguration recurrentImport(String itemId, String source) { + ImportConfiguration configuration = new ImportConfiguration(); + configuration.setItemId(itemId); + configuration.setConfigType(RouterConstants.IMPORT_EXPORT_CONFIG_TYPE_RECURRENT); + configuration.setActive(true); + configuration.getProperties().put("source", source); + configuration.getProperties().put("mapping", Collections.singletonMap("0", 0)); + return configuration; + } + + private ExportConfiguration recurrentExport(String itemId, String destination) { + ExportConfiguration configuration = new ExportConfiguration(); + configuration.setItemId(itemId); + configuration.setConfigType(RouterConstants.IMPORT_EXPORT_CONFIG_TYPE_RECURRENT); + configuration.setActive(true); + configuration.getProperties().put("destination", destination); + configuration.getProperties().put("mapping", Collections.singletonMap("0", "firstName")); + configuration.getProperties().put("segment", "exportSegment"); + configuration.getProperties().put("period", "1m"); + return configuration; + } + + private void addImportRoutes(ImportConfiguration... configurations) throws Exception { + addImportRoutes(DEFAULT_ALLOWED_ENDPOINTS, configurations); + } + + private void addImportRoutes(String allowedEndpoints, ImportConfiguration... configurations) throws Exception { + ProfileImportFromSourceRouteBuilder builder = + new ProfileImportFromSourceRouteBuilder(NO_KAFKA, RouterConstants.CONFIG_TYPE_NOBROKER); + builder.setImportConfigurationList(Arrays.asList(configurations)); + builder.setProfileService(noOpProfileService()); + builder.setJacksonDataFormat(new JacksonDataFormat(ProfileToImport.class)); + builder.setAllowedEndpoints(allowedEndpoints); + builder.setPermittedImportBaseDirs(permittedImportDir.getAbsolutePath()); + builder.setContext(camelContext); + camelContext.addRoutes(builder); + } + + private void addExportRoutes(ExportConfiguration... configurations) throws Exception { + ProfileExportCollectRouteBuilder builder = + new ProfileExportCollectRouteBuilder(NO_KAFKA, RouterConstants.CONFIG_TYPE_NOBROKER); + builder.setExportConfigurationList(Arrays.asList(configurations)); + builder.setJacksonDataFormat(new JacksonDataFormat(ProfileToImport.class)); + builder.setAllowedEndpoints(DEFAULT_ALLOWED_ENDPOINTS); + builder.setPermittedExportBaseDirs(permittedExportDir.getAbsolutePath()); + builder.setContext(camelContext); + camelContext.addRoutes(builder); + } + + /** + * The route builders ask the profile service for the profile property types while they build. + * Nothing in these tests depends on what it answers. + */ + private static ProfileService noOpProfileService() { + return (ProfileService) Proxy.newProxyInstance( + ProfileService.class.getClassLoader(), + new Class<?>[]{ProfileService.class}, + (proxy, method, args) -> { + if (Collection.class.isAssignableFrom(method.getReturnType())) { + return Collections.emptyList(); + } + if (List.class.isAssignableFrom(method.getReturnType())) { + return Collections.emptyList(); + } + return null; + }); + } +}
