Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix path traversal in Java and Spring Boot

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Compare real Path objects: base = baseDir.toRealPath(), target = base.resolve(name).toRealPath(), and serve only if target.startsWith(base). toRealPath() collapses ../ and follows symlinks, and Path.startsWith compares whole name elements, so /srv/files-secret fails. Answer 404 otherwise. Do not strip "../" or call String.startsWith on path strings.

Stack
  • Language Java 21
  • Runtime OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
  • Framework Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 21 KB
On this page

Steps

  1. Find every Files.*, new File, Paths.get, Path.resolve and Resource lookup whose path includes request data (@RequestParam, @PathVariable, request bodies or MultipartFile.getOriginalFilename()).
  2. Resolve the base directory with toRealPath(), then resolve the request against it and call toRealPath() on the result; it throws NoSuchFileException for a missing file.
  3. Serve the file only if target.startsWith(base), comparing Path objects, and only if it is a regular file. Answer the same 404 for refused and missing paths so the response does not reveal what exists outside the folder.
  4. Reject an empty path and a NUL byte with 400 before touching the file system: java.nio.file throws InvalidPathException on a NUL byte.
  5. Add regression tests with this card's payloads (MockMvc against the real store): ../, %2e%2e%2f, an absolute path, ..\, the files-secret sibling, a symlink created in a temporary directory, and ....//.
  6. When users only pick from known files, map an ID to a server-side path instead of accepting a path at all.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/files/impl/VulnerableFileStore.javaJava
@Override
public Optional<byte[]> read(String requestedPath) throws IOException {
    return Optional.of(Files.readAllBytes(baseDir.resolve(requestedPath)));
}

With IMPL=vulnerable, 5 of 12 tests fail (5 exploit tests).

The fix

Fixed…/files/impl/FixedFileStore.javaJava
@Override
public Optional<byte[]> read(String requestedPath) throws IOException {
    // toRealPath() returns an absolute path with ../ collapsed and every symlink followed.
    // Resolve the base the same way: on macOS, for example, /var is a link to /private/var.
    Path base = baseDir.toRealPath();
    // resolve() lets an absolute requestedPath replace base; the check below catches it.
    // toRealPath() throws NoSuchFileException for a missing file, which the controller maps to 404.
    Path target = base.resolve(requestedPath).toRealPath();
    // Path.startsWith compares whole name elements, so /srv/files-secret is not under /srv/files.
    // Serve regular files only: reading a directory would fail with a bare IOException.
    if (!target.startsWith(base) || !Files.isRegularFile(target)) {
        return Optional.empty();
    }
    return Optional.of(Files.readAllBytes(target));
}

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fix…/files/impl/WrongFileStore.javaJava
@Override
public Optional<byte[]> read(String requestedPath) throws IOException {
    String cleaned = requestedPath.replace("../", "");
    return Optional.of(Files.readAllBytes(baseDir.resolve(cleaned)));
}

With IMPL=wrong, 3 of 12 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Test…/example/files/FileApiTest.javaJava
// Exploit: each payload aims at a real file outside files/, so a leak would come back as a 200.

@Test
void exploitDotDotSlashCannotClimbOutOfTheBaseDirectory() throws Exception {
    assertNotServed(requestFile("../secret.txt"));
}

@Test
void exploitPercentEncodedDotDotSlashIsDecodedAndStillRefused() throws Exception {
    assertNotServed(requestRaw("path=%2e%2e%2fsecret.txt"));
}

@Test
void exploitAbsolutePathCannotReplaceTheBaseDirectory() throws Exception {
    assertNotServed(requestFile(TMP.resolve("secret.txt").toString()));
}

@Test
void exploitBackslashesCannotClimbOutASeparatorOnWindows() throws Exception {
    assertNotServed(requestFile("..\\secret.txt"));
}

@Test
void exploitSiblingFolderThatSharesThePrefixIsOutside() throws Exception {
    // files-secret starts with "files" but is not inside files/.
    assertNotServed(requestFile("../files-secret/api-keys.txt"));
}

@Test
void exploitSymlinkInsideTheBaseCannotLeadOutsideIt() throws Exception {
    Files.createSymbolicLink(BASE_DIR.resolve("shared"), TMP.resolve("files-secret"));
    assertNotServed(requestFile("shared/api-keys.txt"));
}

@Test
void exploitFourDotsDoNotTurnBackIntoDotDotSlash() throws Exception {
    // A single-pass filter that deletes "../" turns "....//" into "../".
    assertNotServed(requestFile("....//secret.txt"));
}

