This is an automated email from the ASF dual-hosted git repository.

frankgh pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-sidecar.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 74d400ce CASSSIDECAR-479: Tighten path containment in Live Migration 
file route (#367)
74d400ce is described below

commit 74d400cefce464702dbc2c59ae9f22a5bf2d3541
Author: N V Harikrishna <[email protected]>
AuthorDate: Sat Aug 29 04:42:45 2026 +0530

    CASSSIDECAR-479: Tighten path containment in Live Migration file route 
(#367)
    
    Patch by N V Harikrishna; reviewed by Yifan Cai and Francisco Guerrero for 
CASSSIDECAR-479
---
 CHANGES.txt                                        |   1 +
 .../exceptions/LiveMigrationExceptions.java        |  16 +++
 .../LiveMigrationFileResolveHandler.java           | 110 ++++++++++++-------
 .../LiveMigrationInstanceMetadataUtil.java         | 103 +++++++++++++++++-
 .../sidecar/modules/LiveMigrationModule.java       |  10 +-
 .../LiveMigrationFileDigestHandlerTest.java        |  36 +++++++
 .../livemigration/LiveMigrationFileStreamTest.java |  62 ++++++++++-
 .../LiveMigrationInstanceMetadataUtilTest.java     | 120 +++++++++++++++++++++
 8 files changed, 410 insertions(+), 48 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 588af8c4..3bfb2983 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
 0.5.0
 -----
+ * Tighten path containment in Live Migration file route (CASSSIDECAR-479)
  * Add fast_forward_enabled flag to enable eager pipelining of staging and 
importing in coordinated writes (CASSSIDECAR-463)
  * CachingSchemaStore should be reloaded when Schemastore kafka configs are 
changed (CASSSIDECAR-493)
  * Support retrying on HTTP 429 responses and fix duplicate immediate 
execution in RequestExecutor retry scheduling (CASSSIDECAR-465)
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/exceptions/LiveMigrationExceptions.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/exceptions/LiveMigrationExceptions.java
index 8def795f..256b185c 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/exceptions/LiveMigrationExceptions.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/exceptions/LiveMigrationExceptions.java
@@ -72,6 +72,22 @@ public class LiveMigrationExceptions
         }
     }
 
+    /**
+     * Thrown when a live-migration file URL is well-formed but matches no 
configured directory prefix
+     * on this instance, i.e. it addresses no resource here. Extends {@link 
IllegalArgumentException} so
+     * that destination-side callers - which map file URLs obtained from the 
source's file-list API onto
+     * local paths - keep treating an unmatched prefix (a source/destination 
directory-configuration
+     * mismatch) as a bad argument, while the source-side request handler can 
catch this specific type to
+     * distinguish "no such resource" (HTTP 404) from a malformed URL (HTTP 
400).
+     */
+    public static class UnknownMigrationPrefixException extends 
IllegalArgumentException
+    {
+        public UnknownMigrationPrefixException(String message)
+        {
+            super(message);
+        }
+    }
+
     /**
      * Exception thrown when file verification fails during live migration.
      */
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileResolveHandler.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileResolveHandler.java
index df445324..25d66018 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileResolveHandler.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileResolveHandler.java
@@ -18,13 +18,13 @@
 
 package org.apache.cassandra.sidecar.handlers.livemigration;
 
-import java.net.URLDecoder;
-import java.nio.charset.StandardCharsets;
+import java.io.IOException;
+import java.net.URI;
 import java.nio.file.FileSystems;
 import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.nio.file.PathMatcher;
-import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -42,16 +42,17 @@ import io.vertx.core.http.HttpServerRequest;
 import io.vertx.core.net.SocketAddress;
 import io.vertx.ext.auth.authorization.Authorization;
 import io.vertx.ext.web.RoutingContext;
-import io.vertx.ext.web.handler.HttpException;
 import org.apache.cassandra.sidecar.acl.authorization.BasicPermissions;
 import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
 import org.apache.cassandra.sidecar.concurrent.ExecutorPools;
 import org.apache.cassandra.sidecar.config.LiveMigrationConfiguration;
 import org.apache.cassandra.sidecar.config.SidecarConfiguration;
+import 
org.apache.cassandra.sidecar.exceptions.LiveMigrationExceptions.UnknownMigrationPrefixException;
 import org.apache.cassandra.sidecar.handlers.AbstractHandler;
 import org.apache.cassandra.sidecar.handlers.AccessProtected;
 import org.apache.cassandra.sidecar.handlers.FileStreamHandler;
 import 
org.apache.cassandra.sidecar.livemigration.LiveMigrationInstanceMetadataUtil;
+import 
org.apache.cassandra.sidecar.livemigration.LiveMigrationInstanceMetadataUtil.ResolvedPath;
 import org.apache.cassandra.sidecar.utils.CassandraInputValidator;
 import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher;
 import org.jetbrains.annotations.NotNull;
@@ -60,6 +61,7 @@ import org.jetbrains.annotations.Nullable;
 import static 
