Skip to content

Commit 9147366

Browse files
committed
CASSSIDECAR-479 Harden the live migration file route against symlink-based path traversal by validating that the canonical resolved path stays within the configured directory. Return clearer HTTP status codes - 400 for malformed URLs, 403 for symlink escapes, 404 for missing or excluded files - with consistent JSON error responses. Preserve operator-configured directory paths in exclusion matching and logs so behavior stays predictable when data dirs sit behind symlinks.
1 parent cdf251b commit 9147366

6 files changed

Lines changed: 364 additions & 44 deletions

File tree

server/src/main/java/org/apache/cassandra/sidecar/handlers/livemigration/LiveMigrationFileResolveHandler.java

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@
1818

1919
package org.apache.cassandra.sidecar.handlers.livemigration;
2020

21-
import java.net.URLDecoder;
22-
import java.nio.charset.StandardCharsets;
21+
import java.io.IOException;
22+
import java.net.URI;
2323
import java.nio.file.FileSystems;
2424
import java.nio.file.Files;
25+
import java.nio.file.NoSuchFileException;
2526
import java.nio.file.Path;
2627
import java.nio.file.PathMatcher;
27-
import java.nio.file.Paths;
2828
import java.util.ArrayList;
2929
import java.util.List;
3030
import java.util.Map;
@@ -42,7 +42,6 @@
4242
import io.vertx.core.net.SocketAddress;
4343
import io.vertx.ext.auth.authorization.Authorization;
4444
import io.vertx.ext.web.RoutingContext;
45-
import io.vertx.ext.web.handler.HttpException;
4645
import org.apache.cassandra.sidecar.acl.authorization.BasicPermissions;
4746
import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
4847
import org.apache.cassandra.sidecar.concurrent.ExecutorPools;
@@ -52,6 +51,7 @@
5251
import org.apache.cassandra.sidecar.handlers.AccessProtected;
5352
import org.apache.cassandra.sidecar.handlers.FileStreamHandler;
5453
import org.apache.cassandra.sidecar.livemigration.LiveMigrationInstanceMetadataUtil;
54+
import org.apache.cassandra.sidecar.livemigration.LiveMigrationInstanceMetadataUtil.ResolvedPath;
5555
import org.apache.cassandra.sidecar.utils.CassandraInputValidator;
5656
import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher;
5757
import org.jetbrains.annotations.NotNull;
@@ -60,6 +60,7 @@
6060
import static org.apache.cassandra.sidecar.common.ApiEndpointsV1.DIR_INDEX_PARAM;
6161
import static org.apache.cassandra.sidecar.common.ApiEndpointsV1.DIR_TYPE_PARAM;
6262
import static org.apache.cassandra.sidecar.livemigration.LiveMigrationPlaceholderUtil.replacePlaceholder;
63+
import static org.apache.cassandra.sidecar.utils.HttpExceptions.wrapHttpException;
6364

