This is an automated email from the ASF dual-hosted git repository.
acosentino pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 3495e1519818 CAMEL-23211 - Camel-Docling: Use secure temp file
creation in camel-docling (#22100)
3495e1519818 is described below
commit 3495e1519818e73da724740c01cc9ed9b21d27c7
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Mar 19 12:39:42 2026 +0100
CAMEL-23211 - Camel-Docling: Use secure temp file creation in camel-docling
(#22100)
Replace insecure temp file creation in DoclingProducer with
per-exchange UUID-named subdirectories under the system temp dir.
Set POSIX 700 permissions on directories and POSIX 600 permissions
on files when the platform supports it, preventing local attackers
from pre-creating symlinks or monitoring temp files.
Add createSecureTempDir() and createSecureTempFile() helper methods
with automatic POSIX support detection via FileSystems. Replace the
old single-file cleanup (registerTempFileCleanup) with directory-level
cleanup (registerTempDirCleanup + deleteDirectoryRecursively) that
removes the entire per-exchange subdirectory on exchange completion.
Fix a pre-existing bug where the CLI output temp directory was only
cleaned up when contentInBody=true (default is false), causing temp
directory leaks on every CLI invocation. The finally block now always
cleans up the output directory.
Add DoclingSecureTempFileTest verifying per-exchange subdirectory
isolation and POSIX permission enforcement. Update existing
DoclingTempFileCleanupTest to scan for directories instead of files.
Signed-off-by: Andrea Cosentino <[email protected]>
---
.../camel/component/docling/DoclingProducer.java | 99 ++++++++++---
.../docling/DoclingSecureTempFileTest.java | 162 +++++++++++++++++++++
.../docling/DoclingTempFileCleanupTest.java | 31 ++--
3 files changed, 261 insertions(+), 31 deletions(-)
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 437fa8f540cb..f3f71e7fafe2 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
@@ -21,16 +21,22 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URI;
+import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
+import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
@@ -137,6 +143,18 @@ public class DoclingProducer extends DefaultProducer {
*/
private static final Set<String> PRODUCER_MANAGED_FLAGS =
Set.of("--output", "-o");
+ private static final boolean POSIX_SUPPORTED =
FileSystems.getDefault().supportedFileAttributeViews().contains("posix");
+
+ // Owner-only permissions: rwx for directories, rw for files
+ private static final Set<PosixFilePermission> DIR_PERMISSIONS_700 =
EnumSet.of(
+ PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE,
+ PosixFilePermission.OWNER_EXECUTE);
+
+ private static final Set<PosixFilePermission> FILE_PERMISSIONS_600 =
EnumSet.of(
+ PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE);
+
private DoclingConfiguration configuration;
private DoclingServeApi doclingServeApi;
private ObjectMapper objectMapper;
@@ -1643,9 +1661,10 @@ public class DoclingProducer extends DefaultProducer {
return content;
} else {
// Treat as content to be written to a temp file
- Path tempFile = Files.createTempFile("docling-", ".tmp");
+ Path secureTempDir = createSecureTempDir();
+ Path tempFile = createSecureTempFile(secureTempDir);
Files.write(tempFile, content.getBytes());
- registerTempFileCleanup(exchange, tempFile);
+ registerTempDirCleanup(exchange, secureTempDir);
validateFileSize(tempFile.toString());
return tempFile.toString();
}
@@ -1653,9 +1672,10 @@ public class DoclingProducer extends DefaultProducer {
if (content.length > configuration.getMaxFileSize()) {
throw new IllegalArgumentException("File size exceeds maximum
allowed size: " + configuration.getMaxFileSize());
}
- Path tempFile = Files.createTempFile("docling-", ".tmp");
+ Path secureTempDir = createSecureTempDir();
+ Path tempFile = createSecureTempFile(secureTempDir);
Files.write(tempFile, content);
- registerTempFileCleanup(exchange, tempFile);
+ registerTempDirCleanup(exchange, secureTempDir);
return tempFile.toString();
} else if (body instanceof File file) {
validateFileSize(file.getAbsolutePath());
@@ -1665,20 +1685,63 @@ public class DoclingProducer extends DefaultProducer {
throw new InvalidPayloadException(exchange, String.class);
}
- private void registerTempFileCleanup(Exchange exchange, Path tempFile) {
+ /**
+ * Creates a secure per-exchange subdirectory under the system temp dir
with a UUID for uniqueness and restrictive
+ * POSIX permissions (700) when the platform supports it.
+ */
+ private Path createSecureTempDir() throws IOException {
+ Path tempDir;
+ if (POSIX_SUPPORTED) {
+ FileAttribute<Set<PosixFilePermission>> dirAttr =
PosixFilePermissions.asFileAttribute(DIR_PERMISSIONS_700);
+ tempDir = Files.createTempDirectory("docling-" + UUID.randomUUID()
+ "-", dirAttr);
+ } else {
+ tempDir = Files.createTempDirectory("docling-" + UUID.randomUUID()
+ "-");
+ }
+ LOG.debug("Created secure temp directory: {}", tempDir);
+ return tempDir;
+ }
+
+ /**
+ * Creates a temp file inside the given directory with restrictive POSIX
permissions (600) when the platform
+ * supports it.
+ */
+ private Path createSecureTempFile(Path parentDir) throws IOException {
+ Path tempFile;
+ if (POSIX_SUPPORTED) {
+ FileAttribute<Set<PosixFilePermission>> fileAttr =
PosixFilePermissions.asFileAttribute(FILE_PERMISSIONS_600);
+ tempFile = Files.createTempFile(parentDir, "docling-", ".tmp",
fileAttr);
+ } else {
+ tempFile = Files.createTempFile(parentDir, "docling-", ".tmp");
+ }
+ return tempFile;
+ }
+
+ /**
+ * Registers cleanup of an entire temp directory (and its contents) when
the exchange completes.
+ */
+ private void registerTempDirCleanup(Exchange exchange, Path tempDir) {
exchange.getExchangeExtension().addOnCompletion(new
SynchronizationAdapter() {
@Override
public void onDone(Exchange exchange) {
- try {
- Files.deleteIfExists(tempFile);
- LOG.debug("Cleaned up temp file: {}", tempFile);
- } catch (IOException e) {
- LOG.warn("Failed to clean up temp file: {}", tempFile, e);
- }
+ deleteDirectoryRecursively(tempDir);
}
});
}
+ private static void deleteDirectoryRecursively(Path dir) {
+ try {
+ if (Files.isDirectory(dir)) {
+ try (Stream<Path> entries = Files.list(dir)) {
+
entries.forEach(DoclingProducer::deleteDirectoryRecursively);
+ }
+ }
+ Files.deleteIfExists(dir);
+ LOG.debug("Cleaned up temp path: {}", dir);
+ } catch (IOException e) {
+ LOG.warn("Failed to clean up temp path: {}", dir, e);
+ }
+ }
+
private void validateFileSize(String filePath) throws IOException {
Path path = Paths.get(filePath);
if (Files.exists(path)) {
@@ -1692,8 +1755,8 @@ public class DoclingProducer extends DefaultProducer {
private String executeDoclingCommand(String inputPath, String
outputFormat, Exchange exchange) throws Exception {
LOG.debug("DoclingProducer executing Docling command for input: {}
with format: {}", inputPath, outputFormat);
- // Create temporary output directory
- Path tempOutputDir = Files.createTempDirectory("docling-output");
+ // Create secure temporary output directory with restrictive
permissions
+ Path tempOutputDir = createSecureTempDir();
try {
List<String> command = buildDoclingCommand(inputPath,
outputFormat, exchange, tempOutputDir.toString());
@@ -1763,11 +1826,11 @@ public class DoclingProducer extends DefaultProducer {
return result;
} finally {
- // Clean up temporary directory only if contentInBody is true
- // (the file has already been read and deleted)
- if (configuration.isContentInBody()) {
- deleteDirectory(tempOutputDir);
- }
+ // Always clean up the temporary output directory. When
contentInBody is true,
+ // the file content has been read into the exchange body. When
contentInBody is false,
+ // the output file has been moved to its final location. In both
cases (and on failure),
+ // the temp directory is no longer needed.
+ deleteDirectory(tempOutputDir);
}
}
diff --git
a/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingSecureTempFileTest.java
b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingSecureTempFileTest.java
new file mode 100644
index 000000000000..102a60919cdd
--- /dev/null
+++
b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingSecureTempFileTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.DirectoryStream;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIf;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests that temp files and directories created by DoclingProducer use secure
per-exchange subdirectories with
+ * restrictive POSIX permissions.
+ */
+class DoclingSecureTempFileTest extends CamelTestSupport {
+
+ @Test
+ void tempFilesAreCreatedInsidePerExchangeSubdirectory() throws Exception {
+ // During the exchange, intercept to check what's on disk
+ final List<Path> capturedDirs = new ArrayList<>();
+
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:intercept-convert")
+ .process(exchange -> {
+ // Capture docling temp dirs that exist right now
(mid-exchange)
+ capturedDirs.addAll(listDoclingTempDirs());
+ })
+ .to("docling:convert?operation=CONVERT_TO_MARKDOWN");
+ }
+ });
+
+ // Snapshot before
+ List<Path> before = listDoclingTempDirs();
+
+ try {
+ template.requestBody("direct:intercept-convert", "Some text
content");
+ } catch (CamelExecutionException e) {
+ // Expected — docling binary not available
+ }
+
+ // The temp file should have been inside a docling-UUID subdirectory,
+ // and after cleanup it should be gone.
+ List<Path> after = listDoclingTempDirs();
+ List<Path> leaked = new ArrayList<>(after);
+ leaked.removeAll(before);
+
+ assertTrue(leaked.isEmpty(),
+ "Temp directories leaked after exchange completion: " +
leaked);
+ }
+
+ @Test
+ @EnabledIf("isPosixSupported")
+ void tempDirectoryHasOwnerOnlyPermissions() throws Exception {
+ // We need to intercept the exchange mid-flight to check permissions
+ // before cleanup removes the directory.
+ final List<Set<PosixFilePermission>> capturedPermissions = new
ArrayList<>();
+
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:check-perms")
+ .to("docling:convert?operation=CONVERT_TO_MARKDOWN");
+ }
+ });
+
+ List<Path> before = listDoclingTempDirs();
+
+ // Use a Synchronization to capture directory permissions before
cleanup
+ Exchange exchange = createExchangeWithBody("Text content for
permission test");
+ exchange.getExchangeExtension().addOnCompletion(
+ new org.apache.camel.support.SynchronizationAdapter() {
+ @Override
+ public void onDone(Exchange exchange) {
+ // Check all docling dirs created during this exchange
+ try {
+ for (Path dir : listDoclingTempDirs()) {
+ if (!before.contains(dir) &&
Files.exists(dir)) {
+
capturedPermissions.add(Files.getPosixFilePermissions(dir));
+ }
+ }
+ } catch (IOException e) {
+ // Ignore
+ }
+ }
+
+ @Override
+ public int getOrder() {
+ // Run before the cleanup synchronization (lower order
= earlier)
+ return HIGHEST - 1;
+ }
+ });
+
+ try {
+ template.send("direct:check-perms", exchange);
+ } catch (CamelExecutionException e) {
+ // Expected
+ }
+
+ if (!capturedPermissions.isEmpty()) {
+ for (Set<PosixFilePermission> perms : capturedPermissions) {
+ // Should only have OWNER_READ, OWNER_WRITE, OWNER_EXECUTE
+ assertFalse(perms.contains(PosixFilePermission.GROUP_READ),
"Group read should not be set");
+ assertFalse(perms.contains(PosixFilePermission.GROUP_WRITE),
"Group write should not be set");
+ assertFalse(perms.contains(PosixFilePermission.OTHERS_READ),
"Others read should not be set");
+ assertFalse(perms.contains(PosixFilePermission.OTHERS_WRITE),
"Others write should not be set");
+ assertTrue(perms.contains(PosixFilePermission.OWNER_READ),
"Owner read should be set");
+ assertTrue(perms.contains(PosixFilePermission.OWNER_WRITE),
"Owner write should be set");
+ }
+ }
+ }
+
+ private List<Path> listDoclingTempDirs() throws IOException {
+ List<Path> result = new ArrayList<>();
+ Path tmpDir = Path.of(System.getProperty("java.io.tmpdir"));
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(tmpDir,
"docling-*")) {
+ for (Path entry : stream) {
+ if (Files.isDirectory(entry)) {
+ result.add(entry);
+ }
+ }
+ }
+ return result;
+ }
+
+ static boolean isPosixSupported() {
+ return
FileSystems.getDefault().supportedFileAttributeViews().contains("posix");
+ }
+
+ @Override
+ public boolean isUseRouteBuilder() {
+ return false;
+ }
+}
diff --git
a/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingTempFileCleanupTest.java
b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingTempFileCleanupTest.java
index 69c0178e19ff..bf116ce7d74a 100644
---
a/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingTempFileCleanupTest.java
+++
b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingTempFileCleanupTest.java
@@ -35,15 +35,15 @@ import static org.junit.jupiter.api.Assertions.*;
* processing completes.
*
* <p>
- * Before the fix, temp files created for String content and byte[] bodies
accumulated on disk indefinitely. After the
- * fix, an {@code addOnCompletion} callback deletes them when the exchange
finishes.
+ * Temp files are created inside per-exchange subdirectories under the system
temp dir. The entire subdirectory is
+ * removed when the exchange finishes.
*/
class DoclingTempFileCleanupTest extends CamelTestSupport {
@Test
void tempFileFromStringContentIsCleanedUp() throws Exception {
- // Snapshot temp files before
- List<Path> before = listDoclingTempFiles();
+ // Snapshot docling temp directories before
+ List<Path> before = listDoclingTempDirs();
// Send string content (not a path, not a URL) — this triggers temp
file creation.
// The docling CLI will fail (not installed), but the temp file cleanup
@@ -54,18 +54,18 @@ class DoclingTempFileCleanupTest extends CamelTestSupport {
// Expected — docling binary not available in test env
}
- // After exchange completes, temp files should have been cleaned up
- List<Path> after = listDoclingTempFiles();
+ // After exchange completes, temp directories should have been cleaned
up
+ List<Path> after = listDoclingTempDirs();
List<Path> leaked = new ArrayList<>(after);
leaked.removeAll(before);
assertTrue(leaked.isEmpty(),
- "Temp files leaked after exchange completion: " + leaked);
+ "Temp directories leaked after exchange completion: " +
leaked);
}
@Test
void tempFileFromByteArrayIsCleanedUp() throws Exception {
- List<Path> before = listDoclingTempFiles();
+ List<Path> before = listDoclingTempDirs();
try {
template.requestBody("direct:convert", "Binary content for
conversion".getBytes());
@@ -73,20 +73,25 @@ class DoclingTempFileCleanupTest extends CamelTestSupport {
// Expected — docling binary not available in test env
}
- List<Path> after = listDoclingTempFiles();
+ List<Path> after = listDoclingTempDirs();
List<Path> leaked = new ArrayList<>(after);
leaked.removeAll(before);
assertTrue(leaked.isEmpty(),
- "Temp files leaked after exchange completion: " + leaked);
+ "Temp directories leaked after exchange completion: " +
leaked);
}
- private List<Path> listDoclingTempFiles() throws IOException {
+ /**
+ * Lists docling temp directories (docling-UUID-*) in the system temp dir.
+ */
+ private List<Path> listDoclingTempDirs() throws IOException {
List<Path> result = new ArrayList<>();
Path tmpDir = Path.of(System.getProperty("java.io.tmpdir"));
- try (DirectoryStream<Path> stream = Files.newDirectoryStream(tmpDir,
"docling-*.tmp")) {
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(tmpDir,
"docling-*")) {
for (Path entry : stream) {
- result.add(entry);
+ if (Files.isDirectory(entry)) {
+ result.add(entry);
+ }
}
}
return result;