Run slot-navigation containment guard before file_exists_cached to close out-of-root existence oracle - #159
mikebronner wants to merge 2 commits into
Conversation
`create_slot_location` ran `path_within_root` *after* `file_exists_cached`, so an out-of-root candidate (e.g. from a `loadViewsFrom(__DIR__ . '/../../etc', 'ns')`-style namespace) was still `stat`ed on disk before the guard rejected it — an existence oracle on paths outside the project root. Move the containment check to be the first statement in the loop body so an out-of-root path is never probed on disk; the existing guard is relocated, not duplicated. Behaviour for in-root candidates is unchanged. Add `out_of_root_candidate_is_never_stated`: it asserts the out-of-root candidate never enters `file_exists_cache` (which is only written after a `stat`), proving the guard runs before the existence check. The existing result-level test passes under either ordering, so it can't guard the reorder. Fixes #145
|
🛑 Escalating to @mikebronner — AC premise is contradicted by the codebase, not a routine bounce. The PR cleanly does what AC #1/#2 say (reorder 🔎 paths.retain(|path| match (path.canonicalize(), &canonical_root) {
(Ok(real_path), Ok(real_root)) => real_path.starts_with(real_root),
_ => normalize_path(path).starts_with(&self.root),
});This filter is pre-existing (issue #55), untouched by this PR. Three consequences, all verified against the tree:
This is why it's a dispute, not a request-changes: no Options
Recommendation: option 2. It's the only path that delivers #145's actual security goal — out-of-root candidates are probed on disk today, just by Context: AC #1/#2 structurally met, AC #3 not provable at this layer, AC #4 met, AC #5 green (LSP test/fmt/clippy all pass). 0 prior change-rounds — escalating on the contract, not the strike count. Two tangential, pre-existing observations noted separately so they aren't lost: |
|
Closing unmerged — re-scoping #145 per review. Holmes's escalation established that this PR's reorder is inert: The actual out-of-root existence probe is the No code from this branch is being carried forward. |
…ore canonicalize `path_within_root_lexical` canonicalized the candidate first and only fell back to the lexical check, so an out-of-root candidate that exists on disk was `stat`/`realpath`-probed before being rejected — an existence oracle on paths outside the project root (#145). The probe lives in `resolve_component_path`'s `retain` filter, the real oracle Holmes traced when PR #159's inert `create_slot_location` reorder was dropped. Gate the helper on a lexical `starts_with` that never canonicalizes the candidate: a path under neither the root as given nor its canonicalized form is refused without a probe. Canonicalizing the *root* (a trusted in-root path) is not an oracle and preserves the macOS `/var`->`/private/var` symlinked-root tolerance. A lexically-in-root candidate is still canonicalized to reject symlink escapes, and speculative not-yet-created candidates are still admitted — results are unchanged in every reachable case, only the out-of-root disk probe is removed. Add a regression test that proves the lexical reject precedes canonicalize: an out-of-root symlink that would canonicalize back inside the root is rejected, which fails under the old canonicalize-first order. Fixes #145
…lve_component_path (#197) * chore: start work on #145 * fix(path_containment): 🔒️ reject out-of-root candidates lexically before canonicalize `path_within_root_lexical` canonicalized the candidate first and only fell back to the lexical check, so an out-of-root candidate that exists on disk was `stat`/`realpath`-probed before being rejected — an existence oracle on paths outside the project root (#145). The probe lives in `resolve_component_path`'s `retain` filter, the real oracle Holmes traced when PR #159's inert `create_slot_location` reorder was dropped. Gate the helper on a lexical `starts_with` that never canonicalizes the candidate: a path under neither the root as given nor its canonicalized form is refused without a probe. Canonicalizing the *root* (a trusted in-root path) is not an oracle and preserves the macOS `/var`->`/private/var` symlinked-root tolerance. A lexically-in-root candidate is still canonicalized to reject symlink escapes, and speculative not-yet-created candidates are still admitted — results are unchanged in every reachable case, only the out-of-root disk probe is removed. Add a regression test that proves the lexical reject precedes canonicalize: an out-of-root symlink that would canonicalize back inside the root is rejected, which fails under the old canonicalize-first order. Fixes #145 * test(path_containment): ✅ cover symlinked-root tolerance and lexical symlink-escape Adds the two regression tests Holmes flagged in his review of PR #197 — both on branches this PR introduced in path_within_root_lexical and both backing guarantees AC #2 names explicitly: - lexical_admits_in_root_candidate_under_symlinked_root: exercises the new root.canonicalize() leg of the lexical gate (path_containment.rs:91-95) — the macOS /var→/private/var symlinked-root tolerance. Root is passed as a symlink, the candidate carries the resolved prefix, and a precondition proves the first starts_with(root) leg fails so only the root-canonicalize leg can admit it. Guards against silent in-root goto-definition breakage on macOS. - lexical_refuses_in_root_symlink_escaping_the_root: asserts the in-root symlink-escape rejection (canonical_containment at :103, the #55/#134 no-downgrade guarantee) directly on path_within_root_lexical, not only on the fail-closed path_within_root. Both mirror the existing #[cfg(unix)] symlink-test idiom. Refs #145 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Implements #145. The slot-navigation goto-definition guard in
create_slot_location(laravel-lsp/src/main.rs) ranpath_within_rootafterfile_exists_cached, so an out-of-root candidate — e.g. from aloadViewsFrom(__DIR__ . '/../../etc', 'ns')-style namespace — was stillstated on disk before the containment guard rejected it. That probe is an existence oracle on paths outside the project root. Reversing the order closes it.Changes
create_slot_location: moved thepath_within_root(&path, &config.root)containment check to be the first statement in thefor path in possible_pathsloop body, ahead ofself.file_exists_cached(&path).await. The pre-existing guard (added in Add path_within_root containment guard to slot-navigation goto-definition disk read #143) is relocated, not duplicated. In-root behaviour is unchanged.out_of_root_candidate_is_never_statedinlaravel-lsp/src/tests/slot_navigation_containment.rs: it resolves an out-of-rootcardview that exists on disk and asserts the path never entersfile_exists_cache(which is written only after astat), proving the guard fires before the existence check. The existing result-level test passes under either ordering, so it can't guard the reorder on its own.Acceptance Criteria
path_within_root(&path, &config.root)is the first check in the loop, precedingfile_exists_cachedso no syscall is made for an out-of-root candidate;config.rootcomes from the in-scopeget_cached_config()resultfile_exists_cached, not duplicatedslot_navigation_containment.rsseeds a component whose resolved path exists on disk outside the project root and assertscreate_slot_locationreturnsNone(existingout_of_root_component_view_returns_none_without_disk_read); strengthened byout_of_root_candidate_is_never_stated, which proves nostatis madein_root_component_view_still_resolvesandslot_variable_resolutiontests green)cargo test slot_passes green;cargo fmt --checkandcargo clippy --all-targetsare cleanTest Plan
cargo test slot_— 21 + 10 tests pass (includes the new regression test)cargo fmt --check— cleancargo clippy --all-targets— clean (no lint warnings)Fixes #145