Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
0.5.0
-----
* Tighten path containment in Live Migration file route (CASSSIDECAR-479)
* Implement durable operational job tracker (CASSSIDECAR-374)
* Remove filesystem path from Http response (CASSSIDECAR-477)
* Add basic configuration retrieval logic to ConfigurationManager (CASSSIDECAR-427)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ public LiveMigrationTaskNotFoundException(String message)
}
}

/**
* 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,16 +42,17 @@
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;
Expand All @@ -60,6 +61,7 @@
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.
Expand Down Expand Up @@ -94,7 +96,7 @@ protected Void extractParamsOrThrow(RoutingContext context)
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);
Expand All @@ -105,11 +107,11 @@ protected Void extractParamsOrThrow(RoutingContext context)
}
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.
Expand All @@ -123,65 +125,99 @@ protected void handleInternal(RoutingContext rc,
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;

ResolvedPath resolved;
try
{
localFile = LiveMigrationInstanceMetadataUtil.localPath(normalizedPath, instanceMeta).toString();
resolved = LiveMigrationInstanceMetadataUtil.resolveLexically(normalizedPath, instanceMeta);
}
catch (UnknownMigrationPrefixException e)
{
// The URL is well-formed but matches no configured live-migration directory on this
// instance, so it addresses no resource here - report it as not found.
rc.fail(wrapHttpException(HttpResponseStatus.NOT_FOUND, e.getMessage(), e));
return;
}
catch (IllegalArgumentException e)
{
LOGGER.warn("Invalid path", e);
rc.response().setStatusCode(HttpResponseStatus.NOT_FOUND.code()).end();
// URL must have been malformed, report it as bad request
rc.fail(wrapHttpException(HttpResponseStatus.BAD_REQUEST, e.getMessage(), e));
return;
}

Path path = Paths.get(localFile);

// 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(() -> isInvalidPath(rc, path, reqPath, instanceMeta))
.onSuccess(invalid -> {
if (!invalid)
{
rc.put(FileStreamHandler.FILE_PATH_CONTEXT_KEY, localFile);
rc.next();
}
});
.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));
}

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,12 +31,15 @@
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import org.slf4j.Logger;
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;

Expand All @@ -58,6 +64,14 @@ public class LiveMigrationInstanceMetadataUtil
{
private static final Logger LOGGER = LoggerFactory.getLogger(LiveMigrationInstanceMetadataUtil.class);

/**
* Caches the canonical (symlinks resolved) form of each base directory keyed by its lexical
* path. Base directories come from {@link InstanceMetadata}, which is fixed at startup, so the
* canonical form is stable for the process lifetime. The set of distinct base directories is
* bounded by configuration (~5–10 per instance), so no eviction is required.
*/
private static final ConcurrentMap<Path, Path> BASE_DIR_CANONICAL = new ConcurrentHashMap<>();
Comment on lines +67 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that the LiveMigrationInstanceMetadataUtil is a static helper already. But the static map BASE_DIR_CANONICAL catches my attention. I have 2 suggestions.

  1. It is common in this project to inject util via guice. Can we do the same, instead of an unmanaged global object out side of guice?
  2. To harden the assumption on the size, can we set a size limit and emit warning if exceeding certain size (say 10) when inserting into the map.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class is a utility class and all its methods are static, so Guice cannot be used. Compared to all other work done during downloading, I think the save by this cache is very minimal. For simplicity, I can remove the cache entirely.


/**
* Encapsulates all metadata for a specific directory instance in live migration.
* Each descriptor represents one physical directory with its associated index, URL route, and placeholder.
Expand Down Expand Up @@ -263,14 +277,52 @@ public static Map<String, Set<String>> placeholderDirsMap(InstanceMetadata insta
}

/**
* 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();
}

private static Path canonicalBaseDir(Path baseDir) throws IOException
{
Path cached = BASE_DIR_CANONICAL.get(baseDir);
if (cached != null)
{
return cached;
}
Path canonical = baseDir.toRealPath();
Path existing = BASE_DIR_CANONICAL.putIfAbsent(baseDir, canonical);
return existing != null ? existing : canonical;
}

/**
* 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");
Expand Down Expand Up @@ -299,11 +351,66 @@ public static Path localPath(@NotNull String fileUrl,
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 canonical (symlinks resolved) path stays inside the canonical base directory.
* 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 canonical-path resolution runs, so
* callers can distinguish "file missing" from "path escapes via symlink".
*
* @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 canonical path escapes the base directory
*/
public void verifyContainment() throws IOException
{
if (!Files.exists(resolvedPath))
{
throw new NoSuchFileException(resolvedPath.toString());
}
Path canonical = resolvedPath.toRealPath();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like toRealPath already throws NoSuchFileException when the file does not exist. The above check is redundant.

@throws IOException – if the file does not exist or an I/O error occurs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@throws IOException – if the file does not exist or an I/O error occurs

Documentation says IOException, and implementation may throw just IOException and not NoSuchFileException. So, checking for file existence and throwing NoSuchFileException explicitly.

if (!canonical.startsWith(canonicalBaseDir(baseDir)))
{
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)
Expand Down
Loading
Loading