org.apache.cassandra.sidecar.common.ApiEndpointsV1.DIR_INDEX_PARAM;
 import static 
org.apache.cassandra.sidecar.common.ApiEndpointsV1.DIR_TYPE_PARAM;
 import static 
org.apache.cassandra.sidecar.livemigration.LiveMigrationPlaceholderUtil.replacePlaceholder;
+import static 
org.apache.cassandra.sidecar.utils.HttpExceptions.wrapHttpException;
 
 /**
  * Handler that resolves and validates file paths for live migration 
operations.
@@ -94,7 +96,7 @@ public class LiveMigrationFileResolveHandler extends 
AbstractHandler<Void> imple
         String dirType = context.pathParam(DIR_TYPE_PARAM);
         if (null == dirType || dirType.isEmpty() || null == 
LiveMigrationDirType.find(dirType))
         {
-            throw new HttpException(HttpResponseStatus.BAD_REQUEST.code(), 
"Invalid directory type: " + dirType);
+            throw wrapHttpException(HttpResponseStatus.BAD_REQUEST, "Invalid 
directory type: " + dirType);
         }
 
         String dirIndex = context.pathParam(DIR_INDEX_PARAM);
@@ -105,11 +107,11 @@ public class LiveMigrationFileResolveHandler extends 
AbstractHandler<Void> imple
         }
         catch (NumberFormatException formatException)
         {
-            throw new HttpException(HttpResponseStatus.BAD_REQUEST.code(), 
"Invalid directoryIndex: " + dirIndex);
+            throw wrapHttpException(HttpResponseStatus.BAD_REQUEST, "Invalid 
directoryIndex: " + dirIndex);
         }
         if (index < 0)
         {
-            throw new HttpException(HttpResponseStatus.BAD_REQUEST.code(), 
"Invalid directoryIndex: " + dirIndex);
+            throw wrapHttpException(HttpResponseStatus.BAD_REQUEST, "Invalid 
directoryIndex: " + dirIndex);
         }
 
         // Path params are not used further, hence returning null.
@@ -123,65 +125,95 @@ public class LiveMigrationFileResolveHandler extends 
AbstractHandler<Void> imple
                                   SocketAddress remoteAddress,
                                   @Nullable Void request)
     {
-        String reqPath = URLDecoder.decode(rc.request().path(), 
StandardCharsets.UTF_8);
+        String reqPath;
+        try
+        {
+            reqPath = URI.create(rc.request().path()).getPath();
+        }
+        catch (IllegalArgumentException e)
+        {
+            rc.fail(wrapHttpException(HttpResponseStatus.BAD_REQUEST, 
"Malformed request path", e));
+            return;
+        }
 
         if (reqPath.contains("/../") || reqPath.endsWith("/.."))
         {
             LOGGER.warn("Tried to access file using relative path({}). 
Rejecting the request.", reqPath);
-            
rc.response().setStatusCode(HttpResponseStatus.BAD_REQUEST.code()).end();
+            rc.fail(wrapHttpException(HttpResponseStatus.BAD_REQUEST,
+                                      "Tried to access file using relative 
path: " + reqPath));
             return;
         }
 
         InstanceMetadata instanceMeta = metadataFetcher.instance(host);
         String normalizedPath = rc.normalizedPath();
-        String localFile;
 
-        try
+        ResolvedPath resolved = 
LiveMigrationInstanceMetadataUtil.resolveLexically(normalizedPath, 
instanceMeta);
+
+        // Only the filesystem-touching checks (verifyContainment, 
isDirectory, isExcluded) run on
+        // the worker thread; the lexical resolve above is pure string work 
and stays on the event
+        // loop. validate() throws HttpException on any failure, which flows 
through processFailure
+        // -> context.fail() so the framework's failure handler renders a JSON 
error response.
+        String localFile = resolved.resolvedPath().toString();
+        executorPools.service()
+                     .executeBlocking(() -> {
+                         validate(resolved, instanceMeta);
+                         return localFile;
+                     })
+                     .onSuccess(file -> {
+                         rc.put(FileStreamHandler.FILE_PATH_CONTEXT_KEY, file);
+                         rc.next();
+                     })
+                     .onFailure(cause -> processFailure(cause, rc, host, 
remoteAddress, request));
+    }
+
+    @Override
+    protected void processFailure(Throwable cause, RoutingContext context, 
String host, SocketAddress remoteAddress, Void request)
+    {
+        if (cause instanceof UnknownMigrationPrefixException)
         {
-            localFile = 
LiveMigrationInstanceMetadataUtil.localPath(normalizedPath, 
instanceMeta).toString();
+            context.fail(wrapHttpException(HttpResponseStatus.NOT_FOUND, 
cause.getMessage(), cause));
         }
-        catch (IllegalArgumentException e)
+        else
         {
-            LOGGER.warn("Invalid path", e);
-            
rc.response().setStatusCode(HttpResponseStatus.NOT_FOUND.code()).end();
-            return;
+            super.processFailure(cause, context, host, remoteAddress, request);
         }
-
-        Path path = Paths.get(localFile);
-
-        executorPools.service()
-                     .executeBlocking(() -> isInvalidPath(rc, path, reqPath, 
instanceMeta))
-                     .onSuccess(invalid -> {
-                         if (!invalid)
-                         {
-                             rc.put(FileStreamHandler.FILE_PATH_CONTEXT_KEY, 
localFile);
-                             rc.next();
-                         }
-                     });
     }
 
-    private boolean isInvalidPath(RoutingContext rc, Path path, String 
reqPath, InstanceMetadata instanceMeta)
+    private void validate(ResolvedPath resolved, InstanceMetadata instanceMeta)
     {
-        if (!Files.exists(path))
+        Path path = resolved.resolvedPath();
+        try
+        {
+            resolved.verifyContainment();
+        }
+        catch (NoSuchFileException e)
         {
             LOGGER.info("Requested file is not found. file={}", path);
-            
rc.response().setStatusCode(HttpResponseStatus.NOT_FOUND.code()).end();
-            return true;
+            throw wrapHttpException(HttpResponseStatus.NOT_FOUND, "File not 
found", e);
         }
+        catch (IllegalArgumentException e)
+        {
+            throw wrapHttpException(HttpResponseStatus.FORBIDDEN, 
e.getMessage(), e);
+        }
+        catch (IOException e)
+        {
+            LOGGER.error("Filesystem error while resolving {}", path, e);
+            throw wrapHttpException(HttpResponseStatus.INTERNAL_SERVER_ERROR,
+                                    "Filesystem error while resolving 
requested path", e);
+        }
+
         if (Files.isDirectory(path))
         {
-            LOGGER.info("Cannot transfer directory. path={}.", reqPath);
-            
rc.response().setStatusCode(HttpResponseStatus.BAD_REQUEST.code()).end();
-            return true;
+            LOGGER.info("Cannot transfer directory. path={}", path);
+            throw wrapHttpException(HttpResponseStatus.BAD_REQUEST, "Cannot 
transfer directory");
         }
         if (isExcluded(path, instanceMeta))
         {
             LOGGER.debug("Requested path or one of its parent directories is 
excluded from Live Migration. " +
-                         "path={}", reqPath);
-            
rc.response().setStatusCode(HttpResponseStatus.NOT_FOUND.code()).end();
-            return true;
+                         "path={}", path);
+            throw wrapHttpException(HttpResponseStatus.NOT_FOUND,
+                                    "Requested path is excluded from live 
migration");
         }
-        return false;
     }
 
     private boolean isExcluded(Path localFile, InstanceMetadata 
instanceMetadata)
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/livemigration/LiveMigrationInstanceMetadataUtil.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/livemigration/LiveMigrationInstanceMetadataUtil.java
index 382fffdf..1fbff507 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/livemigration/LiveMigrationInstanceMetadataUtil.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/livemigration/LiveMigrationInstanceMetadataUtil.java
@@ -18,6 +18,9 @@
 
 package org.apache.cassandra.sidecar.livemigration;
 
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
@@ -34,6 +37,7 @@ import org.slf4j.LoggerFactory;
 
 import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
 import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import 
org.apache.cassandra.sidecar.exceptions.LiveMigrationExceptions.UnknownMigrationPrefixException;
 import 
org.apache.cassandra.sidecar.handlers.livemigration.LiveMigrationDirType;
 import org.jetbrains.annotations.NotNull;
 
@@ -263,14 +267,40 @@ public class LiveMigrationInstanceMetadataUtil
     }
 
     /**
-     * Converts given live migration file download URL to local path.
+     * Resolves a live migration file download URL to a local path. Performs 
only a lexical
+     * containment check; symlinks are not resolved and the file is not 
required to exist.
+     * Suitable for destination-side callers that place files into 
operator-controlled directories.
+     * Source-side callers that serve existing files in response to remote 
requests must additionally
+     * call {@link ResolvedPath#verifyContainment()} on the returned object 
before using the path.
      *
      * @param fileUrl  Live migration file download URL
      * @param metadata Cassandra instance metadata
-     * @return local path for given live migration file download URL
+     * @return lexically-resolved local path for given live migration file 
download URL
+     * @throws IllegalArgumentException if the URL is malformed or the lexical 
path escapes
+     *                                  the base directory
      */
     public static Path localPath(@NotNull String fileUrl,
                                  @NotNull InstanceMetadata metadata)
