Add the fail-closed path_within_root guard to find_php_class_file_by_fqcn (FQCN → file resolution containment) - #221
Conversation
…ath_within_root guard find_php_class_file_by_fqcn splits an FQCN on `\`, filters only empty segments, then PathBuf::join's each into a candidate path. join does not resolve `..` on a non-absolute segment — it appends it literally — so an FQCN carrying `..` segments yields a path like `<root>/app/../../etc/secret.php` that path.exists()/the read then stats, a read primitive that can escape the project root. The same holds for a candidate whose path crosses an under-root symlink resolving outside root. Gate every candidate with the fail-closed path_within_root guard before the on-disk check, in both the app (!search_vendor) and vendor (search_vendor) branches — extending the path_within_root containment lineage (#130 → #143 → #148 → #194 → #199 → #201 → #214) to the last FS-touching resolver that lacked it. A candidate that canonicalizes outside root, or can't be proven in-root, is skipped. Add fqcn_resolution_containment.rs: app/vendor `..`-escape negatives, an under-root-symlink escape negative (#[cfg(unix)]), and in-root positive controls. Tests drive the public entry points (find_php_class_file / find_php_class_file_in_app_or_vendor), matching class_locator's existing test style and keeping the heuristic helper private. Fixes #218 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ Approved
Review Summary
- Reviewed PR #221 (+233/−1, 3 files) adding the fail-closed
path_within_rootguard tofind_php_class_file_by_fqcn— the last FS-touching resolver in the containment lineage (#130 → #143 → #148 → #194 → #199 → #201 → #214) that lacked it. Fanned out four blind lens reviewers (AC / correctness / security / test-honesty) over the checkout. - CI green — LSP test/fmt/clippy, wasm check, and CodeQL (js-ts + python) all pass. This is the first review round; no prior change-requests.
Acceptance criteria — all met
- ✅ Guard placed correctly —
if !path_within_root(&path, project_root) { continue; }sits after each candidate is constructed and immediately beforepath.exists()(class_locator.rs:148,:181). - ✅ Both branches — app (
!search_vendor,:148) and vendor (search_vendor,:181). Eachcontinueis correctly scoped inside its ownforloop (["app","src"]/["src",""]), so refusing one candidate still tries the sibling rather than aborting. - ✅ Containment test file —
tests/fqcn_resolution_containment.rscovers, for both public entry points: negative..-escape (App\..\..\secret,Vendor\Pkg\..\..\..\..\secret), positive control (App\Models\User,Laravel\Passport\Token), and a#[cfg(unix)]under-root-symlink escape — a superset of the three cases the AC asked for. - ✅ Registered —
mod fqcn_resolution_containment;intests/mod.rs:23. - ✅ No regression — existing
class_locator.rsunit tests untouched; CI's LSP test job is green. - ✅ Composer-autoload branch unchanged — the guard lives only inside
find_php_class_file_by_fqcn; theComposerAutoload::resolvecall sites are untouched (see follow-up below).
Why the guard-before-exists() ordering is sound
path_within_root → canonical_containment canonicalizes both path and root and is fail-closed (unwrap_or(false)). Because canonicalize() requires the target to exist, placing the guard before path.exists() drops no legitimate resolution: an in-root candidate that doesn't exist would also fail exists(), and the only behaviour that changes is an existing out-of-root candidate — now refused instead of read. Exactly the intended fix, nothing lost.
Tests are discriminating (not false-passing)
Each negative test writes the escaping file to disk outside the root and asserts a precondition (via canonicalize equality) that the constructed candidate resolves out-of-root and exists — so a None result can only come from the guard firing, never from mere absence. The basename-walk fallback can't rescue the out-of-root file either (WalkDir defaults to follow_links=false). Positive controls guard against over-fitting.
📋 Non-blocking follow-ups
ComposerAutoload::resolveis the one FS-touching resolver in the lineage with no containment guard —composer_autoload.rs:64-74. Itpushes each\-split FQCN segment literally (so..is appended verbatim) and returnscandidateon a barecandidate.exists(), with nopath_within_rootgate. This is the higher-priority branch that runs before the heuristic this PR just guarded, so #218's premise that it "returns a verified path" is inaccurate — it verifies existence, not containment. Low practical risk under the LSP threat model (composer.json/installed.json is developer-controlled, not an untrusted boundary), so non-blocking and out of #218's scope — but it's the natural next anchor for the lineage's stated goal that the invariant hold uniformly. Filed as a new issue (linked below).
Ready for @mikebronner to merge.
…root guard `ComposerAutoload::resolve` mapped a PSR-4 FQCN to a candidate file by splitting the post-prefix remainder on `\` and `PathBuf::push`-ing each segment onto the mapped `source_root`, then returned the candidate on a bare `candidate.exists()` — with no containment guard. `push`/`join` appends a `..` segment literally, and `source_root` derives from a PSR-4 mapping value in composer.json / installed.json, so a `..`-bearing FQCN (or a mapping / under-root symlink pointing outside the tree) yielded a candidate that escaped the project root and was then stat'd and returned: an out-of-root read primitive. `resolve` is the higher-priority branch in `class_locator.rs` (it runs before the heuristic `find_php_class_file_by_fqcn` that #218/PR #221 guarded), yet was the one FS-touching resolver in the lineage (#130 → #143 → #148 → #194 → #199 → #201 → #214 → #218) with no guard. Gate every candidate with the fail-closed `path_within_root` guard before the on-disk check. The project root is stored on `ComposerAutoload` at construction (`load`/`for_project` both already receive it) rather than threaded per-call, so resolution is bound to exactly the root the PSR-4 mappings were resolved against and no caller can pass a mismatched root — keeping the two `class_locator.rs` call sites and the existing unit tests unchanged. Add `tests/composer_autoload_containment.rs`: a `..`-escaping FQCN → None (with an out-of-root precondition so None can only be the guard), an in-root positive control → Some, and a `#[cfg(unix)]` under-root-symlink escape → None. Fixes #222
…ve (PSR-4 FQCN → file resolution containment) (#225) * chore: start work on #222 * 🔒️ fix(composer_autoload): gate resolve with fail-closed path_within_root guard `ComposerAutoload::resolve` mapped a PSR-4 FQCN to a candidate file by splitting the post-prefix remainder on `\` and `PathBuf::push`-ing each segment onto the mapped `source_root`, then returned the candidate on a bare `candidate.exists()` — with no containment guard. `push`/`join` appends a `..` segment literally, and `source_root` derives from a PSR-4 mapping value in composer.json / installed.json, so a `..`-bearing FQCN (or a mapping / under-root symlink pointing outside the tree) yielded a candidate that escaped the project root and was then stat'd and returned: an out-of-root read primitive. `resolve` is the higher-priority branch in `class_locator.rs` (it runs before the heuristic `find_php_class_file_by_fqcn` that #218/PR #221 guarded), yet was the one FS-touching resolver in the lineage (#130 → #143 → #148 → #194 → #199 → #201 → #214 → #218) with no guard. Gate every candidate with the fail-closed `path_within_root` guard before the on-disk check. The project root is stored on `ComposerAutoload` at construction (`load`/`for_project` both already receive it) rather than threaded per-call, so resolution is bound to exactly the root the PSR-4 mappings were resolved against and no caller can pass a mismatched root — keeping the two `class_locator.rs` call sites and the existing unit tests unchanged. Add `tests/composer_autoload_containment.rs`: a `..`-escaping FQCN → None (with an out-of-root precondition so None can only be the guard), an in-root positive control → Some, and a `#[cfg(unix)]` under-root-symlink escape → None. Fixes #222
Summary
Implements #218 — extends the fail-closed
path_within_rootcontainment lineage (#130 → #143 → #148 → #194 → #199 → #201 → #214) tofind_php_class_file_by_fqcn, the last FS-touching resolver that lacked the guard.find_php_class_file_by_fqcnmaps an FQCN to a candidate path by splitting on\andPathBuf::join-ing each segment.joindoes not resolve..on a non-absolute segment — it appends it literally — so an FQCN carrying..segments yields a path like<root>/app/../../etc/secret.phpthat the subsequentpath.exists()/read then stats: a read primitive that can escape the project root. The same holds for a candidate whose path crosses an under-root symlink resolving outside root.Changes
class_locator.rs: gate every candidate with the fail-closedpath_within_root(&path, project_root)guard before thepath.exists()check, in both the app (!search_vendor) and vendor (search_vendor) branches. A candidate that canonicalizes outside root — or can't be proven in-root — is skipped. Doc comment updated to record the guard.tests/fqcn_resolution_containment.rs(new):..-escape negatives for both branches, an under-root-symlink escape negative (#[cfg(unix)]), and in-root positive controls. Each negative is discriminating — the escaping file is written to disk outside root, soNonecan only come from the guard, not absence (asserted via preconditions).tests/mod.rs: register the new module.Implementation note for review
The new tests drive the public entry points (
find_php_class_file→ app branch;find_php_class_file_in_app_or_vendor→ vendor branch) rather than calling the privatefind_php_class_file_by_fqcndirectly. Reaching the helper from the test module (which compiles into the binary crate and imports vialaravel_lsp::) would require widening it topubacross the crate boundary. Keeping it private matchesclass_locator's existing test style (class_locator_and_properties.rsdrives the publicfind_php_class_file) and avoids exposing an internal heuristic fallback. The guard lives in the helper either way; the public callers exercise the exact branch each test targets. Open to making itpuband testing it directly if you'd prefer the AC-literal form.Acceptance Criteria
find_php_class_file_by_fqcn, after constructing each candidatePathBufand beforepath.exists(), apply the fail-closedpath_within_root(&path, project_root)guard —continuewhen it returnsfalse!search_vendor) and vendor (search_vendor) branches*_containment.rstest file (tests/fqcn_resolution_containment.rs):..segments resolving outside root →None(app + vendor branches)Some(path)(app + vendor)#[cfg(unix)]negative: an under-root symlink whose target resolves outside root →Nonetests/mod.rsclass_locatorunit testsComposerAutoload::resolve) is unchanged — out of scope for this guardTest Plan
cargo test --lib— 1894 passed (includes existingclass_locatorunit tests)cargo test --bin laravel-lsp— 421 passed (includes the 5 new containment tests)cargo clippy --all-targets— cleancargo fmt— cleantests/integration_tests.rsare unrelated — they depend on a gitignored, untrackedtest-project/.envfixture absent from any fresh clone, and referenceclass_locatorzero times. They fail on a cleanmaincheckout regardless of this change.Fixes #218