6465
/**
6566
* Handler that resolves and validates file paths for live migration operations.
@@ -94,7 +95,7 @@ protected Void extractParamsOrThrow(RoutingContext context)
9495
String dirType = context.pathParam(DIR_TYPE_PARAM);
9596
if (null == dirType || dirType.isEmpty() || null == LiveMigrationDirType.find(dirType))
9697
{
97-
throw new HttpException(HttpResponseStatus.BAD_REQUEST.code(), "Invalid directory type: " + dirType);
98+
throw wrapHttpException(HttpResponseStatus.BAD_REQUEST, "Invalid directory type: " + dirType);
9899
}
99100

100101
String dirIndex = context.pathParam(DIR_INDEX_PARAM);
@@ -105,11 +106,11 @@ protected Void extractParamsOrThrow(RoutingContext context)
105106
}
106107
catch (NumberFormatException formatException)
107108
{
108-
throw new HttpException(HttpResponseStatus.BAD_REQUEST.code(), "Invalid directoryIndex: " + dirIndex);
109+
throw wrapHttpException(HttpResponseStatus.BAD_REQUEST, "Invalid directoryIndex: " + dirIndex);
109110
}
110111
if (index < 0)
111112
{
112-
throw new HttpException(HttpResponseStatus.BAD_REQUEST.code(), "Invalid directoryIndex: " + dirIndex);
113+
throw wrapHttpException(HttpResponseStatus.BAD_REQUEST, "Invalid directoryIndex: " + dirIndex);
113114
}
114115

115116
// Path params are not used further, hence returning null.
@@ -123,65 +124,91 @@ protected void handleInternal(RoutingContext rc,
123124
SocketAddress remoteAddress,
124125
@Nullable Void request)
125126
{
126-
String reqPath = URLDecoder.decode(rc.request().path(), StandardCharsets.UTF_8);
127+
String reqPath;
128+
try
129+
{
130+
reqPath = URI.create(rc.request().path()).getPath();
131+
}
132+
catch (IllegalArgumentException e)
133+
{
134+
rc.fail(wrapHttpException(HttpResponseStatus.BAD_REQUEST, "Malformed request path", e));
135+
return;
136+
}
127137

128138
if (reqPath.contains("/../") || reqPath.endsWith("/.."))
129139
{
130140
LOGGER.warn("Tried to access file using relative path({}). Rejecting the request.", reqPath);
131-
rc.response().setStatusCode(HttpResponseStatus.BAD_REQUEST.code()).end();
141+
rc.fail(wrapHttpException(HttpResponseStatus.BAD_REQUEST,
142+
"Tried to access file using relative path: " + reqPath));
132143
return;
133144
}
134145

135146
InstanceMetadata instanceMeta = metadataFetcher.instance(host);
136147
String normalizedPath = rc.normalizedPath();
137-
String localFile;
138148

149+
ResolvedPath resolved;
139150
try
140151
{
141-
localFile = LiveMigrationInstanceMetadataUtil.localPath(normalizedPath, instanceMeta).toString();
152+
resolved = LiveMigrationInstanceMetadataUtil.resolveLexically(normalizedPath, instanceMeta);
142153
}
143154
catch (IllegalArgumentException e)
144155
{
145-
LOGGER.warn("Invalid path", e);
146-
rc.response().setStatusCode(HttpResponseStatus.NOT_FOUND.code()).end();
156+
rc.fail(wrapHttpException(HttpResponseStatus.BAD_REQUEST, e.getMessage(), e));
147157
return;
148158
}
149159

150-
Path path = Paths.get(localFile);
151-
160+
// Only the filesystem-touching checks (verifyContainment, isDirectory, isExcluded) run on
161+
// the worker thread; the lexical resolve above is pure string work and stays on the event
162+
// loop. validate() throws HttpException on any failure, which flows through processFailure
163+
// -> context.fail() so the framework's failure handler renders a JSON error response.
164+
String localFile = resolved.resolvedPath().toString();
152165
executorPools.service()
153-
.executeBlocking(() -> isInvalidPath(rc, path, reqPath, instanceMeta))
154-
.onSuccess(invalid -> {
155-
if (!invalid)
156-
{
157-
rc.put(FileStreamHandler.FILE_PATH_CONTEXT_KEY, localFile);
158-
rc.next();
159-
}
160-
});
166+
.executeBlocking(() -> {
167+
validate(resolved, instanceMeta);
168+
return localFile;
169+
})
170+
.onSuccess(file -> {
171+
rc.put(FileStreamHandler.FILE_PATH_CONTEXT_KEY, file);
172+
rc.next();
173+
})
174+
.onFailure(cause -> processFailure(cause, rc, host, remoteAddress, request));
161175
}
162176

163-
private boolean isInvalidPath(RoutingContext rc, Path path, String reqPath, InstanceMetadata instanceMeta)
177+
private void validate(ResolvedPath resolved, InstanceMetadata instanceMeta)
164178
{
165-
if (!Files.exists(path))
179+
Path path = resolved.resolvedPath();
180+
try
181+
{
182+
resolved.verifyContainment();
183+
}
184+
catch (NoSuchFileException e)
166185
{
167186
LOGGER.info("Requested file is not found. file={}", path);
168-
rc.response().setStatusCode(HttpResponseStatus.NOT_FOUND.code()).end();
169-
return true;
187+
throw wrapHttpException(HttpResponseStatus.NOT_FOUND, "File not found", e);
188+
}
189+
catch (IllegalArgumentException e)
190+
{
191+
throw wrapHttpException(HttpResponseStatus.FORBIDDEN, e.getMessage(), e);
170192
}
193+
catch (IOException e)
194+
{
195+
LOGGER.error("Filesystem error while resolving {}", path, e);
196+
throw wrapHttpException(HttpResponseStatus.INTERNAL_SERVER_ERROR,
197+
"Filesystem error while resolving requested path", e);
198+
}
199+
171200
if (Files.isDirectory(path))
172201
{
173-
LOGGER.info("Cannot transfer directory. path={}.", reqPath);
174-
rc.response().setStatusCode(HttpResponseStatus.BAD_REQUEST.code()).end();
175-
return true;
202+
LOGGER.info("Cannot transfer directory. path={}", path);
203+
throw wrapHttpException(HttpResponseStatus.BAD_REQUEST, "Cannot transfer directory");
176204
}
177205
if (isExcluded(path, instanceMeta))
178206
{
179207
LOGGER.debug("Requested path or one of its parent directories is excluded from Live Migration. " +
180-
"path={}", reqPath);
181-
rc.response().setStatusCode(HttpResponseStatus.NOT_FOUND.code()).end();
182-
return true;
208+
"path={}", path);
209+
throw wrapHttpException(HttpResponseStatus.NOT_FOUND,
210+
"Requested path is excluded from live migration");
183211
}
184-
return false;
185212
}
186213

187214
private boolean isExcluded(Path localFile, InstanceMetadata instanceMetadata)

server/src/main/java/org/apache/cassandra/sidecar/livemigration/LiveMigrationInstanceMetadataUtil.java

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919
package org.apache.cassandra.sidecar.livemigration;
2020

21+
import java.io.IOException;
22+
import java.nio.file.Files;
23+
import java.nio.file.NoSuchFileException;
2124
import java.nio.file.Path;
2225
import java.nio.file.Paths;
2326
import java.util.ArrayList;
@@ -28,6 +31,8 @@
2831
import java.util.Map;
2932
import java.util.Objects;
3033
import java.util.Set;
34+
import java.util.concurrent.ConcurrentHashMap;
35+
import java.util.concurrent.ConcurrentMap;
3136

3237
import org.slf4j.Logger;
3338
import org.slf4j.LoggerFactory;
@@ -58,6 +63,14 @@ public class LiveMigrationInstanceMetadataUtil
5863
{
5964
private static final Logger LOGGER = LoggerFactory.getLogger(LiveMigrationInstanceMetadataUtil.class);
6065

66+
/**
67+
* Caches the canonical (symlinks resolved) form of each base directory keyed by its lexical
68+
* path. Base directories come from {@link InstanceMetadata}, which is fixed at startup, so the
69+
* canonical form is stable for the process lifetime. The set of distinct base directories is
70+
* bounded by configuration (~5–10 per instance), so no eviction is required.
71+
*/
72+
private static final ConcurrentMap<Path, Path> BASE_DIR_CANONICAL = new ConcurrentHashMap<>();
73+
6174
/**
6275
* Encapsulates all metadata for a specific directory instance in live migration.
6376
* Each descriptor represents one physical directory with its associated index, URL route, and placeholder.
@@ -263,14 +276,38 @@ public static Map<String, Set<String>> placeholderDirsMap(InstanceMetadata insta
263276
}
264277

265278
/**
266-
* Converts given live migration file download URL to local path.
279+
* Resolves a live migration file download URL to a local path. Performs only a lexical
280+
* containment check; symlinks are not resolved and the file is not required to exist.
281+
* Suitable for destination-side callers that place files into operator-controlled directories.
282+
* Source-side callers that serve existing files in response to remote requests must additionally
283+
* call {@link ResolvedPath#verifyContainment()} on the returned object before using the path.
267284
*
268285
* @param fileUrl Live migration file download URL
269286
* @param metadata Cassandra instance metadata
270-
* @return local path for given live migration file download URL
287+
* @return lexically-resolved local path for given live migration file download URL
288+
* @throws IllegalArgumentException if the URL is malformed or the lexical path escapes
289+
* the base directory
271290
*/
272291
public static Path localPath(@NotNull String fileUrl,
273292
@NotNull InstanceMetadata metadata)
293+
{
294+
return resolveLexically(fileUrl, metadata).resolvedPath();
295+
}
296+
297+
private static Path canonicalBaseDir(Path baseDir) throws IOException
298+
{
299+
Path cached = BASE_DIR_CANONICAL.get(baseDir);
300+
if (cached != null)
301+
{
302+
return cached;
303+
}
304+
Path canonical = baseDir.toRealPath();
305+
Path existing = BASE_DIR_CANONICAL.putIfAbsent(baseDir, canonical);
306+
return existing != null ? existing : canonical;
307+
}
308+
309+
public static ResolvedPath resolveLexically(@NotNull String fileUrl,
310+
@NotNull InstanceMetadata metadata)
274311
{
275312
Objects.requireNonNull(fileUrl, "fileUrl cannot be null");
276313
Objects.requireNonNull(metadata, "metadata cannot be null");
@@ -299,13 +336,68 @@ public static Path localPath(@NotNull String fileUrl,
299336
throw new IllegalArgumentException(errorMessage);
300337
}
301338

302-
return resolvedPath;
339+
return new ResolvedPath(baseDir, resolvedPath);
303340
}
304341
}
305342