+    {
+        return resolveLexically(fileUrl, metadata).resolvedPath();
+    }
+
+    /**
+     * Lexically resolves a live migration file download URL to a {@link 
ResolvedPath}. Performs only
+     * string-level validation; the filesystem is not touched and the file is 
not required to exist.
+     *
+     * @param fileUrl  Live migration file download URL
+     * @param metadata Cassandra instance metadata
+     * @return the lexically-resolved {@link ResolvedPath}
+     * @throws IllegalArgumentException        if the URL is malformed - it 
contains a relative traversal
+     *                                         segment ({@code /../}) or 
lexically escapes the configured base directory
+     * @throws UnknownMigrationPrefixException if the URL does not match any 
configured live-migration directory
+     *                                         prefix; the URL is well-formed 
but addresses no resource on this
+     *                                         instance. This is a subtype of 
{@link IllegalArgumentException}, so
+     *                                         callers that only care about 
"bad URL" need not distinguish it
+     */
+    public static ResolvedPath resolveLexically(@NotNull String fileUrl,
+                                                @NotNull InstanceMetadata 
metadata)
     {
         Objects.requireNonNull(fileUrl, "fileUrl cannot be null");
         Objects.requireNonNull(metadata, "metadata cannot be null");
@@ -299,11 +329,76 @@ public class LiveMigrationInstanceMetadataUtil
                     throw new IllegalArgumentException(errorMessage);
                 }
 
