Compare path elements when checking that a served file is below the base directory - #1218
Merged
Conversation
pjfanning
added a commit
to potiuk/pekko-http
that referenced
this pull request
Aug 31, 2026
Motivation: Maintainer review found two claims that do not hold on main and one internal inconsistency: - §5 claimed Pekko HTTP writes no files, but fileUploadAll creates temp files and storeUploadedFile(s) writes entity bytes to an application-chosen destination (FileUploadDirectives.scala:178). - §9 and §14 Q3 described safeDirectoryChildPath as containing traversal, but its canonical-path check compares strings, so a symlink resolving into a sibling directory that shares the served root as a string prefix escapes it (fix in flight in apache#1218). - The status line said both "Q1-Q8 answered" and "all ten answered" while Q3 still ended in an open question, and the pinned commit was the PR's own first commit rather than the main commit reviewed. Modification: Restate the §5 file-system claim with the upload-directive carve-out and correct Q9 accordingly. Answer Q3 as a correction: the escape is VALID under §5b.4 and fixed by apache#1218; update §9 and the §15 back-map to match. Note in §12 that apache#1217 would invalidate the shutdown-hook claim on merge. Fix the status line, the tag tally, and the commit pin (444d939 -> 85d7243, the main commit the branch is based on). Result: Every §5 negative claim matches the source at the pinned commit, Q3 is answered consistently with the "all ten answered" status, and the two in-flight PRs that touch the model's claims (apache#1217, apache#1218) are cross-linked. Tests: Not run - docs only References: Refs apache#1218, Refs apache#1217
pjfanning
requested review from
He-Pin,
Philippus,
jrudolph,
nvollmar,
raboof and
samueleresca
August 31, 2026 12:46
Member
Author
|
Updated after review: rebased onto main and pushed 1144ef3 addressing the findings.
|
pjfanning
added a commit
to potiuk/pekko-http
that referenced
this pull request
Aug 31, 2026
Motivation: Both in-flight PRs the model references changed shape after review. apache#1218 gained a documented platform caveat - File.getCanonicalPath does not resolve NTFS symbolic links or junctions on Windows, so the link-escape class stays open there - and now rejects path segments that no file-system path may contain instead of erroring. apache#1217 replaced the per-file deleteOnExit with one temp directory per actor system removed by a CoordinatedShutdown task. Q3's answer claimed symlink escapes are rejected "whatever its target is named", which overclaims on Windows. Modification: Scope the Q3 and §9 symlink-rejection claims to platforms where canonicalization resolves links, record the Windows residual and the toRealPath follow-up, note the invalid-segment hardening, and update the §5 upload-directive note to describe apache#1217's per-system directory and CoordinatedShutdown cleanup. Result: The model's containment and file-writing claims match what apache#1217 and apache#1218 actually implement, on every platform they address. Tests: Not run - docs only References: Refs apache#1217, Refs apache#1218
…irectory Motivation: `checkIsSafeDescendant` compared the canonical location of the requested file with the canonical path of the served directory as a plain string prefix. A path such as `/var/www-private/secret` has `/var/www` as a string prefix without being contained in it, so a symbolic link inside the served directory that resolves to such a sibling directory passed the check and the file was served. The segment filter in `safeJoinPaths` does not catch this, because no path segment is suspicious; only canonicalization moves the location out of the directory. Modification: Compare the two canonical paths element by element via `java.nio.file.Path` instead of as strings. That keeps the base directory itself accepted, which the directory listing of `getFromBrowseableDirectory` relies on. Result: Only files that are really below the served directory are served, and a symbolic link to a sibling directory is rejected with the existing warning regardless of how the sibling is named. Tests: - sbt "http-tests/testOnly org.apache.pekko.http.scaladsl.server.directives.FileAndResourceDirectivesSymlinkSpec" - pass, 1 new test that serves a symlink to a sibling directory named after the served one; it fails without the change - sbt http-tests/test - pass - sbt http/mimaReportBinaryIssues - pass - sbt http/scalafmt http-tests/Test/scalafmt - clean References: None - tightens the containment check for file and resource directives
…ent-wise containment Motivation: Review of the containment change found that Paths.get can throw an unchecked InvalidPathException where the previous String.startsWith never threw: on Windows a decoded segment such as `a<b` passes safeJoinPaths and File.getCanonicalPath but not the stricter WindowsPathParser, turning a previously clean rejection into a 500 that skips the traversal warning and breaks rejection-based route chaining. A NUL byte in a segment (`%00`) had the same effect on every platform even before this PR, via the IOException that File.getCanonicalPath throws for it. The scaladoc above safeDirectoryChildPath also still described the string-prefix containment semantics that the previous commit removed as insecure, and the symlink spec wrote file content that no assertion reads. Modification: Catch InvalidPathException and IOException in checkIsSafeDescendant and treat such paths as not contained, producing the existing warning and rejection; no file with such a name can exist, so nothing servable is lost. Restate the scaladoc containment contract as element-wise comparison and note that Windows canonicalization does not resolve NTFS symbolic links or junctions. Create the sibling fixture file empty in the symlink spec. Rebased onto main. Result: A request segment that no file-system path may contain is rejected with the traversal warning instead of escaping to the exception handler as a 500, on all platforms; the documented contract matches the implementation. Tests: - sbt "http-tests/testOnly org.apache.pekko.http.scaladsl.server.directives.FileAndResourceDirectivesSpec org.apache.pekko.http.scaladsl.server.directives.FileAndResourceDirectivesSymlinkSpec" - pass (52 tests); new test rejects a %00 segment and asserts the traversal warning; verified it fails with the fix stashed (unhandled IOException) - sbt http/mimaReportBinaryIssues - pass - native scalafmt run on the three changed files - clean References: None - hardens the containment check follow-up from review
pjfanning
force-pushed
the
path-safety
branch
from
September 1, 2026 10:29
1144ef3 to
7c6f5c3
Compare
He-Pin
approved these changes
Sep 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
checkIsSafeDescendantinFileAndResourceDirectivesdecides whether a requested file is really below the served directory by comparing the two canonical paths as plain strings:A string prefix is not a path prefix. With
/var/wwwserved, the location/var/www-private/secretsatisfiesstartsWithwithout being contained in the served directory. The segment filter insafeJoinPathsdoes not catch this, since no path segment is suspicious — the path only moves out of the directory whengetCanonicalPathresolves a symbolic link that points at such a sibling. The file is then served.Modification
Compare the canonical paths element by element through
java.nio.file.Pathrather than as strings. The base directory itself still compares as contained, which the directory listing ingetFromBrowseableDirectoryrelies on (its root listing resolves to exactly the base path).Result
Only files that are genuinely below the served directory are served. A symbolic link into a sibling directory is rejected with the warning that was already there, whatever the sibling is called. Requests that were previously served are unaffected, and the existing symlink and traversal tests still pass.
Tests
sbt "http-tests/testOnly org.apache.pekko.http.scaladsl.server.directives.FileAndResourceDirectivesSymlinkSpec"- pass; new test serves through a symlink pointing at a sibling directory named after the served one, and asserts the request is rejected. Verified that it fails without the change (the file is served,handledistrue).sbt http-tests/test- pass (1484 tests)sbt http/mimaReportBinaryIssues- passsbt http/scalafmt http-tests/Test/scalafmt- cleanReferences
None - tightens the containment check for file and resource directives