Skip to content

feat(sync-plugin): forbid symlinks in plugin dirs/files#627

Merged
lwshang merged 6 commits into
mainfrom
forbid-symlinks-in-sync-plugin-paths
Jun 26, 2026
Merged

feat(sync-plugin): forbid symlinks in plugin dirs/files#627
lwshang merged 6 commits into
mainfrom
forbid-symlinks-in-sync-plugin-paths

Conversation

@lwshang

@lwshang lwshang commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Declared dirs/files in a plugin sync step are resolved on the host before the WASI sandbox boundary, so the old lexical check (relative, no ..) wasn't enough — a symlinked entry, a symlinked parent, or a Windows drive-relative path (C:foo, not "absolute") could escape the canister directory.

Per a team decision, symlinks are now forbidden outright in plugin dirs/files while the feature is young; the restriction can be relaxed later. This PR also closes the related drive-relative escape and moves all dirs/files filesystem access into the sandbox crate.

What changed

Two crate-private helpers in icp-sync-plugin (path.rs), applied uniformly to dirs (before preopen) and files (before read):

  • escapes_base — rejects .., filesystem root, and Windows drive-prefix (C:foo) components, mirroring bundle.rs:861.
  • first_symlink_component — rejects an entry that is, or traverses, a symlink; returns the offending sub-path relative to base so errors don't leak absolute paths.

run_plugin now reads the declared files itself (files: Vec<String> instead of pre-read pairs), so the CLI just forwards manifest strings. Symlinks inside a preopen are still rejected by cap-std at runtime, unchanged.

Tests & docs

  • Unit tests for both helpers, incl. a #[cfg(windows)] test for the drive/UNC-prefix case (runs on Windows CI).
  • Runtime tests (symlinked_dir/symlinked_file/read_file errors) and e2e tests (deploy fails on a symlinked dirs/files); existing plugin happy-paths still pass.
  • Updated sync-plugins.md / configuration.md / DESIGN.md, plus a changelog entry under Unreleased > Experimental.

🤖 Generated with Claude Code

Declared `dirs`/`files` entries are resolved on the host before the WASI
sandbox boundary, so the lexical "relative, no `..`" check was not enough:
an entry that is a symlink — or traverses a symlinked parent component —
would let a preopen or a host read resolve to a target outside the canister
directory.

Add `first_symlink_component`, a shared helper that walks each component of
a declared path under the base directory and rejects the entry if any prefix
is a symlink. Apply it to `dirs` (runtime preopen) and `files` (host read),
with new `SymlinkDir`/`SymlinkFile` error variants. Symlinks inside a preopen
that escape it remain handled by cap-std at runtime.

Symlinks are forbidden outright for now; the restriction can be relaxed later
if a safe use case emerges. Update the concept/reference/design docs, which
previously implied all symlink escapes were rejected.

Tests: unit tests for the helper, a fixture-gated runtime test for the
SymlinkDir wiring, and two end-to-end tests asserting deploy fails when a
declared dir/file points outside the project via a symlink.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Recorded under the Unreleased > Experimental subsection, per the changelog
convention for the (experimental) sync-plugin feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens sync-plugin path handling by forbidding symlinks in declared dirs/files, preventing host-side resolution from escaping the canister directory before the WASI sandbox boundary.

Changes:

  • Added first_symlink_component(base, rel) helper and reused it across icp-sync-plugin runtime (dirs) and icp CLI sync (files).
  • Rejected symlinked declared paths with new error variants (RunPluginError::SymlinkDir, PluginError::SymlinkFile).
  • Updated documentation and added unit/runtime/integration tests covering final and intermediate symlink cases.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
docs/reference/configuration.md Documents the new “no symlinks in declared dirs/files” rule.
docs/concepts/sync-plugins.md Clarifies runtime sandbox behavior vs. declared-path symlink rejection.
crates/icp/src/canister/sync/plugin.rs Adds symlink rejection for host-side reads of declared files.
crates/icp-sync-plugin/src/runtime.rs Adds symlink rejection prior to preopening declared dirs; includes a fixture-gated test.
crates/icp-sync-plugin/src/path.rs Introduces shared helper to detect the first symlink component under a base dir + unit tests.
crates/icp-sync-plugin/src/lib.rs Exposes the new helper from the crate API.
crates/icp-sync-plugin/DESIGN.md Documents the declared-path symlink ban and rationale.
crates/icp-sync-plugin/Cargo.toml Adds camino-tempfile as a dev-dependency for new tests.
crates/icp-cli/tests/sync_tests.rs Adds E2E tests ensuring symlinked declared dirs/files are rejected.
Cargo.lock Updates lockfile for the new dev dependency.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/icp/src/canister/sync/plugin.rs Outdated
Comment thread crates/icp/src/canister/sync/plugin.rs Outdated
Comment thread crates/icp-sync-plugin/src/runtime.rs Outdated
Comment thread crates/icp-sync-plugin/src/runtime.rs Outdated
lwshang and others added 3 commits June 25, 2026 14:46
Address Copilot review on #627:

- Harden the lexical check: a Windows drive-relative path like `C:foo` carries
  a `Prefix` component but `is_absolute()` returns false, so the old check let
  it through and `join` would discard the base directory. Replace the ad-hoc
  `is_absolute() + ..` check with a shared `escapes_base` that rejects
  `ParentDir`, `RootDir`, and `Prefix(_)`, mirroring the bundler's escape
  checks. Used for both `dirs` and `files`.

- `first_symlink_component` now returns the offending sub-path relative to the
  base (e.g. `link` or `link/inner`) instead of the fully joined host path, so
  the error no longer leaks the absolute on-disk location.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the host-side reading of declared `files` from the CLI's
`sync/plugin.rs` into `run_plugin`, alongside the existing `dirs` preopen.
`run_plugin` now takes `files: Vec<String>` (manifest-relative names) instead
of pre-read `(name, content)` pairs and opens them itself from `base_dir`.

This keeps all filesystem access and path-safety logic in one place: the
`escapes_base` and `first_symlink_component` helpers are now crate-private
(no longer re-exported), and the file-path error variants (`UnsafeFile`,
`SymlinkFile`, `ReadFile`) live on `RunPluginError`. The CLI just forwards the
manifest's `dirs`/`files` strings.

Behavior is unchanged: the same checks apply to `files` as before, now
surfaced via `PluginError::Run`. Add runtime-level tests for the moved logic
(missing file -> ReadFile, symlinked file -> SymlinkFile) and update DESIGN.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `escapes_base` assertions lived in the `cfg(all(test, unix))` module, so
the Windows-specific `Prefix` rejection (`C:foo`) — the whole reason that
branch exists — was never compiled or run. CI runs on Windows, so split the
tests:

- A cross-platform `escapes_base_tests` module: shared assertions, plus a
  `#[cfg(windows)]` test asserting drive-relative/absolute/UNC prefixes escape,
  and a `#[cfg(unix)]` test documenting that `C:foo` is a plain filename there.
- The symlink tests stay unix-only (they create symlinks) as `symlink_tests`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Comment thread crates/icp-sync-plugin/src/path.rs Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@lwshang lwshang marked this pull request as ready for review June 25, 2026 19:39
@lwshang lwshang requested a review from a team as a code owner June 25, 2026 19:39
@lwshang lwshang merged commit fa03868 into main Jun 26, 2026
96 checks passed
@lwshang lwshang deleted the forbid-symlinks-in-sync-plugin-paths branch June 26, 2026 15:03
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.

3 participants