-                return resolvedPath;
+                return new ResolvedPath(baseDir, resolvedPath);
             }
         }
 
-        throw new IllegalArgumentException("File url " + fileUrl + " is 
unknown.");
+        LOGGER.warn("File url {} does not match any configured live-migration 
directory prefix.", fileUrl);
+        throw new UnknownMigrationPrefixException("File url " + fileUrl + " is 
unknown.");
+    }
+
+    /**
+     * Lexical resolution of a live-migration URL: the configured base 
directory paired with the
+     * local path it maps to.
+     */
+    public static final class ResolvedPath
+    {
+        private final Path baseDir;
+        private final Path resolvedPath;
+
+        ResolvedPath(Path baseDir, Path resolvedPath)
+        {
+            this.baseDir = baseDir;
+            this.resolvedPath = resolvedPath;
+        }
+
+        public Path resolvedPath()
+        {
+            return resolvedPath;
+        }
+
+        /**
+         * Verifies that the source-side file represented by this {@code 
ResolvedPath} exists and
+         * that its real (symlinks resolved) path stays inside the real base 
directory path.
+         * Use this on the source side after
+         * {@link LiveMigrationInstanceMetadataUtil#resolveLexically(String, 
InstanceMetadata)} to
+         * enforce that the file the operator is about to serve cannot escape 
the configured
+         * directory through a symlink. Callers continue to use {@link 
#resolvedPath()} (the
+         * lexical form) for exclusion matching, logging, and serving, since 
exclusion patterns
+         * and operator-facing logs are configured against the lexical form.
+         *
+         * <p>Performs blocking filesystem I/O - must be called from a worker 
thread, not the event
+         * loop. Throws {@link NoSuchFileException} before any real-path 
resolution runs, so
+         * callers can distinguish "file missing" from "path escapes via 
symlink". The base directory
+         * is only resolved when the file's real path does not already start 
with it, so a base
+         * directory that is not itself a symlink costs no extra filesystem 
calls.
+         *
+         * @throws NoSuchFileException      if the resolved file does not exist
+         * @throws IOException              if {@link Path#toRealPath} fails 
for an I/O reason
+         *                                  other than missing file
+         * @throws IllegalArgumentException if the real path escapes the base 
directory
+         */
+        public void verifyContainment() throws IOException
+        {
+            if (!Files.exists(resolvedPath))
+            {
+                throw new NoSuchFileException(resolvedPath.toString());
+            }
+            Path realPath = resolvedPath.toRealPath();
+            // When the base directory is not itself behind a symlink, the 
file's real path still
+            // starts with the lexical base directory, which already proves 
containment.
+            if (realPath.startsWith(baseDir))
+            {
+                return;
+            }
+            // The base directory itself may be a symlink (for example a data 
dir pointing at a
+            // mounted volume), so compare against its real path before 
rejecting.
+            if (!realPath.startsWith(baseDir.toRealPath()))
+            {
+                LOGGER.error("Resolved path escapes base directory for {}", 
resolvedPath);
+                throw new IllegalArgumentException("Resolved path escapes base 
directory");
+            }
+        }
     }
 
     private static Map<String, String> 
migrationUrlLocalDirMap(InstanceMetadata instanceMetadata)
diff --git 
a/server/src/main/java/org/apache/cassandra/sidecar/modules/LiveMigrationModule.java
 
b/server/src/main/java/org/apache/cassandra/sidecar/modules/LiveMigrationModule.java
index 48ea12cd..5181a9f6 100644
--- 
a/server/src/main/java/org/apache/cassandra/sidecar/modules/LiveMigrationModule.java
+++ 
b/server/src/main/java/org/apache/cassandra/sidecar/modules/LiveMigrationModule.java
@@ -196,8 +196,16 @@ public class LiveMigrationModule extends AbstractModule
     responseCode = "200",
     content = @Content(mediaType = "application/json",
     schema = @Schema(implementation = DigestResponse.class)))
+    @APIResponse(responseCode = "400",
+    description = "Invalid path parameter (e.g., non-numeric directory index) 
or a directory was requested",
+    content = @Content(mediaType = "application/json",
+    schema = @Schema(type = SchemaType.OBJECT)))
+    @APIResponse(responseCode = "403",
+    description = "Resolved path is outside the configured live-migration 
directories",
+    content = @Content(mediaType = "application/json",
+    schema = @Schema(type = SchemaType.OBJECT)))
     @APIResponse(responseCode = "404",
-    description = "Live migration not enabled or node not configured as 
source",
+    description = "Live migration not enabled, node not configured as source, 
or requested file not found",
     content = @Content(mediaType = "application/json",
     schema = @Schema(type = SchemaType.OBJECT)))
     @APIResponse(responseCode = "503",
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileDigestHandlerTest.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileDigestHandlerTest.java
index 5d083891..8bf505fe 100644
--- 
a/server/src/test/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileDigestHandlerTest.java
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileDigestHandlerTest.java
@@ -20,7 +20,9 @@ package org.apache.cassandra.sidecar.handlers.livemigration;
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.util.Arrays;
@@ -278,6 +280,40 @@ public class LiveMigrationFileDigestHandlerTest
         shouldFail(context, testRoute, FIRST_INSTANCE_IP, SECOND_INSTANCE_IP, 
FIRST_INSTANCE_IP, 404);
     }
 
+    @Test
+    public void testRequestUsingDoubleEncodedDots(VertxTestContext context) 
throws IOException
+    {
+        // Double-encoded traversal: "%252e%252e%2f" decodes only once in 
Vert.x's normalizer to "%2e%2e/"
+        // ("%25" is a reserved char that is never decoded), so "%252e%252e" 
stays a literal path segment and
+        // is never collapsed into "..". Plant a secret two levels above the 
data dir (data/0 ->
+        // <tempDir>/<id>/d1/data, so "../../secret.txt" would resolve to 
<tempDir>/<id>/secret.txt) and
+        // confirm the double-encoded request cannot traverse out to digest 
it. The resolve handler runs
+        // before the digest handler, so this guard protects the digest 
endpoint too.
+        createFile("super-secret", 
tempDir.resolve(String.valueOf(FIRST_ID)).resolve("secret.txt").toString());
+
+        String testRoute = LIVE_MIGRATION_FILES_ROUTE + 
"/data/0/%252e%252e/%252e%252e/secret.txt?digestAlgorithm=md5";
+        shouldFail(context, testRoute, FIRST_INSTANCE_IP, SECOND_INSTANCE_IP, 
FIRST_INSTANCE_IP, 404);
+    }
+
+    @Test
+    public void testRouteFailsForSymlinkEscapingDataDir(VertxTestContext 
context) throws IOException
+    {
+        // A symlink inside the data dir pointing outside the migration tree 
cannot be caught by the lexical
+        // ".." containment check. realPath() resolves symlinks via 
toRealPath() and must reject the escape
+        // with 403, so no digest is computed for the file behind the symlink. 
The resolve handler runs
+        // before the digest handler, so this guard protects the digest 
endpoint too.
+        Path outside = tempDir.resolve("outside-tree");
+        Files.createDirectories(outside);
+        createFile("top-secret", outside.resolve("secret.txt").toString());
+
+        Path dataDir = Paths.get(firstInstanceDataDirs.get(0));
+        Files.createDirectories(dataDir);
+        Files.createSymbolicLink(dataDir.resolve("escape"), outside);
+
+        String testRoute = LIVE_MIGRATION_FILES_ROUTE + 
"/data/0/escape/secret.txt?digestAlgorithm=md5";
+        shouldFail(context, testRoute, FIRST_INSTANCE_IP, SECOND_INSTANCE_IP, 
FIRST_INSTANCE_IP, 403);
+    }
+
     @Test
     public void testRouteSucceedsForEmptyFile(VertxTestContext context) throws 
IOException
     {
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileStreamTest.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileStreamTest.java
index e2e40193..6a99c39e 100644
--- 
a/server/src/test/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileStreamTest.java
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileStreamTest.java
@@ -19,7 +19,9 @@
 package org.apache.cassandra.sidecar.handlers.livemigration;
 
 import java.io.IOException;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
@@ -205,14 +207,64 @@ class LiveMigrationFileStreamTest
     @Test
     public void testRequestInvalidPathUsingEncodedDots(VertxTestContext 
context) throws IOException
     {
-        String filePath = "/ks/tb-1234/ks-tb-1234-Data.db";
-        createFile(DUMMY_CONTENT, firstInstanceDataDirs.get(0) + filePath);
+        // If %2E%2E were wrongly decoded and the path were collapsed 
unchecked, three "../"
+        // segments from <tempDir>/<id>/d1/data would walk up to <tempDir>/, 
where this decoy
+        // lives. Plant it so the test actually proves the request can't reach 
it. Vert.x rejects
+        // 3+ consecutive encoded ".." patterns, so the request never reaches 
the resolver.
+        createFile("decoy", tempDir.resolve("secrets").toString());
 
-        // Vertx rejects 3+ consecutive %2E%2E (encoded ..) patterns
         shouldThrowError(context, LIVE_MIGRATION_DATA_FILE_DIR_PATH + 
"/0/%2E%2E/%2E%2E/%2E%2E/secrets",
                          FIRST_INSTANCE_IP, THIRD_INSTANCE_IP, 
FIRST_INSTANCE_IP, 404);
     }
 
+    @Test
+    public void testRequestUsingDoubleEncodedDots(VertxTestContext context) 
throws IOException
+    {
+        // Double-encoded traversal: "%252e%252e%2f" decodes only once in 
Vert.x's normalizer to "%2e%2e/"
+        // ("%25" is a reserved char that is never decoded), so "%252e%252e" 
stays a literal path segment
+        // and is never collapsed into "..". Plant a secret two levels above 
the data dir
+        // (data/0 -> <tempDir>/<id>/d1/data, so "../../secret.txt" would 
resolve to <tempDir>/<id>/secret.txt)
+        // and confirm the double-encoded request cannot traverse out to reach 
it.
+        createFile("super-secret", 
tempDir.resolve(String.valueOf(FIRST_ID)).resolve("secret.txt").toString());
+        createFile(DUMMY_CONTENT, firstInstanceDataDirs.get(0) + 
"/ks/tb-1234/ks-tb-1234-Data.db");
+
+        String testRoute = LIVE_MIGRATION_DATA_FILE_DIR_PATH + 
"/0/%252e%252e/%252e%252e/secret.txt";
+        shouldThrowError(context, testRoute, FIRST_INSTANCE_IP, 
SECOND_INSTANCE_IP, FIRST_INSTANCE_IP, 404);
+    }
+
+    @Test
+    public void testRouteFailsForSymlinkEscapingDataDir(VertxTestContext 
context) throws IOException
+    {
+        // A symlink planted inside the data dir that points outside the 
migration tree cannot be caught by
+        // the lexical ".." containment check. realPath() resolves symlinks 
via toRealPath() and rejects
+        // the escape with 403, so the file behind the symlink is not served.
+        Path outside = tempDir.resolve("outside-tree");
+        Files.createDirectories(outside);
+        createFile("top-secret", outside.resolve("secret.txt").toString());
+
+        Path dataDir = Paths.get(firstInstanceDataDirs.get(0));
+        Files.createDirectories(dataDir);
+        Files.createSymbolicLink(dataDir.resolve("escape"), outside);
+
+        String testRoute = LIVE_MIGRATION_DATA_FILE_DIR_PATH + 
"/0/escape/secret.txt";
+        shouldThrowError(context, testRoute, FIRST_INSTANCE_IP, 
SECOND_INSTANCE_IP, FIRST_INSTANCE_IP, 403);
+    }
+
+    @Test
+    public void testRouteSucceedsForSymlinkWithinDataDir(VertxTestContext 
context) throws IOException
+    {
+        // A symlink that resolves to a target still inside the data dir is 
legitimate and must be served -
+        // the real-path containment check must not over-block symlinks within 
the migration tree.
+        String filePath = "/ks/tb-1234/ks-tb-1234-Data.db";
+        createFile(DUMMY_CONTENT, firstInstanceDataDirs.get(0) + filePath);
+
+        Path dataDir = Paths.get(firstInstanceDataDirs.get(0));
+        Files.createSymbolicLink(dataDir.resolve("link-ks"), 
dataDir.resolve("ks"));
+
+        String testRoute = LIVE_MIGRATION_DATA_FILE_DIR_PATH + 
"/0/link-ks/tb-1234/ks-tb-1234-Data.db";
+        shouldSucceed(context, testRoute, FIRST_INSTANCE_IP, 
SECOND_INSTANCE_IP, FIRST_INSTANCE_IP);
+    }
+
 
     @Test
     public void testRequestDirectory(VertxTestContext context) throws 
IOException
@@ -263,7 +315,9 @@ class LiveMigrationFileStreamTest
         String filePath = "/ks/tb-1234/ks-tb-1234-Data.db";
         createFile(DUMMY_CONTENT, firstInstanceDataDirs.get(0) + filePath);
 
-        // This route has 100 as dataHomeDir index which is greater than 
instance data home dir count. It should fail.
+        // dirIndex 100 is greater than the configured data dir count, so the 
URL doesn't match
+        // any configured prefix - reports it as a missing resource (404), 
since
+        // the URL is well-formed but addresses no directory configured on 
this instance.
         shouldThrowError(context, LIVE_MIGRATION_DATA_FILE_DIR_PATH + "/100" + 
filePath,
                          FIRST_INSTANCE_IP, THIRD_INSTANCE_IP, 
FIRST_INSTANCE_IP, 404);
     }
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/livemigration/LiveMigrationInstanceMetadataUtilTest.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/livemigration/LiveMigrationInstanceMetadataUtilTest.java
index 1c4b3854..4c65c0bf 100644
--- 
a/server/src/test/java/org/apache/cassandra/sidecar/livemigration/LiveMigrationInstanceMetadataUtilTest.java
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/livemigration/LiveMigrationInstanceMetadataUtilTest.java
@@ -18,7 +18,12 @@
 
 package org.apache.cassandra.sidecar.livemigration;
 
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
@@ -31,6 +36,7 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
 import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import 
org.apache.cassandra.sidecar.exceptions.LiveMigrationExceptions.UnknownMigrationPrefixException;
 import org.mockito.Mockito;
 
 import static 
org.apache.cassandra.sidecar.handlers.livemigration.InstanceMetadataTestUtil.LIVE_MIGRATION_CDC_RAW_DIR_PATH;
@@ -40,6 +46,7 @@ import static 
org.apache.cassandra.sidecar.handlers.livemigration.InstanceMetada
 import static 
org.apache.cassandra.sidecar.handlers.livemigration.InstanceMetadataTestUtil.LIVE_MIGRATION_LOCAL_SYSTEM_DATA_FILE_DIR_PATH;
 import static 
org.apache.cassandra.sidecar.handlers.livemigration.InstanceMetadataTestUtil.LIVE_MIGRATION_SAVED_CACHES_DIR_PATH;
 import static 
org.apache.cassandra.sidecar.livemigration.LiveMigrationInstanceMetadataUtil.localPath;
+import static 
org.apache.cassandra.sidecar.livemigration.LiveMigrationInstanceMetadataUtil.resolveLexically;
 import static 
org.apache.cassandra.sidecar.livemigration.LiveMigrationPlaceholderUtil.CDC_RAW_DIR_PLACEHOLDER;
 import static 
org.apache.cassandra.sidecar.livemigration.LiveMigrationPlaceholderUtil.COMMITLOG_DIR_PLACEHOLDER;
 import static 
org.apache.cassandra.sidecar.livemigration.LiveMigrationPlaceholderUtil.DATA_FILE_DIR_PLACEHOLDER;
@@ -47,6 +54,7 @@ import static 
org.apache.cassandra.sidecar.livemigration.LiveMigrationPlaceholde
 import static 
org.apache.cassandra.sidecar.livemigration.LiveMigrationPlaceholderUtil.LOCAL_SYSTEM_DATA_FILE_DIR_PLACEHOLDER;
 import static 
org.apache.cassandra.sidecar.livemigration.LiveMigrationPlaceholderUtil.SAVED_CACHES_DIR_PLACEHOLDER;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
 import static 
org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
 import static org.assertj.core.api.Assertions.entry;
 import static org.mockito.Mockito.when;
@@ -308,6 +316,118 @@ class LiveMigrationInstanceMetadataUtilTest
                                  instanceMetadata);
     }
 
