Skip to content

Folio cursor-read guard: harden lexical normalize_path against symlink escape (path_within_root) - #132

Merged
mikebronner merged 2 commits into
mainfrom
fix/116-folio-cursor-read-guard-harden-lexical-normalizepa
Jun 15, 2026
Merged

mikebronner merged 2 commits into
mainfrom
fix/116-folio-cursor-read-guard-harden-lexical-normalizepa

Conversation

@mikebronner

@mikebronner mikebronner commented Jun 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #116 — hardens the Folio cursor-read containment guard against the symlink-escape class (#55) that the lexical check left open.

folio_route_name_for_cursor previously gated its pre-read root-containment check with the lexical normalize_path(file_path).starts_with(normalize_path(&root)). A symlink under the project root pointing outside it passes that purely textual check while resolving to an out-of-tree file. This routes the read site through the existing canonicalize-based path_within_root helper, closing the symlink leg and making the doc comment's "defense in depth" claim accurate.

Changes

  • folio_route_name_for_cursor (laravel-lsp/src/main.rs): the containment guard now calls path_within_root(file_path, &root) instead of the lexical normalize_path prefix check. No new symlink-resolution logic — reuses the existing #55 helper as-is.
  • Updated the method's doc comment and the inline guard comment to state canonicalize-based containment rather than the lexical-only claim.
  • New #[cfg(unix)] #[tokio::test] under_root_symlink_to_outside_target_returns_none in laravel-lsp/src/tests/folio_cursor_containment.rs: seeds an under-root symlink (std::os::unix::fs::symlink) pointing outside the root, indexes the page under its in-root link path, and asserts folio_route_name_for_cursor returns None. Verified discriminating — it fails against the old lexical guard and passes against path_within_root.

Acceptance Criteria

  • folio_route_name_for_cursor replaces the normalize_path(...).starts_with(...) guard with a call to path_within_root(file_path, &root)
  • The fix reuses path_within_root as-is — no new symlink-resolution logic
  • New #[tokio::test] seeds an under-root symlink pointing outside the root, asserts None, and is discriminating (target exists and is indexed, so only the canonicalize guard can reject it)
  • Existing in_tree_page_still_resolves and out_of_root_page_returns_none_without_disk_read controls remain green
  • The method's doc comment now states canonicalize-based containment (path_within_root) so the "defense in depth" wording is accurate
  • cargo test, cargo fmt --check, and cargo clippy pass clean

Test Plan

  • cargo fmt --check — clean
  • cargo clippy --all-targets — clean (no lints)
  • Unit tests green: laravel_lsp lib (1735) + main bin (279), including the 3 folio_cursor_containment tests
  • Discrimination check: new test fails against the old lexical guard, passes against path_within_root
  • CI bootstraps the test-project fixture (.env + composer update) and runs the full suite on ubuntu-latest (Unix), where the #[cfg(unix)] test runs

Fixes #116

…ard.

Route folio_route_name_for_cursor's root-containment check through the
existing canonicalize-based path_within_root instead of the lexical
normalize_path prefix check. A symlink under the project root that
resolves outside it now returns None before any disk read, closing the
symlink-escape leg the lexical prefix check admitted.

Updates the method's doc comment to state canonicalize-based containment
so the defense-in-depth claim is accurate, and adds a discriminating
#[cfg(unix)] test that fails against the old lexical guard and passes
against path_within_root.

Fixes: #116
@mikebronner
mikebronner marked this pull request as ready for review June 15, 2026 06:09

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved

Review Summary

  • Reviewed PR #132 against issue #116's six acceptance criteria. The change swaps the lexical normalize_path(...).starts_with(...) containment guard in folio_route_name_for_cursor for the canonicalize-based path_within_root(file_path, &root), reuses that helper as-is (no new symlink logic), rewrites the doc/inline comments, and adds a #[cfg(unix)] test for the symlink-escape case.
  • Every acceptance criterion is met:
    • AC#1 ✅ guard now calls path_within_root(file_path, &root) (main.rs:4822).
    • AC#2 ✅ path_within_root (main.rs:18721) is reused untouched — not in the diff.
    • AC#3 ✅ under_root_symlink_to_outside_target_returns_none seeds a live under-root symlink to an outside target, indexes the page under the link path, asserts None. I verified it's genuinely discriminating: the link path is lexically inside root (the old starts_with guard would have admitted it), but canonicalize() resolves to the outside target, so only the new guard rejects it.
    • AC#4 ✅ in_tree_page_still_resolves and out_of_root_page_returns_none_without_disk_read are untouched.
    • AC#5 ✅ doc + inline comments now describe canonicalize-based containment.
    • AC#6 ✅ CI green — LSP — test, fmt, clippy passed.
  • Tests verified: discriminating coverage of the live symlink-escape, positive/negative controls intact, CI green.

This closes the actual exploitable leg (a live under-root symlink resolving outside root is now rejected, proven by the test) and strictly improves on the lexical-only status quo. Nicely minimal and exactly scoped.

Considered and dismissed (not blocking): the inline comment's "before any disk access" reads strictly-loose now that canonicalize() itself stats the disk — but in context "disk read/access" refers to the page-content read (document_or_disk_content), which still happens only after the guard, so the wording is accurate as intended.

📋 Non-blocking follow-ups

  • path_within_root's fallback arm is fail-open — main.rs:18724 (_ => path.starts_with(root)). When canonicalize() fails (a dangling under-root symlink, target absent), it reverts to the lexical prefix check and admits the path. Verified the mechanism is real, but the security impact is a defense-in-depth gap, not an exploit: a dangling symlink read returns None harmlessly, and even in a tight materialize-after-check TOCTOU no out-of-tree content is surfaced — the return value is a pre-indexed route name, never file bytes. Deliberately out of scope for #116 (AC#2 mandated reusing path_within_root as-is, and it's shared with #55's locate_view_file). Tracking separately.

Ready for @mikebronner to merge.

@mikebronner
mikebronner merged commit ecc3b23 into main Jun 15, 2026
5 checks passed
@mikebronner
mikebronner deleted the fix/116-folio-cursor-read-guard-harden-lexical-normalizepa branch June 15, 2026 11:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Folio cursor-read guard: harden lexical normalize_path against symlink escape (path_within_root)

1 participant