davsclaus commented on code in PR #26737:
URL: https://github.com/apache/camel/pull/26737#discussion_r4071659731


##########
components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java:
##########
@@ -2277,17 +2278,41 @@ private void addLayoutArguments(List<String> command) {
         }
     }
 
-    private void addOutputDirectoryArguments(List<String> command, Exchange 
exchange, String outputDirectory) {
+    private void addOutputDirectoryArguments(List<String> command, Exchange 
exchange, String outputDirectory)
+            throws IOException {
         String outputPath = 
exchange.getIn().getHeader(DoclingHeaders.OUTPUT_FILE_PATH, String.class);
+        command.add("--output");
         if (outputPath != null) {
-            command.add("--output");
-            command.add(outputPath);
+            // the header is caller-provided, so it gets the same 
normalization and optional base-directory
+            // containment as input paths do, instead of reaching the CLI 
verbatim
+            
command.add(resolveWithinOutputBaseDirectory(outputPath).toString());
         } else {
-            command.add("--output");
             command.add(outputDirectory);
         }
     }
 
+    /**
+     * Normalizes the given output directory and, when {@code 
outputBaseDirectory} is configured, verifies that it stays
+     * inside that directory. Mirrors {@link 
#resolveWithinInputBaseDirectory(String)} so that the output directory
+     * carried by the {@link DoclingHeaders#OUTPUT_FILE_PATH} header receives 
the same treatment as input paths.
+     */
+    private Path resolveWithinOutputBaseDirectory(String outputPath) throws 
IOException {
+        String base = configuration.getOutputBaseDirectory();
+        if (base == null || base.isEmpty()) {
+            // no directory restriction: normalize lexically only, and leave 
relative paths relative so that they keep
+            // resolving the way they did before - against the CLI working 
directory, when one is set
+            return Paths.get(outputPath).normalize();
+        }
+        Path baseDir = Paths.get(base).toAbsolutePath().normalize();
+        // resolve relative paths against the base directory itself, so that 
the path checked here is exactly the path
+        // used downstream regardless of the process working directory; an 
absolute header value that escapes is rejected
+        Path path = baseDir.resolve(Paths.get(outputPath)).normalize();
+        if (!path.startsWith(baseDir)) {

Review Comment:
   🟡 `normalize()` is lexical, so this containment check does not resolve 
symlinks: a symlink inside `outputBaseDirectory` pointing outside it passes.
   
   That matches `resolveWithinInputBaseDirectory` exactly, so consistency — the 
stated goal of the PR — is preserved, and switching to `toRealPath()` would 
change semantics for paths that don't exist yet, which is the normal case for 
an output directory. So I'm not suggesting a code change; just a sentence in 
the `outputBaseDirectory` javadoc and/or the option description, so operators 
don't read the option as a stronger boundary than it is.



##########
components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java:
##########
@@ -2277,17 +2278,41 @@ private void addLayoutArguments(List<String> command) {
         }
     }
 
-    private void addOutputDirectoryArguments(List<String> command, Exchange 
exchange, String outputDirectory) {
+    private void addOutputDirectoryArguments(List<String> command, Exchange 
exchange, String outputDirectory)
+            throws IOException {
         String outputPath = 
exchange.getIn().getHeader(DoclingHeaders.OUTPUT_FILE_PATH, String.class);
+        command.add("--output");
         if (outputPath != null) {
-            command.add("--output");
-            command.add(outputPath);
+            // the header is caller-provided, so it gets the same 
normalization and optional base-directory
+            // containment as input paths do, instead of reaching the CLI 
verbatim
+            
command.add(resolveWithinOutputBaseDirectory(outputPath).toString());
         } else {
-            command.add("--output");
             command.add(outputDirectory);
         }
     }
 
+    /**
+     * Normalizes the given output directory and, when {@code 
outputBaseDirectory} is configured, verifies that it stays
+     * inside that directory. Mirrors {@link 
#resolveWithinInputBaseDirectory(String)} so that the output directory
+     * carried by the {@link DoclingHeaders#OUTPUT_FILE_PATH} header receives 
the same treatment as input paths.
+     */
+    private Path resolveWithinOutputBaseDirectory(String outputPath) throws 
IOException {
+        String base = configuration.getOutputBaseDirectory();
+        if (base == null || base.isEmpty()) {
+            // no directory restriction: normalize lexically only, and leave 
relative paths relative so that they keep
+            // resolving the way they did before - against the CLI working 
directory, when one is set
+            return Paths.get(outputPath).normalize();

Review Comment:
   🟠 This is the branch the upgrade guide leads with — "the header is now 
normalized lexically (redundant separators and `.`/`..` segments are resolved) 
before it is passed to Docling" — and it is the case most existing routes will 
hit, since `outputBaseDirectory` is unset by default. But no test asserts what 
actually reaches `--output`.
   
   The two "accepted" tests only assert 
`hasMessageNotContaining("outputBaseDirectory")`, which passes for any failure 
that happens not to mention the option — including a failure caused by 
normalization going wrong. A test that captures the built command and checks, 
say, `out/./sub/../x` → `out/x` would pin the documented behaviour down.



##########
components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingOutputPathValidationTest.java:
##########
@@ -0,0 +1,151 @@
+/*
+ * 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.camel.component.docling;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests that the {@code CamelDoclingOutputFilePath} header is normalized and, 
when {@code outputBaseDirectory} is
+ * configured, confined to that directory before it reaches the docling CLI 
{@code --output} flag, consistently with how
+ * input paths honour {@code inputBaseDirectory}.
+ */
+class DoclingOutputPathValidationTest extends CamelTestSupport {
+
+    private static final String CONTENT = "just some document text";
+
+    @TempDir
+    Path tempDir;
+
+    // ------------------------------------------------------- no 
outputBaseDirectory
+
+    @Test
+    void outputPathWithoutBaseDirectoryIsAllowed() {
+        // no restriction is configured: the header is normalized only and 
passes through, so the run fails later on the
+        // absent docling binary rather than on a containment check
+        assertThatThrownBy(() -> 
template.requestBodyAndHeader("direct:default", CONTENT,
+                DoclingHeaders.OUTPUT_FILE_PATH, 
tempDir.resolve("out").toString()))
+                .isInstanceOf(CamelExecutionException.class)
+                .cause()
+                .hasMessageNotContaining("outputBaseDirectory");
+    }
+
+    // ------------------------------------------------------ 
outputBaseDirectory jail
+
+    @Test
+    void outputPathInsideOutputBaseDirectoryIsAccepted() throws Exception {
+        String inside = baseDir().resolve("out").toString();
+
+        // the docling binary is absent so execution still fails, but it must 
not fail on the jail check
+        assertThatThrownBy(() -> 
template.requestBodyAndHeader("direct:jailed", CONTENT,
+                DoclingHeaders.OUTPUT_FILE_PATH, inside))
+                .isInstanceOf(CamelExecutionException.class)
+                .cause()
+                .hasMessageNotContaining("outputBaseDirectory");
+    }
+
+    @Test
+    void outputPathOutsideOutputBaseDirectoryIsRejected() throws Exception {
+        baseDir();
+        String outside = tempDir.resolve("outside").toString();
+
+        assertThatThrownBy(() -> 
template.requestBodyAndHeader("direct:jailed", CONTENT,
+                DoclingHeaders.OUTPUT_FILE_PATH, outside))
+                .isInstanceOf(CamelExecutionException.class)
+                .cause()
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("outputBaseDirectory");
+    }
+
+    @Test
+    void absoluteOutputPathOutsideOutputBaseDirectoryIsRejected() throws 
Exception {
+        baseDir();
+
+        // an absolute header value ignores the base directory when resolved, 
so it must be rejected as escaping
+        assertThatThrownBy(() -> 
template.requestBodyAndHeader("direct:jailed", CONTENT,
+                DoclingHeaders.OUTPUT_FILE_PATH, "/var/www/html/uploads"))
+                .isInstanceOf(CamelExecutionException.class)
+                .cause()
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("outputBaseDirectory");
+    }
+
+    @Test
+    void traversalOutOfOutputBaseDirectoryIsRejected() throws Exception {

Review Comment:
   🟡 `baseDir().resolve("..").resolve("outside")` is already an absolute path 
(`@TempDir` hands out absolute paths), so this exercises the same branch as 
`absoluteOutputPathOutsideOutputBaseDirectoryIsRejected` above rather than the 
relative-traversal one it's named for.
   
   A genuinely relative value — `"../outside"` — would cover the 
`baseDir.resolve(...)` + `normalize()` path that nothing else reaches.



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