+    @Test
+    public void testLocalPathDoesNotEscapeForEncodedTraversal()
+    {
+        // Vert.x's normalizedPath() does a single unreserved-char decode 
followed by dot-segment removal.
+        // A double-encoded "%252e%252e" survives as the literal "%252e%252e" 
(because "%25" is reserved and
+        // is never decoded), and an encoded slash "%2f" stays encoded so 
"..%2f..%2f" is a single literal
+        // path element rather than parent-directory segments. In both cases 
the sequence reaches localPath()
+        // as ordinary file-name text, so the resolved path must stay *inside* 
the data directory and never
+        // escape it. This locks in that the reported double-encoding bypass 
cannot traverse out of the root.
+        String cassandraHomeDir = 
tempDir.resolve("encodedTraversal").toString();
+        InstanceMetadata instanceMetadata = 
getInstanceMetadata(cassandraHomeDir);
+        String dataDir = instanceMetadata.dataDirs().get(0);
+
+        // Double-encoded ".." segments (what survives Vert.x normalization 
for %252e%252e input)
+        // resolve as literal directory names inside the data dir, not as 
parent-directory hops.
+        assertThat(localPath(LIVE_MIGRATION_DATA_FILE_DIR_PATH + 
"/0/%252e%252e/%252e%252e/etc/passwd",
+                             instanceMetadata).toString())
+        .isEqualTo(dataDir + "/%252e%252e/%252e%252e/etc/passwd");
+
+        // Encoded-slash sequence (what survives normalization for %2e%2e%2f 
input) stays a single literal
+        // path element, so it too resolves inside the data dir.
+        assertThat(localPath(LIVE_MIGRATION_DATA_FILE_DIR_PATH + 
"/0/ks/..%2f..%2fetc/passwd",
+                             instanceMetadata).toString())
+        .isEqualTo(dataDir + "/ks/..%2f..%2fetc/passwd");
+    }
+
+    @Test
+    public void testRealPathRejectsSymlinkEscape() throws IOException
+    {
+        // A symlink inside the data dir pointing outside the migration tree 
must be rejected by the
+        // realpath-based containment check; the lexical localPath() check 
alone cannot detect this.
+        String cassandraHomeDir = tempDir.resolve("symlinkEscape").toString();
+        InstanceMetadata instanceMetadata = 
getInstanceMetadata(cassandraHomeDir);
+
+        Path outside = tempDir.resolve("outside-tree");
+        Files.createDirectories(outside);
+        Files.write(outside.resolve("secret.txt"), 
"secret".getBytes(StandardCharsets.UTF_8));
+
+        Path dataDir = Paths.get(instanceMetadata.dataDirs().get(0));
+        Files.createDirectories(dataDir);
+        Files.createSymbolicLink(dataDir.resolve("escape"), outside);
+
+        assertThatIllegalArgumentException()
+        .isThrownBy(() -> resolveLexically(
+                          LIVE_MIGRATION_DATA_FILE_DIR_PATH + 
"/0/escape/secret.txt", instanceMetadata)
+                          .verifyContainment());
+    }
+
+    @Test
+    public void testRealPathReturnsLexicalForSymlinkWithinDir() throws 
IOException
+    {
+        // A symlink whose target is still inside the data dir is legitimate; 
verifyContainment
+        // accepts it without throwing, and resolveLexically returns the 
lexical (URL-derived)
+        // path so downstream consumers operate against the configured 
directory namespace.
+        String cassandraHomeDir = tempDir.resolve("symlinkWithin").toString();
+        InstanceMetadata instanceMetadata = 
getInstanceMetadata(cassandraHomeDir);
+
+        Path dataDir = Paths.get(instanceMetadata.dataDirs().get(0));
+        Files.createDirectories(dataDir.resolve("ks"));
+        Files.write(dataDir.resolve("ks/file.db"), 
"data".getBytes(StandardCharsets.UTF_8));
+        Files.createSymbolicLink(dataDir.resolve("link-ks"), 
dataDir.resolve("ks"));
+
+        LiveMigrationInstanceMetadataUtil.ResolvedPath resolved =
+            resolveLexically(LIVE_MIGRATION_DATA_FILE_DIR_PATH + 
"/0/link-ks/file.db", instanceMetadata);
+        resolved.verifyContainment();
+        
assertThat(resolved.resolvedPath()).isEqualTo(dataDir.resolve("link-ks/file.db"));
+    }
+
+    @Test
+    public void testRealPathThrowsForNonExistentPath()
+    {
+        // verifyContainment does an explicit Files.exists check before 
toRealPath and throws
+        // NoSuchFileException when the resolved file is missing, so callers 
can distinguish
+        // "file not found" cleanly from "path escapes base directory" 
(IllegalArgumentException).
+        String cassandraHomeDir = tempDir.resolve("nonexistent").toString();
+        InstanceMetadata instanceMetadata = 
getInstanceMetadata(cassandraHomeDir);
+
+        assertThatExceptionOfType(NoSuchFileException.class)
+        .isThrownBy(() -> resolveLexically(
+                          LIVE_MIGRATION_DATA_FILE_DIR_PATH + "/0/ks/foo.db", 
instanceMetadata)
+                          .verifyContainment());
+    }
+
+    @Test
+    public void testRealPathRejectsLexicalTraversal()
+    {
+        // resolveLexically rejects /../ patterns up-front, before any 
filesystem I/O runs — same
+        // guard that localPath uses.
+        String cassandraHomeDir = tempDir.resolve("lexicalEscape").toString();
+        InstanceMetadata instanceMetadata = 
getInstanceMetadata(cassandraHomeDir);
+
+        assertThatIllegalArgumentException()
+        .isThrownBy(() -> resolveLexically(
+                          LIVE_MIGRATION_DATA_FILE_DIR_PATH + 
"/0/../../etc/passwd", instanceMetadata));
+    }
+
+    @Test
+    public void testResolveLexicallyThrowsForUnknownPrefix()
+    {
+        // A well-formed URL whose prefix matches no configured directory is 
reported via
+        // UnknownMigrationPrefixException. It is a subtype of 
IllegalArgumentException (so destination-side
+        // localPath callers keep treating it as a bad argument), while the 
source-side handler catches this
+        // specific type to answer 404 - "no such resource" - instead of 400 
for a malformed URL.
+        String cassandraHomeDir = tempDir.resolve("unknownPrefix").toString();
+        InstanceMetadata instanceMetadata = 
getInstanceMetadata(cassandraHomeDir);
+        when(instanceMetadata.cdcDir()).thenReturn(null);
+
+        assertThatExceptionOfType(UnknownMigrationPrefixException.class)
+        .isThrownBy(() -> resolveLexically(LIVE_MIGRATION_CDC_RAW_DIR_PATH + 
"/0/" + FILE_NAME, instanceMetadata))
+        .isInstanceOf(IllegalArgumentException.class);
+    }
+
     @Test
     public void testLocalPathNonExistingDirs()
     {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to