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)
* 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)
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 @@ -34,6 +37,7 @@

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 Down Expand Up @@ -263,14 +267,40 @@ 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();
}

/**
* 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 +329,76 @@ 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 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,16 @@ VertxRoute getAllDataCopyTasksRoute(RouteBuilder.Factory factory,
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",
Expand Down
Loading