Add the fail-closed path_within_root guard to resolve_component_file for containment-invariant uniformity - #203
Conversation
…ile. Mirror the sibling resolve_component_existing_file: after the file_exists_cached check, refuse any candidate whose real path escapes config.root before returning it. resolve_component_path already drops out-of-root candidates with the lexical path_within_root_lexical filter, so this is defense-in-depth — it makes the fail-closed containment invariant hold uniformly across every FS-touching component resolver (the #130 → #143 → #148 → #194 chain). Add component_file_navigation_containment.rs pinning the invariant at the resolve_component_file boundary: out-of-root and under-root-symlink escapes return None, in-root files still resolve. Fixes: #199 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@mikebronner — escalating #199 / PR #203. The guard implementation is correct, but the acceptance criteria I wrote in the #145 follow-up are contradicted by the codebase, which makes two of them unsatisfiable. This is a contract dispute, not a code-quality bounce — your call. I ran four blind lens reviews and verified the crux against What's solid ✅
The problem 🔴 (AC #3 + #4 — verified in
|
|
Option 1 |
…root targets Add a containment backstop at the file-create seam (FileAction::build_code_action, issue #199 AC #5/#6): refuse to offer any create quick-fix (View, BladeComponent, Livewire, Inertia, …) whose target_path escapes the project root, returning None instead of constructing an out-of-root ResourceOp::Create. Completes the "fail-closed containment on every FS-touching path" sweep (#130 → #143 → #148 → #194) across the third surface — the write seam, alongside the read/resolve paths. The guard uses path_within_root_lexical, NOT the fail-closed path_within_root the sibling read paths use: a create target never exists yet, so path.canonicalize() always fails for it and the fail-closed guard would refuse *every* create, including legitimate in-root ones. The lexical guard refuses out-of-root and interior-`..` escapes while admitting a not-yet-created in-root target, and still canonicalizes to catch symlink escapes when the target exists. (AC #5 named path_within_root; flagged on the PR — same class of AC defect as the #3/#4 dispute resolved via Option 1.) Add code_action_create_containment.rs: out-of-root, interior-`..`, in-root positive control, and under-root symlink-escape cases. Reframe the resolve-seam negative tests per the #199 escalation (Option 1): they assert the *invariant* (an out-of-root / symlink-escaping component never resolves), with the resolve_component_file guard documented as the innermost backstop whose reject branch is unreachable via the public API today — the upstream path_within_root_lexical filter catches both negatives first. Drop the false "only path_within_root catches" claim and correct the misleading assert messages. Fixes: #199 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Resumed after Mike's Option 1 decision. Done in this PR: AC #3/#4 reframe (Option 1). The resolve-seam negative tests now assert the invariant (an out-of-root / symlink-escaping component never resolves), with AC #5/#6 (write/create seam, folded in after the original review). Added a containment backstop at the top of
CI: |
There was a problem hiding this comment.
🔄 Changes Requested
One blocker. The resolve_component_file side is solid and the Option-1 reframe landed correctly — but the new build_code_action backstop (AC #5/#6) only guards one of the two files that the multi-file create actions emit, so a forged diagnostic can still coax an out-of-root create. That's the exact containment-escape this whole chain exists to close, on code this PR added.
🔴 Issues Found
The write-seam guard checks only self.target_path; Livewire and BladeComponentWithClass each emit a second, unguarded create. (main.rs:2495–2499)
The guard validates path_within_root_lexical(&self.target_path, root) and nothing else. But:
- Livewire (
main.rs:2502–2570) creates two files —file_uri(fromtarget_path, guarded ✅) andview_urifromself.get_livewire_view_path(root)(:2505–2506, unguardedResourceOp::Create).get_livewire_view_path(:2454) isself.name.replace('.', "/")joined underresources/views/livewire/— andPathBuf::joinof an absolute segment replaces the base, soname = "/etc/passwd"yields/etc/passwd.blade.php. - BladeComponentWithClass (
main.rs:2571–2640) creates two files —file_uri(guarded ✅) andclass_urifromself.get_component_class_path(root)(:2574–2575, unguarded).get_component_class_path(:2369)PathBuf::push-es eachself.name.split('.')segment underapp/View/Components— andpushof an absolute-looking segment replaces the whole path, soname = "/etc/passwd"yields/etc/passwd.php.
Why this is reachable, not theoretical. FileAction::from_diagnostic (main.rs:2143) extracts target_path (from extract_expected_path — the "Expected at:" line) and name (from extract_name_from_diagnostic — the quoted component) as two independent fields. Nothing couples them. A forged/malformed laravel diagnostic — the precise threat model your own guard comment (:2476–2479) and the test-file doc cite — can set an in-root target_path (guard passes) and name = "/etc/passwd" at the same time. Result: the guard offers the action, and the editor materialises an out-of-root file via the second ResourceOp::Create.
Root cause is the guard's own premise. The comment at main.rs:2478 asserts "Every create action (View, BladeComponent, Livewire, Inertia, …) materialises self.target_path." That's false for exactly the two multi-file types above — they materialise target_path plus a name-derived sibling. AC #5 names all four types ("no file-create action (View, BladeComponent, Livewire, Inertia) can be issued for a target path outside the project root"); the guard currently satisfies that intent only for the single-file types (View, anonymous BladeComponent), not the multi-file ones.
Fix
Guard every path a create action emits, not just target_path. Concretely: in the Livewire and BladeComponentWithClass branches, after computing view_path / class_path, run the same path_within_root_lexical(&path, root) check and return None if it escapes — so neither sibling create can be offered out-of-root. (Same lexical primitive, same reason as target_path: these are speculative not-yet-created paths.) Then correct the :2478 comment to reflect that multi-file actions emit more than target_path.
Tests to add (same PR)
In code_action_create_containment.rs, add non-vacuous coverage for the second file — each must return Some today (proving it fails without the fix) and None after:
- A
Livewireaction with an in-roottarget_pathbut anamewhoseget_livewire_view_pathescapes (e.g./-rooted or interior-..name) →None. - A
BladeComponentWithClassaction with an in-roottarget_pathbut anamewhoseget_component_class_pathescapes →None. - Positive controls for both multi-file types (legit in-root
name) → action still offered.
✅ What's Good
- AC #1 — guard
if !path_within_root(&path, &config.root) { continue; }afterfile_exists_cachedinresolve_component_filemirrors the siblingresolve_component_existing_file(:13887) exactly — verified line-by-line: sameconfig.root, samecontinue. Parity is real. - AC #2 — consumer call sites (
:17895,:23056,:23124) untouched; the guard lives in the resolver. - AC #3/#4 (Option 1) — the resolve-seam negative tests now honestly assert the invariant with the guard documented as the unreachable-today innermost backstop; the false "only
path_within_rootcatches" premise is gone and the assert messages were corrected. Clean execution of Mike's ruling. - AC #5 primitive choice — using
path_within_root_lexicalinstead of the literally-namedpath_within_rootis correct and approved, no re-escalation needed: a create target never exists, so fail-closedpath_within_rootwould refuse every create and break AC #6's positive control. The lexical primitive is the only viable one (refuses out-of-root + interior-.., admits speculative in-root, still canonicalizes for symlink escapes). Same class as the Option-1 call — the AC #5 wording should be amended to namepath_within_root_lexical; the code is right. Good catch flagging it rather than silently following a self-contradictory AC. - The
target_path-targeted create-seam tests (out-of-root, interior-.., under-root symlink, positive control) are non-vacuous and well-built.
📋 Non-blocking follow-ups
- None.
CI is fully green and 0 prior change rounds — this is round 1. Please extend the guard to the sibling create paths, add the two multi-file escape tests, and re-request review.
…tions The write/create-seam backstop (issue #199) only validated `self.target_path`, but the multi-file action types each emit a SECOND `ResourceOp::Create`: - `Livewire` → a Blade view at `get_livewire_view_path` - `BladeComponentWithClass` → a PHP class at `get_component_class_path` Both paths are derived from `self.name` — an independent diagnostic field, not coupled to `target_path`. Because `PathBuf::join`/`push` of an absolute-looking segment *replaces* the base, a forged diagnostic with an in-root `target_path` and `name = "/etc/passwd"` slipped past the guard and materialised a file outside the project root via the second create — the exact containment escape this chain exists to close. Guard each sibling path with `path_within_root_lexical(&path, root)` and return `None` on escape, and correct the guard comment that wrongly asserted every create materialises only `target_path`. Adds non-vacuous multi-file coverage in `code_action_create_containment.rs`: a `Livewire` / `BladeComponentWithClass` action with an in-root `target_path` but an escaping `name`-derived view/class path returns `None` (each would be `Some` without the guard), plus in-root positive controls for both types. Refs #199 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Round 2 — Holmes's blocker fixed in this PR (no non-blocking follow-ups were listed). Blocker: the write-seam guard checked only
Since Tests (same PR, non-vacuous): in
Local: |
There was a problem hiding this comment.
✅ Approved
Round 2 — the round-1 blocker is fully closed. 🔒️
Review Summary
- Reviewed the containment guards on both seams: the read/resolve seam (
resolve_component_file) and the write/create seam (build_code_action). CI fully green (LSP test/fmt/clippy ✅). - The round-1 blocker is resolved. The write-seam guard now covers every path a create action emits, not just
self.target_path:Livewire— theview_pathsibling (derived from the independentnamefield) is guarded atmain.rs:2517before itsResourceOp::Create.BladeComponentWithClass— theclass_pathsibling is guarded atmain.rs:2592before itsResourceOp::Create.- The misleading "every create materialises only
self.target_path" premise in the guard comment was corrected to spell out the multi-file second create.
- Tests now exercise the fix, non-vacuously.
livewire_out_of_root_view_path_returns_noneandblade_component_with_class_out_of_root_class_path_returns_nonepin an in-roottarget_pathagainst an escapingname = "/etc/passwd"and assertNone— each would returnSome(_)(fail) if its sibling guard were removed. Positive controls (livewire_in_root_is_offered,blade_component_with_class_in_root_is_offered) confirm legitimate multi-file creates still resolve.
Acceptance Criteria — all met
- AC #1 ✅ —
if !path_within_root(&path, &config.root) { continue; }afterfile_exists_cached, mirroringresolve_component_existing_file;config.root+continueexactly as specified. - AC #2 ✅ — consumer call sites untouched; guard lives in the resolver.
- AC #3 ✅ —
component_file_navigation_containment.rswith all three required tests (existing-file refusal, in-root positive control,#[cfg(unix)]under-root→outside symlink). - AC #4 ✅ — guard exercised; no regressions (CI green).
- AC #5 ✅ — every
ResourceOp::Createtarget backstopped before construction. Met via the Mike-approved divergence topath_within_root_lexical(a create target never exists, so fail-closedpath_within_rootwould refuse every legitimate create) — settled in round 1, not re-litigated here. - AC #6 ✅ — create-seam containment tests for
target_path(out-of-root, interior-.., under-root symlink, positive control) plus the two multi-file sibling-escape tests + positive controls.
📋 Non-blocking follow-ups
- None.
Verdict reached via a four-lens fan-out (AC / correctness / security / test-honesty) over the shared checkout — all four returned clean, no blocker-class findings to verify. Ready for @mikebronner to merge.
Summary
Implements #199 — extends the fail-closed containment invariant across two
FS-touching surfaces so it holds uniformly (the #130 → #143 → #148 → #194 chain):
resolve_component_file(anonymous Blade componenthover / prop completion) gains the containment guard mirroring the sibling
resolve_component_existing_file.FileAction::build_code_actionrefuses to offer anyfile-create quick-fix (View, BladeComponent, Livewire, Inertia, …) whose target
escapes the project root — including the second file the multi-file types
emit (Livewire view, component PHP class).
Changes
main.rs—resolve_component_file: afterfile_exists_cached, refuse anycandidate whose real path escapes
config.root(if !path_within_root(&path, &config.root) { continue; }), mirroringresolve_component_existing_file.main.rs—build_code_action(write/create seam, AC refactor: 👽️ Update diagnostic source from 'laravel-lsp' to 'laravel'. #5): before anyResourceOp::Createis constructed, returnNoneiftarget_pathescapes theknown project
root. Usespath_within_root_lexical(see deviation note below).main.rs—build_code_actionmulti-file sibling guards (round 2, Holmes review):LivewireandBladeComponentWithClasseach emit a secondResourceOp::Create(
view_uri/class_uri) derived fromself.name— an independent diagnosticfield. Because
PathBuf::join/pushof an absolute segment replaces the base, aforged
name(e.g./etc/passwd) escaped the root past thetarget_pathcheck.Each sibling path is now guarded with
path_within_root_lexical+return None,and the guard comment claiming every create materialises only
target_pathiscorrected.
src/tests/code_action_create_containment.rs(write/createseam, incl. multi-file sibling-path coverage) and reframed
component_file_navigation_containment.rs(resolve seam).Acceptance Criteria
resolve_component_fileafterfile_exists_cached,using
config.root+continue— mirrorsresolve_component_existing_file.*_navigation_containment.rsstyle:out_of_root_component_file_returns_none,in_root_component_file_still_resolves,#[cfg(unix)] under_root_symlink_to_outside_target_returns_none.existing tests regress. (Reframed per the escalation — Option 1.)
build_code_action'sResourceOp::Createpath gains a containmentbackstop before construction — no out-of-root create is offered for any action
type, including the second create emitted by the multi-file types; returns
Noneinstead. (Primitive deviation flagged below.)target_path→
None; valid in-root path still produces the code action (positive control);plus interior-
..,#[cfg(unix)]under-root-symlink, and multi-filesibling-path escape cases (Livewire view + component class) with positive
controls for both.
Escalation resolution — Option 1 (AC #3/#4 reframe)
Per the adjudicated dispute (Mike: Option 1), the resolve-seam negative tests
now assert the invariant — an out-of-root / symlink-escaping component never
resolves — not the new guard's reject branch in isolation. Through the public
API that reject branch is unreachable today: the upstream
path_within_root_lexicalfilter in
resolve_component_pathcanonicalizes lexically-in-root candidates anddrops both negative-test candidates before the loop runs, so each negative
Nonecomes from that upstream filter, with the resolver guard as the documented innermost
backstop. The false "only
path_within_rootcatches" premise and the misleadingassert messages are corrected.
AC #5 says "add a
path_within_rootbackstop." Implemented withpath_within_root_lexicalinstead, becausepath_within_rootis fail-closedon un-canonicalizable paths (
canonical_containment(...).unwrap_or(false)), and acreate target never exists yet —
path.canonicalize()always fails for it. Usingpath_within_rootwould refuse every create, including legitimate in-root ones(the AC #6 positive control would fail).
path_within_root_lexicalis thepurpose-built primitive for a speculative emitted path: it refuses out-of-root and
interior-
..escapes while admitting a not-yet-created in-root target, and stillcanonicalizes to catch symlink escapes when the target does exist. Holmes approved
this choice in review (same class as the #3/#4 call); the AC wording should be
amended to name
path_within_root_lexical.Test Plan
cargo test --all-featuresgreen: 1855 + 364 + 80 = 2299, 0 failures(the 364 reflects the +4 multi-file sibling-path tests added this round), after
bootstrapping the gitignored
test-project/.env+ composer fixtures exactly as CI does.their positive controls pass; each negative would return
Some/resolve if itsguard were removed — none is vacuous.
cargo fmt --checkclean;cargo clippy --all-targets --all-features -- -D warningsclean.Fixes #199