This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch backport/26737-to-camel-4.18.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit 9f146ad2dd2c08710c319e6193e6386782a9782f Author: Andrea Cosentino <[email protected]> AuthorDate: Fri Sep 25 11:01:26 2026 +0200 CAMEL-24894: camel-docling - make output path handling consistent with input path and custom-argument handling (#26737) * CAMEL-24894: camel-docling - make output path handling consistent with input path and custom-argument handling The CamelDoclingOutputFilePath header was passed straight to the docling CLI --output flag, while input file paths are normalized and confined to inputBaseDirectory when set, and custom-argument values are normalized via validatePathSafety. Normalize the output header value and add an optional outputBaseDirectory option mirroring inputBaseDirectory, so the output directory can be confined the same way. Both remain unset by default, preserving the previous behavior. Adds DoclingOutputPathValidationTest and documents the option in the component docs and the 4.23 upgrade guide. Co-authored-by: Claude Opus 4.8 <[email protected]> Signed-off-by: Andrea Cosentino <[email protected]> * Regen * CAMEL-24894: camel-docling - clarify that output path normalization is unconditional in docs Review feedback: the upgrade guide and component doc implied the CamelDoclingOutputFilePath value is unchanged when outputBaseDirectory is unset. Normalization actually applies unconditionally; only the base-directory containment is gated behind the option. Reword both to state that. Co-authored-by: Claude Opus 4.8 <[email protected]> Signed-off-by: Andrea Cosentino <[email protected]> * CAMEL-24894: camel-docling - strengthen output path test and document the lexical containment boundary Address review feedback: - assert the value that actually reaches --output via a command-capture test (out/./sub/../x -> out/x), instead of only checking the failure message - use a genuinely relative "../outside" in the traversal test; @TempDir hands out absolute paths, so the old value exercised the absolute-path branch - document that the outputBaseDirectory containment is lexical and does not resolve symbolic links (matching inputBaseDirectory) in the option description, the helper javadoc and the component docs Co-authored-by: Claude Opus 4.8 <[email protected]> Signed-off-by: Andrea Cosentino <[email protected]> * CAMEL-24894: camel-docling - drop reflection from the output path test helper Address review nits (non-blocking): - make buildDoclingCommand package-private (visible for testing) and call it directly, so a signature change becomes a compile error rather than a runtime NoSuchMethodException on the normalization test - comment the bare baseDir() calls that exist for their directory-creation side effect Co-authored-by: Claude Opus 4.8 <[email protected]> Signed-off-by: Andrea Cosentino <[email protected]> --------- Signed-off-by: Andrea Cosentino <[email protected]> Co-authored-by: Claude Opus 4.8 <[email protected]> Co-authored-by: Guillaume Nodet <[email protected]> (cherry picked from commit 48485d408f190c46ef408ba4ba75e45b4e8ffb04) [backport camel-4.18.x] Upgrade note moved to the 4.18.4 -> 4.18.5 section of the 4.18 upgrade guide, generated sources regenerated, test uses camel-test-junit5. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../apache/camel/catalog/components/docling.json | 6 +- .../camel/catalog/docs/docling-component.adoc | 13 +- .../docling/DoclingComponentConfigurer.java | 6 + .../docling/DoclingConfigurationConfigurer.java | 6 + .../docling/DoclingEndpointConfigurer.java | 6 + .../docling/DoclingEndpointUriFactory.java | 3 +- .../apache/camel/component/docling/docling.json | 6 +- .../src/main/docs/docling-component.adoc | 13 +- .../component/docling/DoclingConfiguration.java | 16 ++ .../camel/component/docling/DoclingProducer.java | 39 ++++- .../docling/DoclingOutputPathValidationTest.java | 180 +++++++++++++++++++++ .../ROOT/pages/camel-4x-upgrade-guide-4_18.adoc | 8 + .../dsl/DoclingComponentBuilderFactory.java | 21 +++ .../dsl/DoclingEndpointBuilderFactory.java | 19 +++ 14 files changed, 330 insertions(+), 12 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/docling.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/docling.json index 74e1c6949942..338a275c49dc 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/docling.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/docling.json @@ -72,7 +72,8 @@ "authenticationScheme": { "index": 45, "kind": "property", "displayName": "Authentication Scheme", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "org.apache.camel.component.docling.AuthenticationScheme", "enum": [ "NONE", "BEARER", "API_KEY" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "NONE", "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configuration [...] "authenticationToken": { "index": 46, "kind": "property", "displayName": "Authentication Token", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Authentication token for docling-serve API (Bearer token or API [...] "inputBaseDirectory": { "index": 47, "kind": "property", "displayName": "Input Base Directory", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "When set, every local input file path must resolve inside this d [...] - "maxFileSize": { "index": 48, "kind": "property", "displayName": "Max File Size", "group": "security", "label": "security", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 52428800, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Maximum file size in bytes for processing" } + "maxFileSize": { "index": 48, "kind": "property", "displayName": "Max File Size", "group": "security", "label": "security", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 52428800, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Maximum file size in bytes for processing" }, + "outputBaseDirectory": { "index": 49, "kind": "property", "displayName": "Output Base Directory", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "When set, the output directory passed to the docling CLI must [...] }, "headers": { "CamelDoclingOperation": { "index": 0, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "DoclingOperations", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The operation to perform", "constantName": "org.apache.camel.component.docling.DoclingHeaders#OPERATION" }, @@ -152,6 +153,7 @@ "authenticationScheme": { "index": 44, "kind": "parameter", "displayName": "Authentication Scheme", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "org.apache.camel.component.docling.AuthenticationScheme", "enum": [ "NONE", "BEARER", "API_KEY" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "NONE", "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configuratio [...] "authenticationToken": { "index": 45, "kind": "parameter", "displayName": "Authentication Token", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Authentication token for docling-serve API (Bearer token or API [...] "inputBaseDirectory": { "index": 46, "kind": "parameter", "displayName": "Input Base Directory", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "When set, every local input file path must resolve inside this [...] - "maxFileSize": { "index": 47, "kind": "parameter", "displayName": "Max File Size", "group": "security", "label": "security", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 52428800, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Maximum file size in bytes for processing" } + "maxFileSize": { "index": 47, "kind": "parameter", "displayName": "Max File Size", "group": "security", "label": "security", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 52428800, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Maximum file size in bytes for processing" }, + "outputBaseDirectory": { "index": 48, "kind": "parameter", "displayName": "Output Base Directory", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "When set, the output directory passed to the docling CLI must [...] } } diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/docling-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/docling-component.adoc index 0aef255d2be8..9f1bdbd12a72 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/docling-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/docling-component.adoc @@ -149,6 +149,11 @@ directory-or-file `String` body accepted by the batch operations. | _(none)_ | When set, every local input path must resolve inside this directory once normalized. Applies to the `CamelDoclingInputFilePath` header, to file path bodies, and to the paths used by the batch operations. + +| `outputBaseDirectory` +| _(none)_ +| When set, the output directory passed to the docling CLI must resolve inside this directory once normalized. +Applies to the `CamelDoclingOutputFilePath` header. |=== With both options left at their defaults, a body that is neither a URL nor a path is written to a temporary @@ -159,6 +164,11 @@ keeps working without `allowFilePathSource`. It is still subject to `inputBaseDi Typed bodies - `File`, `byte[]`, `InputStream`, and the explicit path collections used by the batch operations - are unambiguous and are likewise unaffected. +The `CamelDoclingOutputFilePath` header, which selects the CLI `--output` directory, is normalized lexically +and, when `outputBaseDirectory` is set, confined to that directory. The normalization applies unconditionally; +with `outputBaseDirectory` unset, a header value without traversal segments is otherwise used as given. The +containment is lexical and does not resolve symbolic links (as with `inputBaseDirectory`). + [source,java] ---- // the body is the document itself - no opt-in needed @@ -481,7 +491,8 @@ Only the following flags are permitted: |=== The `--output` (`-o`) flag is **not permitted** because the output directory is managed by the producer. -Use the `CamelDoclingOutputFilePath` header or endpoint configuration instead. +Use the `CamelDoclingOutputFilePath` header or endpoint configuration instead. That header is normalized and, +when `outputBaseDirectory` is set, confined to that directory (see the security options above). Additionally, the following are rejected: diff --git a/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingComponentConfigurer.java b/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingComponentConfigurer.java index 1c246c8fd60e..a229c19bedf8 100644 --- a/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingComponentConfigurer.java +++ b/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingComponentConfigurer.java @@ -106,6 +106,8 @@ public class DoclingComponentConfigurer extends PropertyConfigurerSupport implem case "ocrlanguage": case "ocrLanguage": getOrCreateConfiguration(target).setOcrLanguage(property(camelContext, java.lang.String.class, value)); return true; case "operation": getOrCreateConfiguration(target).setOperation(property(camelContext, org.apache.camel.component.docling.DoclingOperations.class, value)); return true; + case "outputbasedirectory": + case "outputBaseDirectory": getOrCreateConfiguration(target).setOutputBaseDirectory(property(camelContext, java.lang.String.class, value)); return true; case "outputformat": case "outputFormat": getOrCreateConfiguration(target).setOutputFormat(property(camelContext, java.lang.String.class, value)); return true; case "pdfbackend": @@ -208,6 +210,8 @@ public class DoclingComponentConfigurer extends PropertyConfigurerSupport implem case "ocrlanguage": case "ocrLanguage": return java.lang.String.class; case "operation": return org.apache.camel.component.docling.DoclingOperations.class; + case "outputbasedirectory": + case "outputBaseDirectory": return java.lang.String.class; case "outputformat": case "outputFormat": return java.lang.String.class; case "pdfbackend": @@ -311,6 +315,8 @@ public class DoclingComponentConfigurer extends PropertyConfigurerSupport implem case "ocrlanguage": case "ocrLanguage": return getOrCreateConfiguration(target).getOcrLanguage(); case "operation": return getOrCreateConfiguration(target).getOperation(); + case "outputbasedirectory": + case "outputBaseDirectory": return getOrCreateConfiguration(target).getOutputBaseDirectory(); case "outputformat": case "outputFormat": return getOrCreateConfiguration(target).getOutputFormat(); case "pdfbackend": diff --git a/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingConfigurationConfigurer.java b/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingConfigurationConfigurer.java index 91ac3233bebe..fd85b059c961 100644 --- a/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingConfigurationConfigurer.java +++ b/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingConfigurationConfigurer.java @@ -94,6 +94,8 @@ public class DoclingConfigurationConfigurer extends org.apache.camel.support.com case "ocrlanguage": case "ocrLanguage": target.setOcrLanguage(property(camelContext, java.lang.String.class, value)); return true; case "operation": target.setOperation(property(camelContext, org.apache.camel.component.docling.DoclingOperations.class, value)); return true; + case "outputbasedirectory": + case "outputBaseDirectory": target.setOutputBaseDirectory(property(camelContext, java.lang.String.class, value)); return true; case "outputformat": case "outputFormat": target.setOutputFormat(property(camelContext, java.lang.String.class, value)); return true; case "pdfbackend": @@ -191,6 +193,8 @@ public class DoclingConfigurationConfigurer extends org.apache.camel.support.com case "ocrlanguage": case "ocrLanguage": return java.lang.String.class; case "operation": return org.apache.camel.component.docling.DoclingOperations.class; + case "outputbasedirectory": + case "outputBaseDirectory": return java.lang.String.class; case "outputformat": case "outputFormat": return java.lang.String.class; case "pdfbackend": @@ -289,6 +293,8 @@ public class DoclingConfigurationConfigurer extends org.apache.camel.support.com case "ocrlanguage": case "ocrLanguage": return target.getOcrLanguage(); case "operation": return target.getOperation(); + case "outputbasedirectory": + case "outputBaseDirectory": return target.getOutputBaseDirectory(); case "outputformat": case "outputFormat": return target.getOutputFormat(); case "pdfbackend": diff --git a/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingEndpointConfigurer.java b/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingEndpointConfigurer.java index c17797ad91a8..49c0ac1cbb21 100644 --- a/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingEndpointConfigurer.java +++ b/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingEndpointConfigurer.java @@ -96,6 +96,8 @@ public class DoclingEndpointConfigurer extends PropertyConfigurerSupport impleme case "ocrlanguage": case "ocrLanguage": target.getConfiguration().setOcrLanguage(property(camelContext, java.lang.String.class, value)); return true; case "operation": target.getConfiguration().setOperation(property(camelContext, org.apache.camel.component.docling.DoclingOperations.class, value)); return true; + case "outputbasedirectory": + case "outputBaseDirectory": target.getConfiguration().setOutputBaseDirectory(property(camelContext, java.lang.String.class, value)); return true; case "outputformat": case "outputFormat": target.getConfiguration().setOutputFormat(property(camelContext, java.lang.String.class, value)); return true; case "pdfbackend": @@ -195,6 +197,8 @@ public class DoclingEndpointConfigurer extends PropertyConfigurerSupport impleme case "ocrlanguage": case "ocrLanguage": return java.lang.String.class; case "operation": return org.apache.camel.component.docling.DoclingOperations.class; + case "outputbasedirectory": + case "outputBaseDirectory": return java.lang.String.class; case "outputformat": case "outputFormat": return java.lang.String.class; case "pdfbackend": @@ -295,6 +299,8 @@ public class DoclingEndpointConfigurer extends PropertyConfigurerSupport impleme case "ocrlanguage": case "ocrLanguage": return target.getConfiguration().getOcrLanguage(); case "operation": return target.getConfiguration().getOperation(); + case "outputbasedirectory": + case "outputBaseDirectory": return target.getConfiguration().getOutputBaseDirectory(); case "outputformat": case "outputFormat": return target.getConfiguration().getOutputFormat(); case "pdfbackend": diff --git a/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingEndpointUriFactory.java b/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingEndpointUriFactory.java index c5b710b8c9a9..db2a85247546 100644 --- a/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingEndpointUriFactory.java +++ b/components/camel-ai/camel-docling/src/generated/java/org/apache/camel/component/docling/DoclingEndpointUriFactory.java @@ -23,7 +23,7 @@ public class DoclingEndpointUriFactory extends org.apache.camel.support.componen private static final Set<String> SECRET_PROPERTY_NAMES; private static final Map<String, String> MULTI_VALUE_PREFIXES; static { - Set<String> props = new HashSet<>(48); + Set<String> props = new HashSet<>(49); props.add("abortOnError"); props.add("allowFilePathSource"); props.add("allowUrlSource"); @@ -62,6 +62,7 @@ public class DoclingEndpointUriFactory extends org.apache.camel.support.componen props.add("ocrLanguage"); props.add("operation"); props.add("operationId"); + props.add("outputBaseDirectory"); props.add("outputFormat"); props.add("pdfBackend"); props.add("pipeline"); diff --git a/components/camel-ai/camel-docling/src/generated/resources/META-INF/org/apache/camel/component/docling/docling.json b/components/camel-ai/camel-docling/src/generated/resources/META-INF/org/apache/camel/component/docling/docling.json index 74e1c6949942..338a275c49dc 100644 --- a/components/camel-ai/camel-docling/src/generated/resources/META-INF/org/apache/camel/component/docling/docling.json +++ b/components/camel-ai/camel-docling/src/generated/resources/META-INF/org/apache/camel/component/docling/docling.json @@ -72,7 +72,8 @@ "authenticationScheme": { "index": 45, "kind": "property", "displayName": "Authentication Scheme", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "org.apache.camel.component.docling.AuthenticationScheme", "enum": [ "NONE", "BEARER", "API_KEY" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "NONE", "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configuration [...] "authenticationToken": { "index": 46, "kind": "property", "displayName": "Authentication Token", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Authentication token for docling-serve API (Bearer token or API [...] "inputBaseDirectory": { "index": 47, "kind": "property", "displayName": "Input Base Directory", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "When set, every local input file path must resolve inside this d [...] - "maxFileSize": { "index": 48, "kind": "property", "displayName": "Max File Size", "group": "security", "label": "security", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 52428800, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Maximum file size in bytes for processing" } + "maxFileSize": { "index": 48, "kind": "property", "displayName": "Max File Size", "group": "security", "label": "security", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 52428800, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Maximum file size in bytes for processing" }, + "outputBaseDirectory": { "index": 49, "kind": "property", "displayName": "Output Base Directory", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "When set, the output directory passed to the docling CLI must [...] }, "headers": { "CamelDoclingOperation": { "index": 0, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "DoclingOperations", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The operation to perform", "constantName": "org.apache.camel.component.docling.DoclingHeaders#OPERATION" }, @@ -152,6 +153,7 @@ "authenticationScheme": { "index": 44, "kind": "parameter", "displayName": "Authentication Scheme", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "org.apache.camel.component.docling.AuthenticationScheme", "enum": [ "NONE", "BEARER", "API_KEY" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "NONE", "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configuratio [...] "authenticationToken": { "index": 45, "kind": "parameter", "displayName": "Authentication Token", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Authentication token for docling-serve API (Bearer token or API [...] "inputBaseDirectory": { "index": 46, "kind": "parameter", "displayName": "Input Base Directory", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "When set, every local input file path must resolve inside this [...] - "maxFileSize": { "index": 47, "kind": "parameter", "displayName": "Max File Size", "group": "security", "label": "security", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 52428800, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Maximum file size in bytes for processing" } + "maxFileSize": { "index": 47, "kind": "parameter", "displayName": "Max File Size", "group": "security", "label": "security", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 52428800, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "Maximum file size in bytes for processing" }, + "outputBaseDirectory": { "index": 48, "kind": "parameter", "displayName": "Output Base Directory", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.docling.DoclingConfiguration", "configurationField": "configuration", "description": "When set, the output directory passed to the docling CLI must [...] } } diff --git a/components/camel-ai/camel-docling/src/main/docs/docling-component.adoc b/components/camel-ai/camel-docling/src/main/docs/docling-component.adoc index 01ad2d76bd44..8c6e64f17009 100644 --- a/components/camel-ai/camel-docling/src/main/docs/docling-component.adoc +++ b/components/camel-ai/camel-docling/src/main/docs/docling-component.adoc @@ -144,6 +144,11 @@ directory-or-file `String` body accepted by the batch operations. | _(none)_ | When set, every local input path must resolve inside this directory once normalized. Applies to the `CamelDoclingInputFilePath` header, to file path bodies, and to the paths used by the batch operations. + +| `outputBaseDirectory` +| _(none)_ +| When set, the output directory passed to the docling CLI must resolve inside this directory once normalized. +Applies to the `CamelDoclingOutputFilePath` header. |=== With both options left at their defaults, a body that is neither a URL nor a path is written to a temporary @@ -154,6 +159,11 @@ keeps working without `allowFilePathSource`. It is still subject to `inputBaseDi Typed bodies - `File`, `byte[]`, `InputStream`, and the explicit path collections used by the batch operations - are unambiguous and are likewise unaffected. +The `CamelDoclingOutputFilePath` header, which selects the CLI `--output` directory, is normalized lexically +and, when `outputBaseDirectory` is set, confined to that directory. The normalization applies unconditionally; +with `outputBaseDirectory` unset, a header value without traversal segments is otherwise used as given. The +containment is lexical and does not resolve symbolic links (as with `inputBaseDirectory`). + [source,java] ---- // the body is the document itself - no opt-in needed @@ -467,7 +477,8 @@ Only the following flags are permitted: |=== The `--output` (`-o`) flag is **not permitted** because the output directory is managed by the producer. -Use the `CamelDoclingOutputFilePath` header or endpoint configuration instead. +Use the `CamelDoclingOutputFilePath` header or endpoint configuration instead. That header is normalized and, +when `outputBaseDirectory` is set, confined to that directory (see the security options above). Additionally, the following are rejected: diff --git a/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingConfiguration.java b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingConfiguration.java index f833800024dc..9bf7b3acb8f8 100644 --- a/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingConfiguration.java +++ b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingConfiguration.java @@ -91,6 +91,14 @@ public class DoclingConfiguration implements Cloneable { + " restriction is applied.") private String inputBaseDirectory; + @UriParam(label = "security") + @Metadata(description = "When set, the output directory passed to the docling CLI must resolve inside this" + + " directory once normalized. Applies to the CamelDoclingOutputFilePath header. The" + + " check is lexical and does not resolve symbolic links, matching inputBaseDirectory." + + " When empty, no directory restriction is applied and the header value is only" + + " normalized.") + private String outputBaseDirectory; + @UriParam @Metadata(description = "Include the content of the output file in the exchange body and delete the output file", defaultValue = "false") @@ -331,6 +339,14 @@ public class DoclingConfiguration implements Cloneable { this.inputBaseDirectory = inputBaseDirectory; } + public String getOutputBaseDirectory() { + return outputBaseDirectory; + } + + public void setOutputBaseDirectory(String outputBaseDirectory) { + this.outputBaseDirectory = outputBaseDirectory; + } + public boolean isContentInBody() { return contentInBody; } diff --git a/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java index f1b6c24d43f6..06880889638f 100644 --- a/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java +++ b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java @@ -1838,7 +1838,9 @@ public class DoclingProducer extends DefaultProducer { } } - private List<String> buildDoclingCommand(String inputPath, String outputFormat, Exchange exchange, String outputDirectory) { + // package-private so DoclingOutputPathValidationTest can assert the built command without reflection + List<String> buildDoclingCommand(String inputPath, String outputFormat, Exchange exchange, String outputDirectory) + throws IOException { List<String> command = new ArrayList<>(); command.add(configuration.getDoclingCommand()); @@ -1999,17 +2001,44 @@ public class DoclingProducer extends DefaultProducer { } } - 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. The + * containment check is lexical: symbolic links are not resolved, so - like + * {@link #resolveWithinInputBaseDirectory(String)} - a symlink inside the base directory that points outside it is + * not detected. + */ + 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)) { + throw new IOException("Output path resolves outside of outputBaseDirectory (" + baseDir + "): " + outputPath); + } + return path; + } + private String mapToDoclingFormat(String outputFormat) { switch (outputFormat.toLowerCase()) { case "markdown": diff --git a/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingOutputPathValidationTest.java b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingOutputPathValidationTest.java new file mode 100644 index 000000000000..492fc0f293f6 --- /dev/null +++ b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingOutputPathValidationTest.java @@ -0,0 +1,180 @@ +/* + * 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 java.nio.file.Paths; +import java.util.List; + +import org.apache.camel.CamelExecutionException; +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit5.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"); + } + + @Test + void outputPathIsNormalizedInTheBuiltCommand() throws Exception { + // with outputBaseDirectory unset (the default, and the branch most routes hit) the header is still + // normalized lexically before it reaches --output: out/./sub/../x collapses to out/x + List<String> command = buildDoclingCommandFor("out/./sub/../x"); + + int i = command.indexOf("--output"); + assertThat(i).isGreaterThanOrEqualTo(0); + assertThat(command.get(i + 1)) + .isEqualTo(Paths.get("out", "x").toString()) + .doesNotContain(".."); + } + + // ------------------------------------------------------ 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(); // create the directory the jailed route points at via outputBaseDirectory + 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(); // create the directory the jailed route points at via outputBaseDirectory + + // 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 { + baseDir(); // create the directory the jailed route points at via outputBaseDirectory + + // a genuinely relative value, so the baseDir.resolve(..) + normalize() branch is exercised; an absolute + // value would instead hit the same branch as absoluteOutputPathOutsideOutputBaseDirectoryIsRejected + assertThatThrownBy(() -> template.requestBodyAndHeader("direct:jailed", CONTENT, + DoclingHeaders.OUTPUT_FILE_PATH, "../outside")) + .isInstanceOf(CamelExecutionException.class) + .cause() + .isInstanceOf(IOException.class) + .hasMessageContaining("outputBaseDirectory"); + } + + @Test + void siblingDirectorySharingANamePrefixIsRejected() throws Exception { + // "<base>-evil" shares a string prefix with "<base>" but is not inside it; a plain String.startsWith + // comparison would wrongly accept this + baseDir(); // create the directory the jailed route points at via outputBaseDirectory + String sibling = tempDir.resolve("base-evil").resolve("out").toString(); + + assertThatThrownBy(() -> template.requestBodyAndHeader("direct:jailed", CONTENT, + DoclingHeaders.OUTPUT_FILE_PATH, sibling)) + .isInstanceOf(CamelExecutionException.class) + .cause() + .isInstanceOf(IOException.class) + .hasMessageContaining("outputBaseDirectory"); + } + + // ------------------------------------------------------------------ configuration + + @Test + void outputBaseDirectoryDefaultsToNull() { + assertThat(new DoclingConfiguration().getOutputBaseDirectory()).isNull(); + } + + private Path baseDir() throws IOException { + return Files.createDirectories(tempDir.resolve("base")); + } + + private List<String> buildDoclingCommandFor(String outputHeader) throws Exception { + // outputBaseDirectory is unset on this endpoint, so buildDoclingCommand exercises the no-base branch + DoclingEndpoint endpoint + = context.getEndpoint("docling:convert?operation=CONVERT_TO_MARKDOWN", DoclingEndpoint.class); + DoclingProducer producer = (DoclingProducer) endpoint.createProducer(); + Exchange exchange = endpoint.createExchange(); + exchange.getIn().setHeader(DoclingHeaders.OUTPUT_FILE_PATH, outputHeader); + + return producer.buildDoclingCommand("input.pdf", "markdown", exchange, "/tmp/managed"); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() throws Exception { + String missingBinary = tempDir.resolve("no-such-docling").toString(); + + from("direct:default") + .to("docling:convert?operation=CONVERT_TO_MARKDOWN&doclingCommand=" + missingBinary); + + from("direct:jailed") + .to("docling:convert?operation=CONVERT_TO_MARKDOWN" + + "&outputBaseDirectory=" + baseDir() + "&doclingCommand=" + missingBinary); + } + }; + } +} diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc index 8e8789842e2b..86e21b7a320a 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc @@ -13,6 +13,14 @@ See the xref:camel-upgrade-recipes-tool.adoc[documentation] page for details. == Upgrading from 4.18.4 to 4.18.5 +=== camel-docling - output path handling + +The `CamelDoclingOutputFilePath` header, which selects the CLI output directory, is now normalized lexically +(redundant separators and `.`/`..` segments are resolved) before it is passed to Docling; a header value +without such segments is still used as given. A new `outputBaseDirectory` option additionally confines the +header the same way `inputBaseDirectory` confines input paths - when set, an output path that resolves outside +it, including an absolute path, is rejected with an `IOException`. `outputBaseDirectory` is unset by default. + === camel-core - Rest DSL response Content-Type Under json or xml binding, a response without a Content-Type header again takes it from the `produces` of the rest diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/DoclingComponentBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/DoclingComponentBuilderFactory.java index 50df3b8a4993..19e698309760 100644 --- a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/DoclingComponentBuilderFactory.java +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/DoclingComponentBuilderFactory.java @@ -891,6 +891,26 @@ public interface DoclingComponentBuilderFactory { doSetProperty("maxFileSize", maxFileSize); return this; } + + /** + * When set, the output directory passed to the docling CLI must resolve + * inside this directory once normalized. Applies to the + * CamelDoclingOutputFilePath header. The check is lexical and does not + * resolve symbolic links, matching inputBaseDirectory. When empty, no + * directory restriction is applied and the header value is only + * normalized. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: security + * + * @param outputBaseDirectory the value to set + * @return the dsl builder + */ + default DoclingComponentBuilder outputBaseDirectory(java.lang.String outputBaseDirectory) { + doSetProperty("outputBaseDirectory", outputBaseDirectory); + return this; + } } class DoclingComponentBuilderImpl @@ -961,6 +981,7 @@ public interface DoclingComponentBuilderFactory { case "authenticationToken": getOrCreateConfiguration((DoclingComponent) component).setAuthenticationToken((java.lang.String) value); return true; case "inputBaseDirectory": getOrCreateConfiguration((DoclingComponent) component).setInputBaseDirectory((java.lang.String) value); return true; case "maxFileSize": getOrCreateConfiguration((DoclingComponent) component).setMaxFileSize((long) value); return true; + case "outputBaseDirectory": getOrCreateConfiguration((DoclingComponent) component).setOutputBaseDirectory((java.lang.String) value); return true; default: return false; } } diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/DoclingEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/DoclingEndpointBuilderFactory.java index 574c25119f89..28c5be0877cf 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/DoclingEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/DoclingEndpointBuilderFactory.java @@ -653,6 +653,25 @@ public interface DoclingEndpointBuilderFactory { doSetProperty("maxFileSize", maxFileSize); return this; } + /** + * When set, the output directory passed to the docling CLI must resolve + * inside this directory once normalized. Applies to the + * CamelDoclingOutputFilePath header. The check is lexical and does not + * resolve symbolic links, matching inputBaseDirectory. When empty, no + * directory restriction is applied and the header value is only + * normalized. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: security + * + * @param outputBaseDirectory the value to set + * @return the dsl builder + */ + default DoclingEndpointBuilder outputBaseDirectory(String outputBaseDirectory) { + doSetProperty("outputBaseDirectory", outputBaseDirectory); + return this; + } } /**