343+
LOGGER.warn("File url {} does not match any configured live-migration directory prefix.", fileUrl);
306344
throw new IllegalArgumentException("File url " + fileUrl + " is unknown.");
307345
}
308346

347+
/**
348+
* Lexical resolution of a live-migration URL: the configured base directory paired with the
349+
* local path it maps to.
350+
*/
351+
public static final class ResolvedPath
352+
{
353+
private final Path baseDir;
354+
private final Path resolvedPath;
355+
356+
ResolvedPath(Path baseDir, Path resolvedPath)
357+
{
358+
this.baseDir = baseDir;
359+
this.resolvedPath = resolvedPath;
360+
}
361+
362+
public Path resolvedPath()
363+
{
364+
return resolvedPath;
365+
}
366+
367+
/**
368+
* Verifies that the source-side file represented by this {@code ResolvedPath} exists and
369+
* that its canonical (symlinks resolved) path stays inside the canonical base directory.
370+
* Use this on the source side after
371+
* {@link LiveMigrationInstanceMetadataUtil#resolveLexically(String, InstanceMetadata)} to
372+
* enforce that the file the operator is about to serve cannot escape the configured
373+
* directory through a symlink. Callers continue to use {@link #resolvedPath()} (the
374+
* lexical form) for exclusion matching, logging, and serving, since exclusion patterns
375+
* and operator-facing logs are configured against the lexical form.
376+
*
377+
* <p>Performs blocking filesystem I/O - must be called from a worker thread, not the event
378+
* loop. Throws {@link NoSuchFileException} before any canonical-path resolution runs, so
379+
* callers can distinguish "file missing" from "path escapes via symlink".
380+
*
381+
* @throws NoSuchFileException if the resolved file does not exist
382+
* @throws IOException if {@link Path#toRealPath} fails for an I/O reason
383+
* other than missing file
384+
* @throws IllegalArgumentException if the canonical path escapes the base directory
385+
*/
386+
public void verifyContainment() throws IOException
387+
{
388+
if (!Files.exists(resolvedPath))
389+
{
390+
throw new NoSuchFileException(resolvedPath.toString());
391+
}
392+
Path canonical = resolvedPath.toRealPath();
393+
if (!canonical.startsWith(canonicalBaseDir(baseDir)))
394+
{
395+
LOGGER.error("Resolved path escapes base directory for {}", resolvedPath);
396+
throw new IllegalArgumentException("Resolved path escapes base directory");
397+
}
398+
}
399+
}
400+
309401
private static Map<String, String> migrationUrlLocalDirMap(InstanceMetadata instanceMetadata)
310402
{
311403
Map<String, String> urlToLocalDirMap = new HashMap<>();

server/src/main/java/org/apache/cassandra/sidecar/modules/LiveMigrationModule.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,16 @@ VertxRoute getAllDataCopyTasksRoute(RouteBuilder.Factory factory,
196196
responseCode = "200",
197197
content = @Content(mediaType = "application/json",
198198
schema = @Schema(implementation = DigestResponse.class)))
199+
@APIResponse(responseCode = "400",
200+
description = "Invalid path parameter (e.g., non-numeric directory index) or a directory was requested",
201+
content = @Content(mediaType = "application/json",
202+
schema = @Schema(type = SchemaType.OBJECT)))
203+
@APIResponse(responseCode = "403",
204+
description = "Resolved path is outside the configured live-migration directories",
205+
content = @Content(mediaType = "application/json",
206+
schema = @Schema(type = SchemaType.OBJECT)))
199207
@APIResponse(responseCode = "404",
200-
description = "Live migration not enabled or node not configured as source",
208+
description = "Live migration not enabled, node not configured as source, or requested file not found",
201209
content = @Content(mediaType = "application/json",
202210
schema = @Schema(type = SchemaType.OBJECT)))
203211
@APIResponse(responseCode = "503",

0 commit comments

Comments
 (0)