Copilot commented on code in PR #849:
URL: https://github.com/apache/unomi/pull/849#discussion_r3785025095


##########
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";
+            }
+        }

Review Comment:
   Camel decodes query-parameter names before binding endpoint options, but 
this check compares the raw name. An encoded key such as 
`file%4Eame=../../outside` is therefore ignored here and later interpreted by 
Camel as `fileName`, bypassing containment. Decode the option name before 
matching it.



##########
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)) {

Review Comment:
   Path-bearing file options support Camel File Language expressions, but 
containment is checked against the unevaluated placeholder. For example, 
`move=${file:parent}/../outside` is normalized here as a child of `directory` 
and accepted; for a file directly in the endpoint directory, Camel can expand 
it to the sibling `outside` directory at runtime. Dynamic path expressions need 
to be rejected/restricted to safe tokens or evaluated with equivalent Camel 
semantics before approval.



##########
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);

Review Comment:
   Decoded file paths can cause `Paths.get`/`Path.resolve` to throw 
`InvalidPathException` (for example, an endpoint containing `%00`). Because 
this escapes `validate`, REST returns 500 and route construction aborts the 
rest of the batch—the malformed-endpoint failure mode this change is intended 
to prevent. Convert invalid paths into a validation refusal.



##########
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")));

Review Comment:
   `include` and `antInclude` are file-selection patterns, and `antFilter` is a 
filter reference—not output/input paths that Camel resolves. Passing these 
values through `Path.resolve` can reject legitimate regular expressions or 
filter references even though they cannot redirect file access outside the 
endpoint directory. Keep containment checks to options that Camel actually 
resolves as paths.



##########
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;

Review Comment:
   Falling back to the lexical path makes symlink resolution fail open. A 
dangling symlink inside a permitted base causes `toRealPath()` to throw, is 
accepted as in-bounds here, and can later direct Camel outside the base once 
its external target is created. Treat failure to resolve an existing path 
component as a validation refusal rather than returning `normalized`.



##########
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);

Review Comment:
   When a value contains any percent escape, every unescaped character is 
written as a single byte. This corrupts non-ASCII characters (for example, 
`café%20files` becomes a path containing the replacement character), so 
legitimate Unicode file endpoints can be refused or compared against the wrong 
path. Preserve unescaped characters as UTF-8 while decoding only `%HH` byte 
sequences.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to