Pitfalls

  • Path.resolve(other) returns other when it is absolute, and it does not normalise: /srv/files/../secret.txt still startsWith /srv/files as a Path until you call normalize() or toRealPath().
  • String.startsWith on path strings accepts /srv/files-secret. The widely copied check file.getCanonicalPath().startsWith(BASE_DIRECTORY), which PortSwigger's prevention example uses, has this flaw unless BASE_DIRECTORY ends with a separator; CodeQL's partial path traversal queries look for exactly that.
  • normalize() only rewrites the path; toRealPath() also resolves symlinks, as does File.getCanonicalPath() on Unix.
  • replace("../", "") makes one pass, so ....// turns back into ../.
  • Spring's static resource handling with a file: location (spring.web.resources.static-locations) answered 404 for ../ and %2e%2e, but served files through a symlinked folder and a symlinked file inside the location that point outside it (Spring Boot 4.1.1, checked with MockMvc).
  • Request parameters arrive decoded: a query value of %2e%2e%2f reaches the controller as ../ (the servlet container decodes it, and so does MockMvc).
  • On macOS, java.io.tmpdir lives under /var, a symlink to /private/var. Call toRealPath() on the base as well, or every legitimate file will be refused.
  • Files.readAllBytes on a directory throws a bare IOException ("Is a directory"), so the fixed store also requires Files.isRegularFile.
  • Checking and then reading leaves a window in which someone who can write inside the base can swap in a symlink. Keep the served tree read-only for untrusted users.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs path-traversal/java-spring does this for you).
  2. Against your own endpoint, request ?path=..%2Fsecret.txt, an absolute path and a file reached through a symlink that points outside the base; each must answer 404 without the file's content.
  3. If you use CodeQL, java/path-injection's models recognise this fixed pattern and java/partial-path-traversal-from-remote covers canonical-path string checks (neither was executed for this card). The Semgrep rule above misses Path.resolve() code, so keep the regression tests as the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix path traversal in Java and Spring Boot
ToolRuleWhat it found here
Semgrepjava.spring.security.injection.tainted-file-path.tainted-file-pathNo findings on this card: the rule is marked interfile, and the @RequestParam source (FileController) and the file access (impl/*) are in different files. Its sinks are new File(...), Paths.get(...), FileInputStream and similar; on a probe controller in one file it flagged Paths.get and new File, but not Files.readAllBytes(baseDir.resolve(path)), which is the API this card uses.Checked: Semgrep 1.180.0 (CE)
CodeQLjava/path-injectionFrom the models: @RequestParam parameters are remote sources (FlowSources.qll), Path.resolve and toRealPath carry taint, and Files.readAllBytes(Path) is a path-injection sink (ext/java.nio.file.model.yml). PathSanitizer.qll accepts a String or Path startsWith check on a value from normalize(), toRealPath(), getCanonicalPath() or getCanonicalFile(), which matches the fixed code; replace("../", "") is not one of its sanitisers, so the vulnerable and wrong code would be reported. That prefix check does not require a trailing separator.Checked: github/codeql main at b5b8165; not executed
CodeQLjava/partial-path-traversal-from-remoteNot applicable to this card's Path-based code. It reports String.startsWith calls on a File.getCanonicalPath() result whose argument is not built with a trailing separator, when remote input reaches them; java/partial-path-traversal (medium precision) reports the same pattern without requiring remote input. This is the check that catches the files-secret mistake in code that compares canonical path strings.Checked: github/codeql main at b5b8165; not executed
Find Security Bugs (SpotBugs plugin)PATH_TRAVERSAL_INPotential Path Traversal (file read)Not run on this card.Checked: Bug pattern listed on the Find Security Bugs site; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.springframework.boot:spring-boot-starter-webmvc-test4.1.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3
com.jayway.jsonpath:json-path (managed by Spring Boot; reads seed.json in the test)2.10.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the path-traversal/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).

Shell
mvn -B -q test
Shell
# Expected: every test passes
IMPL=fixed mvn -B -o -q test

# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test

# Expected: a test fails
IMPL=wrong mvn -B -o -q test

Each run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs path-traversal/java-spring runs all three modes and checks the expected results.

Download the project ZIP, 21 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • java OpenJDK 21.0.12.1 (Homebrew)
  • maven 3.9.16
  • springBoot 4.1.1
  • springFramework 7.0.9
  • junitJupiter 6.0.3
Matrix
fixed: 12 of 12 tests pass; vulnerable: 5 of 12 tests fail (5 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
Test results
IMPL=fixed 12/12 pass; IMPL=vulnerable 5/12 fail, all exploit tests (../, %2e%2e%2f, the absolute path, the files-secret sibling, the symlink); IMPL=wrong 3/12 fail, all exploit tests (the absolute path, the symlink, ....//). The ..\ test passes in every mode on macOS and Linux, where a backslash is an ordinary file-name character; it guards a Windows deployment.
Dependency freshness
Spring Boot 4.1.1 (2026-08-20) is the current 4.1.x GA and well over 14 days old; JUnit Jupiter 6.0.3 is the version Boot 4.1.1 manages, required because Spring Framework 7's SpringExtension needs Jupiter 6. .mvn/maven.config sets file-based resolver locking so concurrent Maven runs can share the local repository.
Scanners
Semgrep 1.180.0 from a throwaway venv (pip install semgrep==1.180.0), not a card dependency; CodeQL read from source, not executed.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP: Path Traversal
  2. OWASP WSTG: Testing Directory Traversal File Include
  3. Java SE 21 API: Path.resolve(String)
  4. Java SE 21 API: Path.toRealPath(LinkOption...)
  5. Java SE 21 API: Path.startsWith(Path)
  6. Spring Boot: Static content
  7. CodeQL: Partial path traversal vulnerability from remote
  8. PortSwigger Web Security Academy: Path traversal (prevention example)
  9. Find Security Bugs: PATH_TRAVERSAL_IN

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.